From 0c3dcf353a02690b22772654ac42af8085ee0adf Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 11 Dec 2025 16:00:45 +0000 Subject: [PATCH] feat: Implement multi-provider AI support Backend: - Add per-provider API key fields to AIConfig (AnthropicAPIKey, OpenAIAPIKey, DeepSeekAPIKey, OllamaBaseURL, OpenAIBaseURL) - Add NewForProvider() and NewForModel() factory functions for multi-provider instantiation - Update ListModels() to aggregate models from all configured providers with provider:model format - Update Execute/ExecuteStream to dynamically create provider based on selected model - Update TestConnection to use multi-provider aware provider creation - Add helper functions: HasProvider(), GetConfiguredProviders(), GetAPIKeyForProvider(), GetBaseURLForProvider(), ParseModelString(), FormatModelString() Frontend: - Remove legacy single-provider UI (provider grid, single API key input, single base URL) - Add accordion-style UI for configuring all providers independently - Add model grouping by provider in selectors using optgroup - Update AIChat model dropdown with grouped provider sections - Add helper functions for parsing provider from model ID and grouping models API: - Add multi-provider fields to AISettingsResponse and AISettingsUpdateRequest - Add /api/ai/models endpoint for dynamic model listing - Update settings handlers for per-provider credential management --- frontend-modern/src/api/ai.ts | 5 + frontend-modern/src/api/patrol.ts | 2 +- frontend-modern/src/components/AI/AIChat.tsx | 121 ++- .../src/components/Settings/AISettings.tsx | 794 ++++++++++-------- frontend-modern/src/pages/Alerts.tsx | 11 +- frontend-modern/src/stores/aiChat.ts | 9 +- frontend-modern/src/types/ai.ts | 41 +- internal/ai/patrol.go | 37 +- internal/ai/providers/anthropic.go | 44 + internal/ai/providers/anthropic_oauth.go | 49 ++ internal/ai/providers/factory.go | 62 ++ internal/ai/providers/ollama.go | 42 + internal/ai/providers/openai.go | 55 ++ internal/ai/providers/provider.go | 12 + internal/ai/service.go | 224 ++++- internal/api/ai_handlers.go | 143 +++- internal/api/router.go | 1 + internal/config/ai.go | 184 +++- 18 files changed, 1414 insertions(+), 422 deletions(-) diff --git a/frontend-modern/src/api/ai.ts b/frontend-modern/src/api/ai.ts index c8b9122..2facd5f 100644 --- a/frontend-modern/src/api/ai.ts +++ b/frontend-modern/src/api/ai.ts @@ -32,6 +32,11 @@ export class AIAPI { }) as Promise; } + // 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 }>; + } + // Start OAuth flow for Claude Pro/Max subscription // Returns the authorization URL to redirect the user to static async startOAuth(): Promise<{ auth_url: string; state: string }> { diff --git a/frontend-modern/src/api/patrol.ts b/frontend-modern/src/api/patrol.ts index cd231bf..b927a51 100644 --- a/frontend-modern/src/api/patrol.ts +++ b/frontend-modern/src/api/patrol.ts @@ -61,7 +61,7 @@ export interface PatrolRunRecord { started_at: string; completed_at: string; duration_ms: number; - type: 'quick' | 'deep'; + type: string; // Always 'patrol' now (kept for backwards compat with old records) resources_checked: number; // Breakdown by resource type nodes_checked: number; diff --git a/frontend-modern/src/components/AI/AIChat.tsx b/frontend-modern/src/components/AI/AIChat.tsx index b23547e..63eff68 100644 --- a/frontend-modern/src/components/AI/AIChat.tsx +++ b/frontend-modern/src/components/AI/AIChat.tsx @@ -1,4 +1,4 @@ -import { Component, Show, createSignal, For, createEffect, createMemo } from 'solid-js'; +import { Component, Show, createSignal, For, createEffect, createMemo, onMount } from 'solid-js'; import { marked } from 'marked'; import { AIAPI } from '@/api/ai'; import { notificationStore } from '@/stores/notifications'; @@ -13,8 +13,50 @@ import type { AIStreamToolEndData, AIStreamCompleteData, AIStreamApprovalNeededData, + ModelInfo, } from '@/types/ai'; +// Provider display names for grouped model selection +const PROVIDER_DISPLAY_NAMES: Record = { + anthropic: 'Anthropic (Claude)', + openai: 'OpenAI (GPT)', + deepseek: 'DeepSeek', + ollama: 'Ollama (Local)', +}; + +// Parse provider from model ID (format: "provider:model-name") +function getProviderFromModelId(modelId: string): string { + const colonIndex = modelId.indexOf(':'); + if (colonIndex > 0) { + return modelId.substring(0, colonIndex); + } + // Default detection for models without prefix + if (modelId.includes('claude') || modelId.includes('opus') || modelId.includes('sonnet') || modelId.includes('haiku')) { + return 'anthropic'; + } + if (modelId.includes('gpt') || modelId.includes('o1') || modelId.includes('o3')) { + return 'openai'; + } + if (modelId.includes('deepseek')) { + return 'deepseek'; + } + return 'ollama'; +} + +// Group models by provider for grouped rendering +function groupModelsByProvider(models: ModelInfo[]): Map { + const grouped = new Map(); + + for (const model of models) { + const provider = getProviderFromModelId(model.id); + const existing = grouped.get(provider) || []; + existing.push(model); + grouped.set(provider, existing); + } + + return grouped; +} + // Configure marked for safe rendering marked.setOptions({ breaks: true, // Convert \n to
@@ -116,6 +158,7 @@ export const AIChat: Component = (props) => { const targetId = () => context().targetId; const contextData = () => context().context; const initialPrompt = () => context().initialPrompt; + const findingId = () => context().findingId; // For resolving patrol findings // Access WebSocket state for listing available resources const wsContext = useWebSocket(); @@ -247,10 +290,32 @@ export const AIChat: Component = (props) => { const [input, setInput] = createSignal(''); const [isLoading, setIsLoading] = createSignal(false); const [queuedMessage, setQueuedMessage] = createSignal(null); + + // Model selection + const [availableModels, setAvailableModels] = createSignal([]); + const [selectedModel, setSelectedModel] = createSignal(''); // Empty = use default + const [showModelSelector, setShowModelSelector] = createSignal(false); + let messagesEndRef: HTMLDivElement | undefined; let inputRef: HTMLTextAreaElement | undefined; let abortControllerRef: AbortController | null = null; + // Fetch available models on mount using the dynamic API + onMount(async () => { + try { + const result = await AIAPI.getModels(); + if (result.models && result.models.length > 0) { + setAvailableModels(result.models.map(m => ({ + id: m.id, + name: m.name || m.id, + description: m.description, + }))); + } + } catch (e) { + // Silently fail - models will just not be selectable + } + }); + // Wrapper to sync messages to global store const setMessages = (updater: Message[] | ((prev: Message[]) => Message[])) => { setMessagesLocal((prev) => { @@ -425,6 +490,8 @@ export const AIChat: Component = (props) => { target_id: targetId(), context: contextData(), history: history.length > 0 ? history : undefined, + finding_id: findingId(), // Pass finding ID so AI can resolve it when fixed + model: selectedModel() || undefined, // Use selected model or default }, (event: AIStreamEvent) => { lastEventTime = Date.now(); // Update last event time @@ -871,6 +938,58 @@ export const AIChat: Component = (props) => {
+ {/* Model selector dropdown */} + 0}> +
+ + +
+ + + {([provider, models]) => ( + <> +
+ {PROVIDER_DISPLAY_NAMES[provider] || provider} +
+ + {(model) => ( + + )} + + + )} +
+
+
+
+
- )} - -
- - - {/* Authentication Method - only for Anthropic */} - -
- -
- - -
-

