diff --git a/frontend-modern/src/api/ai.ts b/frontend-modern/src/api/ai.ts index 2facd5f..3779d81 100644 --- a/frontend-modern/src/api/ai.ts +++ b/frontend-modern/src/api/ai.ts @@ -32,6 +32,13 @@ export class AIAPI { }) as Promise; } + // Test a specific provider connection + static async testProvider(provider: string): Promise<{ success: boolean; message: string; provider: string }> { + return apiFetchJSON(`${this.baseUrl}/ai/test/${provider}`, { + method: 'POST', + }) as Promise<{ success: boolean; message: string; provider: string }>; + } + // Get available models from the AI provider static async getModels(): Promise<{ models: { id: string; name: string; description?: string }[]; error?: string }> { return apiFetchJSON(`${this.baseUrl}/ai/models`) as Promise<{ models: { id: string; name: string; description?: string }[]; error?: string }>; diff --git a/frontend-modern/src/components/Settings/AISettings.tsx b/frontend-modern/src/components/Settings/AISettings.tsx index 6148c72..0cb3d6a 100644 --- a/frontend-modern/src/components/Settings/AISettings.tsx +++ b/frontend-modern/src/components/Settings/AISettings.tsx @@ -66,6 +66,10 @@ export const AISettings: Component = () => { // Accordion state for provider configuration sections const [expandedProviders, setExpandedProviders] = createSignal>(new Set(['anthropic'])); + // Per-provider test state + const [testingProvider, setTestingProvider] = createSignal(null); + const [providerTestResult, setProviderTestResult] = createSignal<{ provider: string; success: boolean; message: string } | null>(null); + const [form, setForm] = createStore({ enabled: false, provider: 'anthropic' as AIProvider, // Legacy - kept for compatibility @@ -316,6 +320,27 @@ export const AISettings: Component = () => { } }; + const handleTestProvider = async (provider: string) => { + setTestingProvider(provider); + setProviderTestResult(null); + try { + const result = await AIAPI.testProvider(provider); + setProviderTestResult(result); + if (result.success) { + notificationStore.success(`${provider}: ${result.message}`); + } else { + notificationStore.error(`${provider}: ${result.message}`); + } + } catch (error) { + logger.error(`[AISettings] Test ${provider} failed:`, error); + const message = error instanceof Error ? error.message : 'Connection test failed'; + setProviderTestResult({ provider, success: false, message }); + notificationStore.error(`${provider}: ${message}`); + } finally { + setTestingProvider(null); + } + }; + // OAuth handlers removed - OAuth is currently unavailable from Anthropic for third-party apps // When OAuth becomes available, handlers can be added back to the Anthropic accordion section @@ -550,16 +575,35 @@ export const AISettings: Component = () => { -
+
setForm('anthropicApiKey', e.currentTarget.value)} - placeholder={settings()?.anthropic_configured ? '••••••••••• (configured)' : 'sk-ant-... (from console.anthropic.com)'} + placeholder={settings()?.anthropic_configured ? '••••••••••• (configured)' : 'sk-ant-...'} class={controlClass()} disabled={saving()} /> -

Get your API key from console.anthropic.com

+
+

+ Get API key → +

+ + + +
+ +

+ {providerTestResult()?.message} +