- {form.authMethod === 'api_key' - ? 'Pay-per-use API billing from console.anthropic.com' - : 'Use your Claude Pro ($20/mo) or Max ($100+/mo) subscription'} -

-
-
- - {/* OAuth Login/Status - shown when Anthropic + OAuth selected */} - -
- {/* Connected state */} - -
-
-
- - - - Connected to Claude with your subscription -
-

- AI requests use your Claude Pro/Max subscription limits instead of API billing. -

-
- -
-
- - {/* OAuth flow in progress - show URL and code input */} - -
-
- Step 1: Click the link below to sign in with your Anthropic account: -
- - {oauthAuthUrl()} - -
- Step 2: After signing in, you'll see a code. Paste it here: -
-
- setOAuthCode(e.currentTarget.value)} - placeholder="Paste authorization code here..." - class="flex-1 px-3 py-2 text-sm border border-amber-300 dark:border-amber-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400" - /> - -
- -
-
- - {/* Initial state - show sign in button */} - -
-
- Use your Claude subscription -

- Sign in with your Anthropic account to use your Pro/Max subscription for AI features instead of API billing. -

-
- -
-
-
-
- - {/* API Key - not shown for Ollama or when using OAuth */} - -
-
- - - - -
+ + 0} fallback={ { - setForm('apiKey', event.currentTarget.value); - if (event.currentTarget.value.trim() !== '') { - setForm('clearApiKey', false); - } - }} - placeholder={ - settings()?.api_key_set - ? '•••••••• (leave blank to keep existing)' - : `Enter ${PROVIDER_NAMES[form.provider]} API key` - } + type="text" + value={form.model} + onInput={(e) => setForm('model', e.currentTarget.value)} + placeholder={DEFAULT_MODELS[form.provider]} class={controlClass()} disabled={saving()} /> -

- {form.provider === 'anthropic' - ? 'Get your API key from console.anthropic.com' - : form.provider === 'deepseek' - ? 'Get your API key from platform.deepseek.com' - : 'Get your API key from platform.openai.com'} -

-
-
- - {/* Model */} -
- - setForm('model', event.currentTarget.value)} - placeholder={DEFAULT_MODELS[form.provider]} - class={controlClass()} - disabled={saving()} - /> + }> + +

- {form.provider === 'anthropic' - ? 'e.g., claude-opus-4-5-20251101, claude-sonnet-4-20250514' - : form.provider === 'openai' - ? 'e.g., gpt-4o, gpt-4-turbo' - : form.provider === 'deepseek' - ? 'e.g., deepseek-chat, deepseek-coder' - : 'e.g., llama3, mixtral, codellama'} + Main model used when no specific override is set. {availableModels().length === 0 && 'Save API key and refresh to see available models.'}

- {/* Base URL - shown for Ollama (required) and OpenAI (optional) */} - -
- + {/* Chat Model Override */} +
+ + 0} fallback={ setForm('baseUrl', event.currentTarget.value)} - placeholder={ - form.provider === 'ollama' - ? 'http://localhost:11434' - : form.provider === 'deepseek' - ? 'https://api.deepseek.com/chat/completions' - : 'https://api.openai.com/v1' - } + type="text" + value={form.chatModel} + onInput={(e) => setForm('chatModel', e.currentTarget.value)} + placeholder="Leave empty to use default model" class={controlClass()} disabled={saving()} /> -

- {form.provider === 'ollama' - ? 'URL where your Ollama server is running' - : form.provider === 'deepseek' - ? 'Custom endpoint (leave blank for default DeepSeek API)' - : 'Custom endpoint for Azure OpenAI or compatible APIs'} + }> + + +

+ Model for interactive AI chat. Use a more capable model for complex reasoning. +

+
+ + {/* Patrol Model Override */} +
+ + 0} fallback={ + setForm('patrolModel', e.currentTarget.value)} + placeholder="Leave empty to use default model" + class={controlClass()} + disabled={saving()} + /> + }> + + +

+ Model for background patrol analysis. Use a cheaper/faster model to save tokens. +

+
+ + {/* AI Provider Configuration - Configure API keys for all providers */} +
+
+

+ + + + AI Provider Configuration +

+

+ Configure API keys for each AI provider you want to use. Models from all configured providers will appear in the model selectors.

- + + {/* Provider Accordions */} +
+ {/* Anthropic */} +
+ + +
+ setForm('anthropicApiKey', e.currentTarget.value)} + placeholder={settings()?.anthropic_configured ? '••••••••••• (configured)' : 'sk-ant-... (from console.anthropic.com)'} + class={controlClass()} + disabled={saving()} + /> +

Get your API key from console.anthropic.com

+
+
+
+ + {/* OpenAI */} +
+ + +
+ setForm('openaiApiKey', e.currentTarget.value)} + placeholder={settings()?.openai_configured ? '••••••••••• (configured)' : 'sk-... (from platform.openai.com)'} + class={controlClass()} + disabled={saving()} + /> + setForm('openaiBaseUrl', e.currentTarget.value)} + placeholder="Custom base URL (optional, for Azure OpenAI)" + class={controlClass()} + disabled={saving()} + /> +

Get your API key from platform.openai.com

+
+
+
+ + {/* DeepSeek */} +
+ + +
+ setForm('deepseekApiKey', e.currentTarget.value)} + placeholder={settings()?.deepseek_configured ? '••••••••••• (configured)' : 'sk-... (from platform.deepseek.com)'} + class={controlClass()} + disabled={saving()} + /> +

Get your API key from platform.deepseek.com

+
+
+
+ + {/* Ollama */} +
+ + +
+ setForm('ollamaBaseUrl', e.currentTarget.value)} + placeholder="http://localhost:11434" + class={controlClass()} + disabled={saving()} + /> +

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

+
+
+
+
+
{/* Autonomous Mode */}
@@ -754,11 +816,7 @@ export const AISettings: Component = () => { ? settings()?.oauth_connected ? `Ready to use with ${settings()?.model} (via Claude subscription)` : `Ready to use with ${settings()?.model}` - : form.authMethod === 'oauth' && form.provider === 'anthropic' - ? 'Sign in with your Claude subscription to enable AI features' - : needsApiKey() - ? 'API key required to enable AI features' - : 'Configure Ollama server URL to enable AI features'} + : 'Configure at least one AI provider above to enable AI features'}
diff --git a/frontend-modern/src/pages/Alerts.tsx b/frontend-modern/src/pages/Alerts.tsx index a4383a3..72e99f4 100644 --- a/frontend-modern/src/pages/Alerts.tsx +++ b/frontend-modern/src/pages/Alerts.tsx @@ -2171,12 +2171,12 @@ function OverviewTab(props: { } setPatrolStatus(status); - setAiFindings(findings); - setPatrolRunHistory(runHistory); - setSuppressionRules(rules); + setAiFindings(findings || []); + setPatrolRunHistory(runHistory || []); + setSuppressionRules(rules || []); // Auto-expand history if most recent run found issues - if (runHistory.length > 0 && runHistory[0].status !== 'healthy') { + if (runHistory && runHistory.length > 0 && runHistory[0].status !== 'healthy') { setShowRunHistory(true); } } catch (e) { @@ -2624,7 +2624,8 @@ function OverviewTab(props: { onClick={(e) => { e.stopPropagation(); aiChatStore.openWithPrompt( - `Help me fix this issue on ${finding.resource_name}: ${finding.title}\n\nDescription: ${finding.description}\n\nSuggested fix: ${finding.recommendation || 'None provided'}\n\nPlease guide me through applying this fix.` + `Help me fix this issue on ${finding.resource_name}: ${finding.title}\n\nDescription: ${finding.description}\n\nSuggested fix: ${finding.recommendation || 'None provided'}\n\nPlease guide me through applying this fix. When you've successfully fixed the issue, use the resolve_finding tool to mark it as resolved.`, + { findingId: finding.id } ); }} > diff --git a/frontend-modern/src/stores/aiChat.ts b/frontend-modern/src/stores/aiChat.ts index c60a89b..ef84812 100644 --- a/frontend-modern/src/stores/aiChat.ts +++ b/frontend-modern/src/stores/aiChat.ts @@ -5,6 +5,7 @@ interface AIChatContext { targetId?: string; context?: Record; initialPrompt?: string; + findingId?: string; // If opened from AI Insights "Get Help", the finding ID to resolve on success } // A single context item that can be accumulated @@ -170,16 +171,16 @@ export const aiChatStore = { setTargetContext(targetType: string, targetId: string, additionalContext?: Record) { // Use addContextItem instead of replacing const name = (additionalContext?.guestName as string) || - (additionalContext?.name as string) || - targetId; + (additionalContext?.name as string) || + targetId; this.addContextItem(targetType, targetId, name, additionalContext || {}); }, // Open for a specific target - opens the panel and adds to context openForTarget(targetType: string, targetId: string, additionalContext?: Record) { const name = (additionalContext?.guestName as string) || - (additionalContext?.name as string) || - targetId; + (additionalContext?.name as string) || + targetId; this.addContextItem(targetType, targetId, name, additionalContext || {}); setIsAIChatOpen(true); }, diff --git a/frontend-modern/src/types/ai.ts b/frontend-modern/src/types/ai.ts index 2693364..6782690 100644 --- a/frontend-modern/src/types/ai.ts +++ b/frontend-modern/src/types/ai.ts @@ -3,12 +3,21 @@ export type AIProvider = 'anthropic' | 'openai' | 'ollama' | 'deepseek'; export type AuthMethod = 'api_key' | 'oauth'; +export interface ModelInfo { + id: string; + name: string; + description?: string; + is_default?: boolean; +} + export interface AISettings { enabled: boolean; - provider: AIProvider; - api_key_set: boolean; // API key is never exposed, just whether it's set + provider: AIProvider; // DEPRECATED: legacy single provider + api_key_set: boolean; // DEPRECATED: whether legacy API key is set model: string; - base_url?: string; + chat_model?: string; // Model for interactive chat (empty = use default) + patrol_model?: string; // Model for background patrol (empty = use default) + base_url?: string; // DEPRECATED: legacy base URL configured: boolean; // true if AI is ready to use autonomous_mode: boolean; // true if AI can execute commands without approval custom_context: string; // user-provided infrastructure context @@ -19,21 +28,39 @@ export interface AISettings { patrol_schedule_preset?: string; // "15min" | "1hr" | "6hr" | "12hr" | "daily" | "disabled" alert_triggered_analysis?: boolean; // true if AI should analyze when alerts fire patrol_auto_fix?: boolean; // true if patrol can attempt automatic remediation + available_models?: ModelInfo[]; // DEPRECATED: use /api/ai/models endpoint + // Multi-provider configuration + anthropic_configured: boolean; // true if Anthropic API key or OAuth is set + openai_configured: boolean; // true if OpenAI API key is set + deepseek_configured: boolean; // true if DeepSeek API key is set + ollama_configured: boolean; // true (always available for attempt) + ollama_base_url: string; // Ollama server URL + openai_base_url?: string; // Custom OpenAI base URL + configured_providers: AIProvider[]; // List of providers with credentials } export interface AISettingsUpdateRequest { enabled?: boolean; - provider?: AIProvider; - api_key?: string; // empty string clears, undefined preserves + provider?: AIProvider; // DEPRECATED: use model selection instead + api_key?: string; // DEPRECATED: use per-provider keys model?: string; - base_url?: string; + base_url?: string; // DEPRECATED: use per-provider URLs autonomous_mode?: boolean; custom_context?: string; // user-provided infrastructure context auth_method?: AuthMethod; // "api_key" or "oauth" + // Model overrides for different use cases + chat_model?: string; // Model for interactive chat + patrol_model?: string; // Model for background patrol // Patrol settings for token efficiency patrol_schedule_preset?: string; // "15min" | "1hr" | "6hr" | "12hr" | "daily" | "disabled" alert_triggered_analysis?: boolean; // true if AI should analyze when alerts fire patrol_auto_fix?: boolean; // true if patrol can attempt automatic remediation + // Multi-provider credentials + anthropic_api_key?: string; // Set Anthropic API key + openai_api_key?: string; // Set OpenAI API key + deepseek_api_key?: string; // Set DeepSeek API key + ollama_base_url?: string; // Set Ollama server URL + openai_base_url?: string; // Set custom OpenAI base URL } @@ -80,6 +107,8 @@ export interface AIExecuteRequest { target_id?: string; context?: Record; history?: AIConversationMessage[]; // Previous conversation messages + finding_id?: string; // If fixing a patrol finding, the ID to resolve on success + model?: string; // Override model for this request (user selection in chat) } // Tool execution info diff --git a/internal/ai/patrol.go b/internal/ai/patrol.go index 25ec632..110cbbd 100644 --- a/internal/ai/patrol.go +++ b/internal/ai/patrol.go @@ -467,8 +467,7 @@ func (p *PatrolService) Start(ctx context.Context) { p.mu.Unlock() log.Info(). - Dur("quick_interval", p.config.QuickCheckInterval). - Dur("deep_interval", p.config.DeepAnalysisInterval). + Dur("interval", p.config.GetInterval()). Msg("Starting AI Patrol Service") go p.patrolLoop(ctx) @@ -1053,6 +1052,35 @@ func (p *PatrolService) GetFindingsSummary() FindingsSummary { return p.findings.GetSummary() } +// ResolveFinding marks a finding as resolved with a resolution note +// This is called when the AI successfully fixes an issue +func (p *PatrolService) ResolveFinding(findingID string, resolutionNote string) error { + if findingID == "" { + return fmt.Errorf("finding ID is required") + } + + // Get the finding first to update its resolution note + finding := p.findings.Get(findingID) + if finding == nil { + return fmt.Errorf("finding not found: %s", findingID) + } + + // Update the user note with the resolution + finding.UserNote = resolutionNote + + // Mark as resolved (not auto-resolved since user/AI initiated it) + if !p.findings.Resolve(findingID, false) { + return fmt.Errorf("failed to resolve finding: %s", findingID) + } + + log.Info(). + Str("finding_id", findingID). + Str("resolution_note", resolutionNote). + Msg("AI resolved finding") + + return nil +} + // GetRunHistory returns the history of patrol runs // If limit is > 0, returns at most that many records func (p *PatrolService) GetRunHistory(limit int) []PatrolRunRecord { @@ -1419,7 +1447,7 @@ func (p *PatrolService) runAIAnalysis(ctx context.Context, state models.StateSna return nil, nil // Nothing to analyze } - prompt := p.buildPatrolPrompt(summary, false) + prompt := p.buildPatrolPrompt(summary) log.Debug().Msg("AI Patrol: Sending infrastructure to LLM for analysis") @@ -1434,6 +1462,7 @@ func (p *PatrolService) runAIAnalysis(ctx context.Context, state models.StateSna resp, err := p.aiService.ExecuteStream(ctx, ExecuteRequest{ Prompt: prompt, SystemPrompt: p.getPatrolSystemPrompt(), + UseCase: "patrol", // Use patrol model for background analysis }, func(event StreamEvent) { switch event.Type { case "content": @@ -1638,7 +1667,7 @@ func (p *PatrolService) buildInfrastructureSummary(state models.StateSnapshot) s // buildPatrolPrompt creates the prompt for AI analysis // Includes user feedback context to prevent re-raising dismissed findings -func (p *PatrolService) buildPatrolPrompt(summary string, deep bool) string { +func (p *PatrolService) buildPatrolPrompt(summary string) string { // Get user feedback context (dismissed/snoozed findings) feedbackContext := p.findings.GetDismissedForContext() diff --git a/internal/ai/providers/anthropic.go b/internal/ai/providers/anthropic.go index 3343533..cf4acf9 100644 --- a/internal/ai/providers/anthropic.go +++ b/internal/ai/providers/anthropic.go @@ -346,3 +346,47 @@ func (c *AnthropicClient) TestConnection(ctx context.Context) error { }) return err } + +// ListModels fetches available models from the Anthropic API +func (c *AnthropicClient) ListModels(ctx context.Context) ([]ModelInfo, error) { + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.anthropic.com/v1/models", nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("x-api-key", c.apiKey) + req.Header.Set("anthropic-version", anthropicAPIVersion) + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body)) + } + + var result struct { + Data []struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + CreatedAt string `json:"created_at"` + } `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + models := make([]ModelInfo, 0, len(result.Data)) + for _, m := range result.Data { + models = append(models, ModelInfo{ + ID: m.ID, + Name: m.DisplayName, + }) + } + + return models, nil +} diff --git a/internal/ai/providers/anthropic_oauth.go b/internal/ai/providers/anthropic_oauth.go index 8396a87..9a6e344 100644 --- a/internal/ai/providers/anthropic_oauth.go +++ b/internal/ai/providers/anthropic_oauth.go @@ -609,3 +609,52 @@ func (c *AnthropicOAuthClient) TestConnection(ctx context.Context) error { }) return err } + +// ListModels fetches available models from the Anthropic API using OAuth +func (c *AnthropicOAuthClient) ListModels(ctx context.Context) ([]ModelInfo, error) { + // Ensure we have a valid token + if err := c.ensureValidToken(ctx); err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.anthropic.com/v1/models", nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.accessToken) + req.Header.Set("anthropic-version", anthropicAPIVersion) + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body)) + } + + var result struct { + Data []struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + CreatedAt string `json:"created_at"` + } `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + models := make([]ModelInfo, 0, len(result.Data)) + for _, m := range result.Data { + models = append(models, ModelInfo{ + ID: m.ID, + Name: m.DisplayName, + }) + } + + return models, nil +} diff --git a/internal/ai/providers/factory.go b/internal/ai/providers/factory.go index 4909da9..9953b61 100644 --- a/internal/ai/providers/factory.go +++ b/internal/ai/providers/factory.go @@ -7,6 +7,7 @@ import ( ) // NewFromConfig creates a Provider based on the AIConfig settings +// DEPRECATED: Use NewForModel or NewForProvider for multi-provider support func NewFromConfig(cfg *config.AIConfig) (Provider, error) { if cfg == nil { return nil, fmt.Errorf("AI config is nil") @@ -16,6 +17,13 @@ func NewFromConfig(cfg *config.AIConfig) (Provider, error) { return nil, fmt.Errorf("AI is not enabled") } + // Try multi-provider format first (uses per-provider API keys) + provider, model := config.ParseModelString(cfg.Model) + if providerClient, err := NewForProvider(cfg, provider, model); err == nil { + return providerClient, nil + } + + // Fall back to legacy single-provider format switch cfg.Provider { case config.AIProviderAnthropic: // If we have an API key (from direct entry or OAuth-created), use regular client @@ -55,3 +63,57 @@ func NewFromConfig(cfg *config.AIConfig) (Provider, error) { } } +// NewForProvider creates a Provider for a specific provider using multi-provider credentials +func NewForProvider(cfg *config.AIConfig, provider, model string) (Provider, error) { + if cfg == nil { + return nil, fmt.Errorf("AI config is nil") + } + + switch provider { + case config.AIProviderAnthropic: + // Check for OAuth first + if cfg.IsUsingOAuth() && cfg.OAuthAccessToken != "" { + return NewAnthropicOAuthClient( + cfg.OAuthAccessToken, + cfg.OAuthRefreshToken, + cfg.OAuthExpiresAt, + model, + ), nil + } + // Then check for per-provider API key + apiKey := cfg.GetAPIKeyForProvider(config.AIProviderAnthropic) + if apiKey == "" { + return nil, fmt.Errorf("Anthropic API key not configured") + } + return NewAnthropicClient(apiKey, model), nil + + case config.AIProviderOpenAI: + apiKey := cfg.GetAPIKeyForProvider(config.AIProviderOpenAI) + if apiKey == "" { + return nil, fmt.Errorf("OpenAI API key not configured") + } + baseURL := cfg.GetBaseURLForProvider(config.AIProviderOpenAI) + return NewOpenAIClient(apiKey, model, baseURL), nil + + case config.AIProviderDeepSeek: + apiKey := cfg.GetAPIKeyForProvider(config.AIProviderDeepSeek) + if apiKey == "" { + return nil, fmt.Errorf("DeepSeek API key not configured") + } + baseURL := cfg.GetBaseURLForProvider(config.AIProviderDeepSeek) + return NewOpenAIClient(apiKey, model, baseURL), nil + + case config.AIProviderOllama: + baseURL := cfg.GetBaseURLForProvider(config.AIProviderOllama) + return NewOllamaClient(model, baseURL), nil + + default: + return nil, fmt.Errorf("unknown provider: %s", provider) + } +} + +// NewForModel creates a Provider for a specific model, automatically detecting the provider +func NewForModel(cfg *config.AIConfig, modelString string) (Provider, error) { + provider, model := config.ParseModelString(modelString) + return NewForProvider(cfg, provider, model) +} diff --git a/internal/ai/providers/ollama.go b/internal/ai/providers/ollama.go index b66c5b7..645486d 100644 --- a/internal/ai/providers/ollama.go +++ b/internal/ai/providers/ollama.go @@ -171,3 +171,45 @@ func (c *OllamaClient) TestConnection(ctx context.Context) error { return nil } + +// ListModels fetches available models from the local Ollama instance +func (c *OllamaClient) ListModels(ctx context.Context) ([]ModelInfo, error) { + url := c.baseURL + "/api/tags" + httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body)) + } + + var result struct { + Models []struct { + Name string `json:"name"` + ModifiedAt string `json:"modified_at"` + Size int64 `json:"size"` + } `json:"models"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + models := make([]ModelInfo, 0, len(result.Models)) + for _, m := range result.Models { + models = append(models, ModelInfo{ + ID: m.Name, + Name: m.Name, + }) + } + + return models, nil +} diff --git a/internal/ai/providers/openai.go b/internal/ai/providers/openai.go index 14dbe12..cdf29e0 100644 --- a/internal/ai/providers/openai.go +++ b/internal/ai/providers/openai.go @@ -386,3 +386,58 @@ func (c *OpenAIClient) TestConnection(ctx context.Context) error { }) return err } + +// ListModels fetches available models from the OpenAI API +func (c *OpenAIClient) ListModels(ctx context.Context) ([]ModelInfo, error) { + // Determine the models endpoint based on the base URL + modelsURL := "https://api.openai.com/v1/models" + if c.isDeepSeek() { + modelsURL = "https://api.deepseek.com/models" + } + + req, err := http.NewRequestWithContext(ctx, "GET", modelsURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.apiKey) + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body)) + } + + var result struct { + Data []struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + OwnedBy string `json:"owned_by"` + } `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + models := make([]ModelInfo, 0, len(result.Data)) + for _, m := range result.Data { + // Filter to only chat-capable models + if strings.Contains(m.ID, "gpt") || strings.Contains(m.ID, "o1") || + strings.Contains(m.ID, "o3") || strings.Contains(m.ID, "deepseek") { + models = append(models, ModelInfo{ + ID: m.ID, + Name: m.ID, // OpenAI uses ID as name + CreatedAt: m.Created, + }) + } + } + + return models, nil +} diff --git a/internal/ai/providers/provider.go b/internal/ai/providers/provider.go index b61e9ec..8b48e4c 100644 --- a/internal/ai/providers/provider.go +++ b/internal/ai/providers/provider.go @@ -58,6 +58,14 @@ type ChatResponse struct { OutputTokens int `json:"output_tokens,omitempty"` } +// ModelInfo represents information about an available model +type ModelInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + CreatedAt int64 `json:"created_at,omitempty"` +} + // Provider defines the interface for AI providers type Provider interface { // Chat sends a chat request and returns the response @@ -68,4 +76,8 @@ type Provider interface { // Name returns the provider name Name() string + + // ListModels returns available models from the provider's API + // Returns nil if the provider doesn't support listing models + ListModels(ctx context.Context) ([]ModelInfo, error) } diff --git a/internal/ai/service.go b/internal/ai/service.go index 2865829..8919b9f 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -710,6 +710,9 @@ type ExecuteRequest struct { Context map[string]interface{} `json:"context,omitempty"` // Current metrics, state, etc. SystemPrompt string `json:"system_prompt,omitempty"` // Override system prompt History []ConversationMessage `json:"history,omitempty"` // Previous conversation messages + FindingID string `json:"finding_id,omitempty"` // If fixing a patrol finding, the ID to resolve + Model string `json:"model,omitempty"` // Override model for this request (for user selection in chat) + UseCase string `json:"use_case,omitempty"` // "chat" or "patrol" - determines which default model to use } // ExecuteResponse represents the AI's response @@ -729,6 +732,34 @@ type ToolExecution struct { Success bool `json:"success"` } +// getModelForRequest determines which model to use for a request +// Priority: explicit override > use case default > config default +func (s *Service) getModelForRequest(req ExecuteRequest) string { + // If request has explicit model override, use it + if req.Model != "" { + return req.Model + } + + s.mu.RLock() + cfg := s.cfg + s.mu.RUnlock() + + if cfg == nil { + return "" + } + + // Use case-specific model selection + switch req.UseCase { + case "patrol": + return cfg.GetPatrolModel() + case "chat": + return cfg.GetChatModel() + default: + // Default to chat model for interactive requests + return cfg.GetChatModel() + } +} + // StreamEvent represents an event during AI execution for streaming type StreamEvent struct { Type string `json:"type"` // "tool_start", "tool_end", "content", "done", "error", "approval_needed" @@ -765,11 +796,22 @@ type ApprovalNeededData struct { // If tools are available and the AI requests them, it executes them in a loop func (s *Service) Execute(ctx context.Context, req ExecuteRequest) (*ExecuteResponse, error) { s.mu.RLock() - provider := s.provider - cfg := s.cfg + defaultProvider := s.provider agentServer := s.agentServer + cfg := s.cfg s.mu.RUnlock() + // Determine the model to use for this request + modelString := s.getModelForRequest(req) + + // Create a provider for this specific model (supports multi-provider switching) + provider, err := providers.NewForModel(cfg, modelString) + if err != nil { + // Fall back to default provider if model-specific provider can't be created + log.Debug().Err(err).Str("model", modelString).Msg("Could not create provider for model, using default") + provider = defaultProvider + } + if provider == nil { return nil, fmt.Errorf("AI is not enabled or configured") } @@ -836,7 +878,7 @@ Always execute the commands rather than telling the user how to do it.` for i := 0; i < maxIterations; i++ { resp, err := provider.Chat(ctx, providers.ChatRequest{ Messages: messages, - Model: cfg.GetModel(), + Model: s.getModelForRequest(req), System: systemPrompt, MaxTokens: 4096, Tools: tools, @@ -893,11 +935,22 @@ Always execute the commands rather than telling the user how to do it.` // This allows the UI to show real-time progress during tool execution func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callback StreamCallback) (*ExecuteResponse, error) { s.mu.RLock() - provider := s.provider - cfg := s.cfg + defaultProvider := s.provider agentServer := s.agentServer + cfg := s.cfg s.mu.RUnlock() + // Determine the model to use for this request + modelString := s.getModelForRequest(req) + + // Create a provider for this specific model (supports multi-provider switching) + provider, err := providers.NewForModel(cfg, modelString) + if err != nil { + // Fall back to default provider if model-specific provider can't be created + log.Debug().Err(err).Str("model", modelString).Msg("Could not create provider for model, using default") + provider = defaultProvider + } + if provider == nil { return nil, fmt.Errorf("AI is not enabled or configured") } @@ -994,7 +1047,7 @@ Always execute the commands rather than telling the user how to do it.` resp, err := provider.Chat(ctx, providers.ChatRequest{ Messages: messages, - Model: cfg.GetModel(), + Model: s.getModelForRequest(req), System: systemPrompt, MaxTokens: 4096, Tools: tools, @@ -1327,6 +1380,24 @@ func (s *Service) getTools() []providers.Tool { "required": []string{"resource_type", "resource_id", "url"}, }, }, + { + Name: "resolve_finding", + Description: "Mark an AI patrol finding as resolved after you have successfully fixed the underlying issue. Only use this after confirming the fix worked (e.g., by running a verification command). The finding ID is provided in your context when helping with a patrol finding.", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "finding_id": map[string]interface{}{ + "type": "string", + "description": "The ID of the finding to resolve. Use the finding_id from the request context.", + }, + "resolution_note": map[string]interface{}{ + "type": "string", + "description": "Brief description of how the issue was resolved (e.g., 'Restarted nginx service', 'Cleaned up disk space').", + }, + }, + "required": []string{"finding_id", "resolution_note"}, + }, + }, } // Add web search tool for Anthropic provider @@ -1607,6 +1678,47 @@ func (s *Service) executeTool(ctx context.Context, req ExecuteRequest, tc provid execution.Success = true return execution.Output, execution + case "resolve_finding": + findingID, _ := tc.Input["finding_id"].(string) + resolutionNote, _ := tc.Input["resolution_note"].(string) + execution.Input = fmt.Sprintf("finding: %s, note: %s", findingID, resolutionNote) + + // If no finding ID provided by AI, check the request context + if findingID == "" { + findingID = req.FindingID + } + + if findingID == "" { + execution.Output = "Error: finding_id is required. The finding ID should be provided in the request context when helping fix a patrol finding." + return execution.Output, execution + } + + if resolutionNote == "" { + execution.Output = "Error: resolution_note is required. Please describe how the issue was resolved." + return execution.Output, execution + } + + // Get the patrol service to resolve the finding + s.mu.RLock() + patrolService := s.patrolService + s.mu.RUnlock() + + if patrolService == nil { + execution.Output = "Error: Patrol service not available" + return execution.Output, execution + } + + // Resolve the finding + err := patrolService.ResolveFinding(findingID, resolutionNote) + if err != nil { + execution.Output = fmt.Sprintf("Error resolving finding: %s", err) + return execution.Output, execution + } + + execution.Output = fmt.Sprintf("✅ Finding resolved! The AI Insight has been marked as fixed.\nID: %s\nResolution: %s", findingID, resolutionNote) + execution.Success = true + return execution.Output, execution + default: execution.Output = fmt.Sprintf("Unknown tool: %s", tc.Name) return execution.Output, execution @@ -2255,31 +2367,109 @@ func (s *Service) buildUserAnnotationsContext() string { } // TestConnection tests the AI provider connection +// Tests the provider for the currently configured default model func (s *Service) TestConnection(ctx context.Context) error { s.mu.RLock() - provider := s.provider + cfg := s.cfg + defaultProvider := s.provider s.mu.RUnlock() - if provider == nil { - // Try to create a temporary provider from config to test - cfg, err := s.persistence.LoadAIConfig() + // Load config if not available + if cfg == nil { + var err error + cfg, err = s.persistence.LoadAIConfig() if err != nil { return fmt.Errorf("failed to load AI config: %w", err) } - if cfg == nil || cfg.APIKey == "" { - return fmt.Errorf("API key not configured") - } + } - tempProvider, err := providers.NewFromConfig(cfg) - if err != nil { - return err + if cfg == nil || !cfg.IsConfigured() { + return fmt.Errorf("no AI provider configured") + } + + // Try to create a provider for the current default model + provider, err := providers.NewForModel(cfg, cfg.GetModel()) + if err != nil { + // Fall back to default provider or NewFromConfig + log.Debug().Err(err).Str("model", cfg.GetModel()).Msg("Could not create provider for model, using fallback") + if defaultProvider != nil { + provider = defaultProvider + } else { + provider, err = providers.NewFromConfig(cfg) + if err != nil { + return err + } } - provider = tempProvider } return provider.TestConnection(ctx) } +// ListModels fetches available models from ALL configured AI providers +// Returns a unified list with models prefixed by provider name +func (s *Service) ListModels(ctx context.Context) ([]providers.ModelInfo, error) { + cfg, err := s.persistence.LoadAIConfig() + if err != nil { + return nil, fmt.Errorf("failed to load AI config: %w", err) + } + if cfg == nil { + return nil, fmt.Errorf("AI not configured") + } + + var allModels []providers.ModelInfo + + // Query each configured provider + providersList := []string{config.AIProviderAnthropic, config.AIProviderOpenAI, config.AIProviderDeepSeek, config.AIProviderOllama} + + for _, providerName := range providersList { + if !cfg.HasProvider(providerName) { + continue + } + + // Create provider for this specific provider + provider, err := providers.NewForProvider(cfg, providerName, "") + if err != nil { + log.Debug().Err(err).Str("provider", providerName).Msg("Skipping provider - not configured") + continue + } + + // Fetch models from this provider + models, err := provider.ListModels(ctx) + if err != nil { + log.Warn().Err(err).Str("provider", providerName).Msg("Failed to fetch models from provider") + continue + } + + // Add provider prefix to each model + for _, m := range models { + allModels = append(allModels, providers.ModelInfo{ + ID: config.FormatModelString(providerName, m.ID), + Name: m.Name, + Description: providerDisplayName(providerName) + ": " + m.ID, + CreatedAt: m.CreatedAt, + }) + } + } + + return allModels, nil +} + +// providerDisplayName returns a user-friendly name for a provider +func providerDisplayName(provider string) string { + switch provider { + case config.AIProviderAnthropic: + return "Anthropic" + case config.AIProviderOpenAI: + return "OpenAI" + case config.AIProviderDeepSeek: + return "DeepSeek" + case config.AIProviderOllama: + return "Ollama" + default: + return provider + } +} + // Reload reloads the AI configuration (call after settings change) func (s *Service) Reload() error { return s.LoadConfig() diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index 8969a0e..91d71ba 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -102,13 +102,15 @@ func (h *AISettingsHandler) GetAlertTriggeredAnalyzer() *ai.AlertTriggeredAnalyz } // AISettingsResponse is returned by GET /api/settings/ai -// API key is masked for security +// API keys are masked for security type AISettingsResponse struct { Enabled bool `json:"enabled"` - Provider string `json:"provider"` - APIKeySet bool `json:"api_key_set"` // true if API key is configured (never expose actual key) + Provider string `json:"provider"` // DEPRECATED: legacy single provider + APIKeySet bool `json:"api_key_set"` // DEPRECATED: true if legacy API key is configured Model string `json:"model"` - BaseURL string `json:"base_url,omitempty"` + ChatModel string `json:"chat_model,omitempty"` // Model for interactive chat (empty = use default) + PatrolModel string `json:"patrol_model,omitempty"` // Model for patrol (empty = use default) + BaseURL string `json:"base_url,omitempty"` // DEPRECATED: legacy base URL Configured bool `json:"configured"` // true if AI is ready to use AutonomousMode bool `json:"autonomous_mode"` // true if AI can execute without approval CustomContext string `json:"custom_context"` // user-provided infrastructure context @@ -116,23 +118,40 @@ type AISettingsResponse struct { AuthMethod string `json:"auth_method"` // "api_key" or "oauth" OAuthConnected bool `json:"oauth_connected"` // true if OAuth tokens are configured // Patrol settings for token efficiency - PatrolSchedulePreset string `json:"patrol_schedule_preset"` // "15min", "1hr", "6hr", "12hr", "daily", "disabled" - AlertTriggeredAnalysis bool `json:"alert_triggered_analysis"` // true if AI analyzes when alerts fire + PatrolSchedulePreset string `json:"patrol_schedule_preset"` // "15min", "1hr", "6hr", "12hr", "daily", "disabled" + AlertTriggeredAnalysis bool `json:"alert_triggered_analysis"` // true if AI analyzes when alerts fire + AvailableModels []config.ModelInfo `json:"available_models"` // List of models for current provider + // Multi-provider credentials - shows which providers are configured + AnthropicConfigured bool `json:"anthropic_configured"` // true if Anthropic API key or OAuth is set + OpenAIConfigured bool `json:"openai_configured"` // true if OpenAI API key is set + DeepSeekConfigured bool `json:"deepseek_configured"` // true if DeepSeek API key is set + OllamaConfigured bool `json:"ollama_configured"` // true (always available for attempt) + OllamaBaseURL string `json:"ollama_base_url"` // Ollama server URL + OpenAIBaseURL string `json:"openai_base_url,omitempty"` // Custom OpenAI base URL + ConfiguredProviders []string `json:"configured_providers"` // List of provider names with credentials } // AISettingsUpdateRequest is the request body for PUT /api/settings/ai type AISettingsUpdateRequest struct { Enabled *bool `json:"enabled,omitempty"` - Provider *string `json:"provider,omitempty"` - APIKey *string `json:"api_key,omitempty"` // empty string clears, null preserves + Provider *string `json:"provider,omitempty"` // DEPRECATED: use model selection instead + APIKey *string `json:"api_key,omitempty"` // DEPRECATED: use per-provider keys Model *string `json:"model,omitempty"` - BaseURL *string `json:"base_url,omitempty"` + ChatModel *string `json:"chat_model,omitempty"` // Model for interactive chat + PatrolModel *string `json:"patrol_model,omitempty"` // Model for background patrol + BaseURL *string `json:"base_url,omitempty"` // DEPRECATED: use per-provider URLs AutonomousMode *bool `json:"autonomous_mode,omitempty"` CustomContext *string `json:"custom_context,omitempty"` // user-provided infrastructure context AuthMethod *string `json:"auth_method,omitempty"` // "api_key" or "oauth" // Patrol settings for token efficiency PatrolSchedulePreset *string `json:"patrol_schedule_preset,omitempty"` // "15min", "1hr", "6hr", "12hr", "daily", "disabled" AlertTriggeredAnalysis *bool `json:"alert_triggered_analysis,omitempty"` // true if AI analyzes when alerts fire + // Multi-provider credentials + AnthropicAPIKey *string `json:"anthropic_api_key,omitempty"` // Set Anthropic API key + OpenAIAPIKey *string `json:"openai_api_key,omitempty"` // Set OpenAI API key + DeepSeekAPIKey *string `json:"deepseek_api_key,omitempty"` // Set DeepSeek API key + OllamaBaseURL *string `json:"ollama_base_url,omitempty"` // Set Ollama server URL + OpenAIBaseURL *string `json:"openai_base_url,omitempty"` // Set custom OpenAI base URL } // HandleGetAISettings returns the current AI settings (GET /api/settings/ai) @@ -164,6 +183,8 @@ func (h *AISettingsHandler) HandleGetAISettings(w http.ResponseWriter, r *http.R Provider: settings.Provider, APIKeySet: settings.APIKey != "", Model: settings.GetModel(), + ChatModel: settings.ChatModel, + PatrolModel: settings.PatrolModel, BaseURL: settings.BaseURL, Configured: settings.IsConfigured(), AutonomousMode: settings.AutonomousMode, @@ -173,6 +194,15 @@ func (h *AISettingsHandler) HandleGetAISettings(w http.ResponseWriter, r *http.R // Patrol settings PatrolSchedulePreset: settings.PatrolSchedulePreset, AlertTriggeredAnalysis: settings.AlertTriggeredAnalysis, + AvailableModels: nil, // Now populated via /api/ai/models endpoint + // Multi-provider configuration + AnthropicConfigured: settings.HasProvider(config.AIProviderAnthropic), + OpenAIConfigured: settings.HasProvider(config.AIProviderOpenAI), + DeepSeekConfigured: settings.HasProvider(config.AIProviderDeepSeek), + OllamaConfigured: settings.HasProvider(config.AIProviderOllama), + OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama), + OpenAIBaseURL: settings.OpenAIBaseURL, + ConfiguredProviders: settings.GetConfiguredProviders(), } if err := utils.WriteJSONResponse(w, response); err != nil { @@ -247,6 +277,14 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt settings.Model = strings.TrimSpace(*req.Model) } + if req.ChatModel != nil { + settings.ChatModel = strings.TrimSpace(*req.ChatModel) + } + + if req.PatrolModel != nil { + settings.PatrolModel = strings.TrimSpace(*req.PatrolModel) + } + if req.BaseURL != nil { settings.BaseURL = strings.TrimSpace(*req.BaseURL) } @@ -296,6 +334,23 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt settings.AlertTriggeredAnalysis = *req.AlertTriggeredAnalysis } + // Handle multi-provider credentials + if req.AnthropicAPIKey != nil { + settings.AnthropicAPIKey = strings.TrimSpace(*req.AnthropicAPIKey) + } + if req.OpenAIAPIKey != nil { + settings.OpenAIAPIKey = strings.TrimSpace(*req.OpenAIAPIKey) + } + if req.DeepSeekAPIKey != nil { + settings.DeepSeekAPIKey = strings.TrimSpace(*req.DeepSeekAPIKey) + } + if req.OllamaBaseURL != nil { + settings.OllamaBaseURL = strings.TrimSpace(*req.OllamaBaseURL) + } + if req.OpenAIBaseURL != nil { + settings.OpenAIBaseURL = strings.TrimSpace(*req.OpenAIBaseURL) + } + // Save settings if err := h.persistence.SaveAIConfig(*settings); err != nil { log.Error().Err(err).Msg("Failed to save AI settings") @@ -336,6 +391,8 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt Provider: settings.Provider, APIKeySet: settings.APIKey != "", Model: settings.GetModel(), + ChatModel: settings.ChatModel, + PatrolModel: settings.PatrolModel, BaseURL: settings.BaseURL, Configured: settings.IsConfigured(), AutonomousMode: settings.AutonomousMode, @@ -344,6 +401,15 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt OAuthConnected: settings.OAuthAccessToken != "", PatrolSchedulePreset: settings.PatrolSchedulePreset, AlertTriggeredAnalysis: settings.AlertTriggeredAnalysis, + AvailableModels: nil, // Now populated via /api/ai/models endpoint + // Multi-provider configuration + AnthropicConfigured: settings.HasProvider(config.AIProviderAnthropic), + OpenAIConfigured: settings.HasProvider(config.AIProviderOpenAI), + DeepSeekConfigured: settings.HasProvider(config.AIProviderDeepSeek), + OllamaConfigured: settings.HasProvider(config.AIProviderOllama), + OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama), + OpenAIBaseURL: settings.OpenAIBaseURL, + ConfiguredProviders: settings.GetConfiguredProviders(), } if err := utils.WriteJSONResponse(w, response); err != nil { @@ -390,6 +456,65 @@ func (h *AISettingsHandler) HandleTestAIConnection(w http.ResponseWriter, r *htt } } +// 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 { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Require authentication + if !CheckAuth(h.config, w, r) { + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + type ModelInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + } + + type Response struct { + Models []ModelInfo `json:"models"` + Error string `json:"error,omitempty"` + Cached bool `json:"cached"` + } + + models, err := h.aiService.ListModels(ctx) + if err != nil { + // Return error but don't fail the request - frontend can show a fallback + resp := Response{ + Models: []ModelInfo{}, + Error: err.Error(), + } + if jsonErr := utils.WriteJSONResponse(w, resp); jsonErr != nil { + log.Error().Err(jsonErr).Msg("Failed to write AI models response") + } + return + } + + // Convert provider models to response format + responseModels := make([]ModelInfo, 0, len(models)) + for _, m := range models { + responseModels = append(responseModels, ModelInfo{ + ID: m.ID, + Name: m.Name, + Description: m.Description, + }) + } + + resp := Response{ + Models: responseModels, + } + + if err := utils.WriteJSONResponse(w, resp); err != nil { + log.Error().Err(err).Msg("Failed to write AI models response") + } +} + // AIExecuteRequest is the request body for POST /api/ai/execute // AIConversationMessage represents a message in conversation history type AIConversationMessage struct { diff --git a/internal/api/router.go b/internal/api/router.go index 82993f2..d070f77 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/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)) r.mux.HandleFunc("/api/ai/investigate-alert", RequireAuth(r.config, r.aiSettingsHandler.HandleInvestigateAlert)) diff --git a/internal/config/ai.go b/internal/config/ai.go index 3d14a30..ed66505 100644 --- a/internal/config/ai.go +++ b/internal/config/ai.go @@ -16,13 +16,22 @@ const ( // This is stored in ai.enc (encrypted) in the config directory type AIConfig struct { Enabled bool `json:"enabled"` - Provider string `json:"provider"` // "anthropic", "openai", "ollama", "deepseek" - APIKey string `json:"api_key"` // encrypted at rest (not needed for ollama or oauth) - Model string `json:"model"` // e.g., "claude-opus-4-5-20250514", "gpt-4o", "llama3" - BaseURL string `json:"base_url"` // custom endpoint (required for ollama, optional for openai) + Provider string `json:"provider"` // DEPRECATED: legacy single provider field, kept for migration + APIKey string `json:"api_key"` // DEPRECATED: legacy single API key, kept for migration + Model string `json:"model"` // Currently selected default model (format: "provider:model-name") + ChatModel string `json:"chat_model,omitempty"` // Model for interactive chat (defaults to Model) + PatrolModel string `json:"patrol_model,omitempty"` // Model for background patrol (defaults to Model, can be cheaper) + BaseURL string `json:"base_url"` // DEPRECATED: legacy base URL, kept for migration AutonomousMode bool `json:"autonomous_mode"` // when true, AI executes commands without approval CustomContext string `json:"custom_context"` // user-provided context about their infrastructure + // Multi-provider credentials - each provider can be configured independently + AnthropicAPIKey string `json:"anthropic_api_key,omitempty"` // Anthropic API key + OpenAIAPIKey string `json:"openai_api_key,omitempty"` // OpenAI API key + DeepSeekAPIKey string `json:"deepseek_api_key,omitempty"` // DeepSeek API key + OllamaBaseURL string `json:"ollama_base_url,omitempty"` // Ollama server URL (default: http://localhost:11434) + OpenAIBaseURL string `json:"openai_base_url,omitempty"` // Custom OpenAI-compatible base URL (optional) + // OAuth fields for Claude Pro/Max subscription authentication AuthMethod AuthMethod `json:"auth_method,omitempty"` // "api_key" or "oauth" (for anthropic only) OAuthAccessToken string `json:"oauth_access_token,omitempty"` // OAuth access token (encrypted at rest) @@ -61,6 +70,21 @@ const ( DefaultDeepSeekBaseURL = "https://api.deepseek.com/chat/completions" ) +// ModelInfo represents information about an available model +// Deprecated: Use providers.ModelInfo instead - models are now fetched dynamically from APIs +type ModelInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + IsDefault bool `json:"is_default,omitempty"` +} + +// GetAvailableModels is deprecated - models are now fetched dynamically from provider APIs +// This returns nil; use the /api/ai/models endpoint instead which queries the actual API +func GetAvailableModels(provider string) []ModelInfo { + return nil +} + // NewDefaultAIConfig returns an AIConfig with sensible defaults func NewDefaultAIConfig() *AIConfig { return &AIConfig{ @@ -83,14 +107,21 @@ func NewDefaultAIConfig() *AIConfig { } // IsConfigured returns true if the AI config has enough info to make API calls +// For multi-provider setup, returns true if ANY provider is configured func (c *AIConfig) IsConfigured() bool { if !c.Enabled { return false } + // Check multi-provider credentials first (new format) + if c.HasProvider(AIProviderAnthropic) || c.HasProvider(AIProviderOpenAI) || + c.HasProvider(AIProviderDeepSeek) || c.HasProvider(AIProviderOllama) { + return true + } + + // Fall back to legacy single-provider check for backward compatibility switch c.Provider { case AIProviderAnthropic: - // Anthropic can use API key OR OAuth if c.AuthMethod == AuthMethodOAuth { return c.OAuthAccessToken != "" } @@ -98,19 +129,140 @@ func (c *AIConfig) IsConfigured() bool { case AIProviderOpenAI, AIProviderDeepSeek: return c.APIKey != "" case AIProviderOllama: - // Ollama doesn't need an API key return true default: return false } } +// HasProvider returns true if the specified provider has credentials configured +func (c *AIConfig) HasProvider(provider string) bool { + switch provider { + case AIProviderAnthropic: + // Anthropic can use API key OR OAuth + if c.AuthMethod == AuthMethodOAuth && c.OAuthAccessToken != "" { + return true + } + return c.AnthropicAPIKey != "" + case AIProviderOpenAI: + return c.OpenAIAPIKey != "" + case AIProviderDeepSeek: + return c.DeepSeekAPIKey != "" + case AIProviderOllama: + // Ollama is available if URL is set (or default localhost) + return true // Always available for attempt + default: + return false + } +} + +// GetConfiguredProviders returns a list of all providers with credentials configured +func (c *AIConfig) GetConfiguredProviders() []string { + var providers []string + if c.HasProvider(AIProviderAnthropic) { + providers = append(providers, AIProviderAnthropic) + } + if c.HasProvider(AIProviderOpenAI) { + providers = append(providers, AIProviderOpenAI) + } + if c.HasProvider(AIProviderDeepSeek) { + providers = append(providers, AIProviderDeepSeek) + } + if c.HasProvider(AIProviderOllama) { + providers = append(providers, AIProviderOllama) + } + return providers +} + +// GetAPIKeyForProvider returns the API key for the specified provider +func (c *AIConfig) GetAPIKeyForProvider(provider string) string { + switch provider { + case AIProviderAnthropic: + if c.AnthropicAPIKey != "" { + return c.AnthropicAPIKey + } + // Fall back to legacy API key if provider matches + if c.Provider == AIProviderAnthropic { + return c.APIKey + } + case AIProviderOpenAI: + if c.OpenAIAPIKey != "" { + return c.OpenAIAPIKey + } + if c.Provider == AIProviderOpenAI { + return c.APIKey + } + case AIProviderDeepSeek: + if c.DeepSeekAPIKey != "" { + return c.DeepSeekAPIKey + } + if c.Provider == AIProviderDeepSeek { + return c.APIKey + } + } + return "" +} + +// GetBaseURLForProvider returns the base URL for the specified provider +func (c *AIConfig) GetBaseURLForProvider(provider string) string { + switch provider { + case AIProviderOllama: + if c.OllamaBaseURL != "" { + return c.OllamaBaseURL + } + // Fall back to legacy BaseURL if provider matches + if c.Provider == AIProviderOllama && c.BaseURL != "" { + return c.BaseURL + } + return DefaultOllamaBaseURL + case AIProviderOpenAI: + if c.OpenAIBaseURL != "" { + return c.OpenAIBaseURL + } + return "" // Uses default OpenAI URL + case AIProviderDeepSeek: + return DefaultDeepSeekBaseURL + } + return "" +} + // IsUsingOAuth returns true if OAuth authentication is configured for Anthropic func (c *AIConfig) IsUsingOAuth() bool { - return c.Provider == AIProviderAnthropic && c.AuthMethod == AuthMethodOAuth && c.OAuthAccessToken != "" + return c.AuthMethod == AuthMethodOAuth && c.OAuthAccessToken != "" +} + +// ParseModelString parses a model string in "provider:model-name" format +// Returns the provider and model name. If no provider prefix, attempts to detect. +func ParseModelString(model string) (provider, modelName string) { + // Check for explicit provider prefix + for _, p := range []string{AIProviderAnthropic, AIProviderOpenAI, AIProviderDeepSeek, AIProviderOllama} { + prefix := p + ":" + if len(model) > len(prefix) && model[:len(prefix)] == prefix { + return p, model[len(prefix):] + } + } + + // No prefix - try to detect from model name patterns + switch { + case len(model) >= 6 && model[:6] == "claude": + return AIProviderAnthropic, model + case len(model) >= 3 && (model[:3] == "gpt" || model[:2] == "o1" || model[:2] == "o3" || model[:2] == "o4"): + return AIProviderOpenAI, model + case len(model) >= 8 && model[:8] == "deepseek": + return AIProviderDeepSeek, model + default: + // Assume Ollama for unrecognized models (local models have varied names) + return AIProviderOllama, model + } +} + +// FormatModelString creates a "provider:model-name" format string +func FormatModelString(provider, modelName string) string { + return provider + ":" + modelName } // GetBaseURL returns the base URL, using defaults where appropriate +// DEPRECATED: Use GetBaseURLForProvider instead func (c *AIConfig) GetBaseURL() string { if c.BaseURL != "" { return c.BaseURL @@ -143,6 +295,24 @@ func (c *AIConfig) GetModel() string { } } +// GetChatModel returns the model for interactive chat conversations +// Falls back to the main Model if ChatModel is not set +func (c *AIConfig) GetChatModel() string { + if c.ChatModel != "" { + return c.ChatModel + } + return c.GetModel() +} + +// GetPatrolModel returns the model for background patrol analysis +// Falls back to the main Model if PatrolModel is not set +func (c *AIConfig) GetPatrolModel() string { + if c.PatrolModel != "" { + return c.PatrolModel + } + return c.GetModel() +} + // ClearOAuthTokens clears OAuth tokens (used when switching back to API key auth) func (c *AIConfig) ClearOAuthTokens() { c.OAuthAccessToken = ""