+
@@ -593,7 +637,7 @@ export const AISettings: Component = () => { type="password" value={form.openaiApiKey} onInput={(e) => setForm('openaiApiKey', e.currentTarget.value)} - placeholder={settings()?.openai_configured ? '••••••••••• (configured)' : 'sk-... (from platform.openai.com)'} + placeholder={settings()?.openai_configured ? '••••••••••• (configured)' : 'sk-...'} class={controlClass()} disabled={saving()} /> @@ -605,7 +649,26 @@ export const AISettings: Component = () => { class={controlClass()} disabled={saving()} /> -

Get your API key from platform.openai.com

+
+

+ Get API key → +

+ + + +
+ +

+ {providerTestResult()?.message} +

+
@@ -634,16 +697,35 @@ export const AISettings: Component = () => { -
+
setForm('deepseekApiKey', e.currentTarget.value)} - placeholder={settings()?.deepseek_configured ? '••••••••••• (configured)' : 'sk-... (from platform.deepseek.com)'} + placeholder={settings()?.deepseek_configured ? '••••••••••• (configured)' : 'sk-...'} class={controlClass()} disabled={saving()} /> -

Get your API key from platform.deepseek.com

+
+

+ Get API key → +

+ + + +
+ +

+ {providerTestResult()?.message} +

+
@@ -672,7 +754,7 @@ export const AISettings: Component = () => { -
+
{ class={controlClass()} disabled={saving()} /> -

URL where your Ollama server is running. No API key needed - it's free!

+
+

+ Learn about Ollama → + · Free & local +

+ + + +
+ +

+ {providerTestResult()?.message} +

+
diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index 91d71ba..2a7c4f8 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -456,6 +456,75 @@ func (h *AISettingsHandler) HandleTestAIConnection(w http.ResponseWriter, r *htt } } +// HandleTestProvider tests a specific AI provider connection (POST /api/ai/test/:provider) +func (h *AISettingsHandler) HandleTestProvider(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Require admin authentication + if !CheckAuth(h.config, w, r) { + return + } + + // Get provider from URL path (e.g., /api/ai/test/anthropic -> anthropic) + provider := strings.TrimPrefix(r.URL.Path, "/api/ai/test/") + if provider == "" || provider == r.URL.Path { + http.Error(w, `{"error":"Provider is required"}`, http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + var testResult struct { + Success bool `json:"success"` + Message string `json:"message"` + Provider string `json:"provider"` + } + testResult.Provider = provider + + // Load config and create provider for testing + cfg := h.aiService.GetConfig() + if cfg == nil { + testResult.Success = false + testResult.Message = "AI not configured" + utils.WriteJSONResponse(w, testResult) + return + } + + // Check if provider is configured + if !cfg.HasProvider(provider) { + testResult.Success = false + testResult.Message = "Provider not configured" + utils.WriteJSONResponse(w, testResult) + return + } + + // Create provider and test connection + testProvider, err := providers.NewForProvider(cfg, provider, cfg.GetModel()) + if err != nil { + testResult.Success = false + testResult.Message = fmt.Sprintf("Failed to create provider: %v", err) + utils.WriteJSONResponse(w, testResult) + return + } + + err = testProvider.TestConnection(ctx) + if err != nil { + testResult.Success = false + testResult.Message = err.Error() + } else { + testResult.Success = true + testResult.Message = "Connection successful" + } + + if err := utils.WriteJSONResponse(w, testResult); err != nil { + log.Error().Err(err).Msg("Failed to write provider test response") + } +} + // HandleListModels fetches available models from the configured AI provider (GET /api/ai/models) func (h *AISettingsHandler) HandleListModels(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { diff --git a/internal/api/router.go b/internal/api/router.go index d070f77..a5dfb8e 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1076,6 +1076,7 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/settings/ai", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.aiSettingsHandler.HandleGetAISettings))) r.mux.HandleFunc("/api/settings/ai/update", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.aiSettingsHandler.HandleUpdateAISettings))) r.mux.HandleFunc("/api/ai/test", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.aiSettingsHandler.HandleTestAIConnection))) + r.mux.HandleFunc("/api/ai/test/{provider}", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.aiSettingsHandler.HandleTestProvider))) r.mux.HandleFunc("/api/ai/models", RequireAuth(r.config, r.aiSettingsHandler.HandleListModels)) r.mux.HandleFunc("/api/ai/execute", RequireAuth(r.config, r.aiSettingsHandler.HandleExecute)) r.mux.HandleFunc("/api/ai/execute/stream", RequireAuth(r.config, r.aiSettingsHandler.HandleExecuteStream))