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
This commit is contained in:
rcourtman 2025-12-11 16:00:45 +00:00
parent 29404d4e81
commit 0c3dcf353a
18 changed files with 1414 additions and 422 deletions

View file

@ -32,6 +32,11 @@ export class AIAPI {
}) as Promise<AITestResult>;
}
// 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 }> {

View file

@ -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;

View file

@ -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<string, string> = {
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<string, ModelInfo[]> {
const grouped = new Map<string, ModelInfo[]>();
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 <br>
@ -116,6 +158,7 @@ export const AIChat: Component<AIChatProps> = (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<AIChatProps> = (props) => {
const [input, setInput] = createSignal('');
const [isLoading, setIsLoading] = createSignal(false);
const [queuedMessage, setQueuedMessage] = createSignal<string | null>(null);
// Model selection
const [availableModels, setAvailableModels] = createSignal<ModelInfo[]>([]);
const [selectedModel, setSelectedModel] = createSignal<string>(''); // 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<AIChatProps> = (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<AIChatProps> = (props) => {
</div>
</div>
<div class="flex items-center gap-2">
{/* Model selector dropdown */}
<Show when={availableModels().length > 0}>
<div class="relative">
<button
onClick={() => setShowModelSelector(!showModelSelector())}
class="flex items-center gap-1 px-2 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 rounded border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 bg-white dark:bg-gray-800"
title="Select AI model"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span class="max-w-[80px] truncate">
{selectedModel() || 'Default'}
</span>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<Show when={showModelSelector()}>
<div class="absolute right-0 top-full mt-1 w-64 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50 py-1">
<button
onClick={() => { setSelectedModel(''); setShowModelSelector(false); }}
class={`w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${!selectedModel() ? 'bg-blue-50 dark:bg-blue-900/30' : ''}`}
>
<div class="font-medium text-gray-900 dark:text-gray-100">Default</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Use configured default model</div>
</button>
<For each={Array.from(groupModelsByProvider(availableModels()).entries())}>
{([provider, models]) => (
<>
<div class="px-3 py-1.5 text-xs font-semibold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700/50 sticky top-0">
{PROVIDER_DISPLAY_NAMES[provider] || provider}
</div>
<For each={models}>
{(model) => (
<button
onClick={() => { setSelectedModel(model.id); setShowModelSelector(false); }}
class={`w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${selectedModel() === model.id ? 'bg-blue-50 dark:bg-blue-900/30' : ''}`}
>
<div class="font-medium text-gray-900 dark:text-gray-100">
{model.name || model.id.split(':').pop()}
</div>
</button>
)}
</For>
</>
)}
</For>
</div>
</Show>
</div>
</Show>
<button
onClick={clearChat}
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"

View file

@ -8,36 +8,84 @@ import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
import { AIAPI } from '@/api/ai';
import type { AISettings as AISettingsType, AIProvider, AuthMethod } from '@/types/ai';
import { PROVIDER_NAMES, PROVIDER_DESCRIPTIONS, DEFAULT_MODELS } from '@/types/ai';
import { DEFAULT_MODELS } from '@/types/ai';
const PROVIDERS: AIProvider[] = ['anthropic', 'openai', 'ollama', 'deepseek'];
// Providers are now configured via accordion sections, not a single-provider selector
// Provider display names for optgroup labels
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
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 optgroup rendering
function groupModelsByProvider(models: { id: string; name: string; description?: string }[]): Map<string, { id: string; name: string; description?: string }[]> {
const grouped = new Map<string, { id: string; name: string; description?: string }[]>();
for (const model of models) {
const provider = getProviderFromModelId(model.id);
const existing = grouped.get(provider) || [];
existing.push(model);
grouped.set(provider, existing);
}
return grouped;
}
export const AISettings: Component = () => {
const [settings, setSettings] = createSignal<AISettingsType | null>(null);
const [loading, setLoading] = createSignal(false);
const [saving, setSaving] = createSignal(false);
const [testing, setTesting] = createSignal(false);
const [startingOAuth, setStartingOAuth] = createSignal(false);
const [disconnectingOAuth, setDisconnectingOAuth] = createSignal(false);
const [exchangingCode, setExchangingCode] = createSignal(false);
// OAuth flow state
const [oauthAuthUrl, setOAuthAuthUrl] = createSignal<string | null>(null);
const [oauthState, setOAuthState] = createSignal<string | null>(null);
const [oauthCode, setOAuthCode] = createSignal('');
// Dynamic model list from provider API
const [availableModels, setAvailableModels] = createSignal<{ id: string; name: string; description?: string }[]>([]);
const [modelsLoading, setModelsLoading] = createSignal(false);
// Accordion state for provider configuration sections
const [expandedProviders, setExpandedProviders] = createSignal<Set<AIProvider>>(new Set(['anthropic']));
const [form, setForm] = createStore({
enabled: false,
provider: 'anthropic' as AIProvider,
apiKey: '',
provider: 'anthropic' as AIProvider, // Legacy - kept for compatibility
apiKey: '', // Legacy - kept for compatibility
model: '',
baseUrl: '',
chatModel: '', // Empty means use default model
patrolModel: '', // Empty means use default model
baseUrl: '', // Legacy - kept for compatibility
clearApiKey: false,
autonomousMode: false,
authMethod: 'api_key' as AuthMethod,
patrolSchedulePreset: '6hr',
alertTriggeredAnalysis: true,
patrolAutoFix: false,
// Multi-provider credentials
anthropicApiKey: '',
openaiApiKey: '',
deepseekApiKey: '',
ollamaBaseUrl: 'http://localhost:11434',
openaiBaseUrl: '',
});
const resetForm = (data: AISettingsType | null) => {
@ -47,6 +95,8 @@ export const AISettings: Component = () => {
provider: 'anthropic',
apiKey: '',
model: DEFAULT_MODELS.anthropic,
chatModel: '',
patrolModel: '',
baseUrl: '',
clearApiKey: false,
autonomousMode: false,
@ -54,6 +104,12 @@ export const AISettings: Component = () => {
patrolSchedulePreset: '6hr',
alertTriggeredAnalysis: true,
patrolAutoFix: false,
// Multi-provider - empty by default
anthropicApiKey: '',
openaiApiKey: '',
deepseekApiKey: '',
ollamaBaseUrl: 'http://localhost:11434',
openaiBaseUrl: '',
});
return;
}
@ -63,6 +119,8 @@ export const AISettings: Component = () => {
provider: data.provider,
apiKey: '',
model: data.model || DEFAULT_MODELS[data.provider],
chatModel: data.chat_model || '',
patrolModel: data.patrol_model || '',
baseUrl: data.base_url || '',
clearApiKey: false,
autonomousMode: data.autonomous_mode || false,
@ -70,7 +128,39 @@ export const AISettings: Component = () => {
patrolSchedulePreset: data.patrol_schedule_preset || '6hr',
alertTriggeredAnalysis: data.alert_triggered_analysis !== false, // default to true
patrolAutoFix: data.patrol_auto_fix || false, // default to false (observe only)
// Multi-provider - never load actual keys from server (security), just track if configured
anthropicApiKey: '', // Always empty - we only show if configured
openaiApiKey: '',
deepseekApiKey: '',
ollamaBaseUrl: data.ollama_base_url || 'http://localhost:11434',
openaiBaseUrl: data.openai_base_url || '',
});
// Auto-expand providers that are configured
const configured = new Set<AIProvider>();
if (data.anthropic_configured) configured.add('anthropic');
if (data.openai_configured) configured.add('openai');
if (data.deepseek_configured) configured.add('deepseek');
if (data.ollama_configured) configured.add('ollama');
// Default to anthropic if nothing is configured
if (configured.size === 0) configured.add('anthropic');
setExpandedProviders(configured);
};
// Load available models from the provider's API
const loadModels = async () => {
setModelsLoading(true);
try {
const result = await AIAPI.getModels();
if (result.models && result.models.length > 0) {
setAvailableModels(result.models);
}
} catch (e) {
// Silently fail - user can still type model names manually
logger.debug('[AISettings] Failed to load models from API:', e);
} finally {
setModelsLoading(false);
}
};
const loadSettings = async () => {
@ -79,6 +169,10 @@ export const AISettings: Component = () => {
const data = await AIAPI.getSettings();
setSettings(data);
resetForm(data);
// Load available models after settings (needs API key to be configured)
if (data.configured) {
loadModels();
}
} catch (error) {
logger.error('[AISettings] Failed to load settings:', error);
notificationStore.error('Failed to load AI settings');
@ -116,14 +210,8 @@ export const AISettings: Component = () => {
}
});
const handleProviderChange = (provider: AIProvider) => {
setForm('provider', provider);
// Update model to default for new provider if current model doesn't look like it belongs
const currentModel = form.model;
if (!currentModel || currentModel === DEFAULT_MODELS[settings()?.provider || 'anthropic']) {
setForm('model', DEFAULT_MODELS[provider]);
}
};
// Note: handleProviderChange is no longer used as we now use multi-provider accordions
// The provider is implicitly determined by the selected model (e.g., "anthropic:claude-opus")
const handleSave = async (event?: Event) => {
event?.preventDefault();
@ -170,6 +258,33 @@ export const AISettings: Component = () => {
payload.patrol_auto_fix = form.patrolAutoFix;
}
// Include model overrides if changed
if (form.chatModel !== (settings()?.chat_model || '')) {
payload.chat_model = form.chatModel;
}
if (form.patrolModel !== (settings()?.patrol_model || '')) {
payload.patrol_model = form.patrolModel;
}
// Include multi-provider credentials if set (non-empty)
if (form.anthropicApiKey.trim()) {
payload.anthropic_api_key = form.anthropicApiKey.trim();
}
if (form.openaiApiKey.trim()) {
payload.openai_api_key = form.openaiApiKey.trim();
}
if (form.deepseekApiKey.trim()) {
payload.deepseek_api_key = form.deepseekApiKey.trim();
}
// Always include Ollama URL changes
if (form.ollamaBaseUrl !== (settings()?.ollama_base_url || 'http://localhost:11434')) {
payload.ollama_base_url = form.ollamaBaseUrl.trim();
}
if (form.openaiBaseUrl !== (settings()?.openai_base_url || '')) {
payload.openai_base_url = form.openaiBaseUrl.trim();
}
const updated = await AIAPI.updateSettings(payload);
setSettings(updated);
resetForm(updated);
@ -201,82 +316,10 @@ export const AISettings: Component = () => {
}
};
const handleStartOAuth = async () => {
setStartingOAuth(true);
try {
const result = await AIAPI.startOAuth();
// Store the auth URL and state for the user to visit manually
setOAuthAuthUrl(result.auth_url);
setOAuthState(result.state);
setOAuthCode('');
notificationStore.info('Click the link below to sign in, then paste the code back here', 5000);
} catch (error) {
logger.error('[AISettings] OAuth start failed:', error);
const message = error instanceof Error ? error.message : 'Failed to start OAuth flow';
notificationStore.error(message);
} finally {
setStartingOAuth(false);
}
};
// 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
const handleExchangeCode = async () => {
const code = oauthCode().trim();
const state = oauthState();
if (!code || !state) {
notificationStore.error('Please enter the authorization code');
return;
}
setExchangingCode(true);
try {
await AIAPI.exchangeOAuthCode(code, state);
notificationStore.success('Successfully connected to Claude with your subscription!');
// Clear OAuth flow state
setOAuthAuthUrl(null);
setOAuthState(null);
setOAuthCode('');
// Reload settings
await loadSettings();
} catch (error) {
logger.error('[AISettings] OAuth code exchange failed:', error);
const message = error instanceof Error ? error.message : 'Failed to exchange authorization code';
notificationStore.error(message);
} finally {
setExchangingCode(false);
}
};
const handleCancelOAuth = () => {
setOAuthAuthUrl(null);
setOAuthState(null);
setOAuthCode('');
};
const handleDisconnectOAuth = async () => {
if (!confirm('Are you sure you want to disconnect your Claude subscription? You will need to provide an API key to continue using AI features.')) {
return;
}
setDisconnectingOAuth(true);
try {
await AIAPI.disconnectOAuth();
notificationStore.success('Claude subscription disconnected');
// Reload settings
await loadSettings();
} catch (error) {
logger.error('[AISettings] OAuth disconnect failed:', error);
const message = error instanceof Error ? error.message : 'Failed to disconnect OAuth';
notificationStore.error(message);
} finally {
setDisconnectingOAuth(false);
}
};
const needsApiKey = () => form.provider !== 'ollama' && (form.provider !== 'anthropic' || form.authMethod !== 'oauth');
const showBaseUrl = () => form.provider === 'ollama' || form.provider === 'openai' || form.provider === 'deepseek';
const isAnthropicWithOAuth = () => form.provider === 'anthropic' && form.authMethod === 'oauth';
const showAuthMethodSelector = () => form.provider === 'anthropic';
// Legacy helper functions removed - multi-provider accordions handle all provider-specific UI
return (
<Card
@ -342,289 +385,308 @@ export const AISettings: Component = () => {
<Show when={!loading()}>
<div class="space-y-4">
{/* Provider Selection */}
{/* Model Selection */}
<div class={formField}>
<label class={labelClass()}>AI Provider</label>
<div class="grid grid-cols-3 gap-3">
<For each={PROVIDERS}>
{(provider) => (
<button
type="button"
class={`p-3 rounded-lg border-2 text-left transition-all ${form.provider === provider
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/30'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
onClick={() => handleProviderChange(provider)}
disabled={saving()}
>
<div class="font-medium text-sm text-gray-900 dark:text-gray-100">
{PROVIDER_NAMES[provider]}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{PROVIDER_DESCRIPTIONS[provider]}
</div>
</button>
)}
</For>
</div>
</div>
{/* Authentication Method - only for Anthropic */}
<Show when={showAuthMethodSelector()}>
<div class={formField}>
<label class={labelClass()}>Authentication Method</label>
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class={`p-3 rounded-lg border-2 text-left transition-all ${form.authMethod === 'api_key'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/30'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
onClick={() => setForm('authMethod', 'api_key')}
disabled={saving()}
>
<div class="font-medium text-sm text-gray-900 dark:text-gray-100">
API Key
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Pay per token usage
</div>
</button>
<button
type="button"
class={`p-3 rounded-lg border-2 text-left transition-all opacity-60 cursor-not-allowed ${form.authMethod === 'oauth'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/30'
: 'border-gray-200 dark:border-gray-700'
}`}
disabled={true}
title="OAuth with Claude Pro/Max subscription is not yet available. Anthropic currently restricts third-party OAuth access."
>
<div class="font-medium text-sm text-gray-900 dark:text-gray-100 flex items-center gap-1.5">
Claude Pro/Max
<span class="inline-flex items-center px-1.5 py-0.5 text-[10px] font-semibold bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded">
Unavailable
</span>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Use your subscription
</div>
</button>
</div>
<p class={formHelpText}>
{form.authMethod === 'api_key'
? 'Pay-per-use API billing from console.anthropic.com'
: 'Use your Claude Pro ($20/mo) or Max ($100+/mo) subscription'}
</p>
</div>
</Show>
{/* OAuth Login/Status - shown when Anthropic + OAuth selected */}
<Show when={isAnthropicWithOAuth()}>
<div class={`${formField} p-4 rounded-lg border ${settings()?.oauth_connected
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
: oauthAuthUrl()
? 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800'
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
}`}>
{/* Connected state */}
<Show when={settings()?.oauth_connected}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Connected to Claude with your subscription
</div>
<p class="text-xs text-green-700 dark:text-green-300 mt-1">
AI requests use your Claude Pro/Max subscription limits instead of API billing.
</p>
</div>
<button
type="button"
class="px-3 py-1.5 text-xs border border-red-300 dark:border-red-700 text-red-700 dark:text-red-300 rounded hover:bg-red-50 dark:hover:bg-red-900/30 disabled:opacity-50"
onClick={handleDisconnectOAuth}
disabled={disconnectingOAuth() || saving()}
>
{disconnectingOAuth() ? 'Disconnecting...' : 'Disconnect'}
</button>
</div>
</Show>
{/* OAuth flow in progress - show URL and code input */}
<Show when={!settings()?.oauth_connected && oauthAuthUrl()}>
<div class="space-y-4">
<div class="text-sm text-amber-800 dark:text-amber-200">
<strong>Step 1:</strong> Click the link below to sign in with your Anthropic account:
</div>
<a
href={oauthAuthUrl()!}
target="_blank"
rel="noopener noreferrer"
class="block p-2 bg-white dark:bg-gray-800 rounded border border-amber-300 dark:border-amber-700 text-xs text-blue-600 dark:text-blue-400 hover:underline break-all"
>
{oauthAuthUrl()}
</a>
<div class="text-sm text-amber-800 dark:text-amber-200">
<strong>Step 2:</strong> After signing in, you'll see a code. Paste it here:
</div>
<div class="flex gap-2">
<input
type="text"
value={oauthCode()}
onInput={(e) => 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"
/>
<button
type="button"
class="px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 text-white text-sm rounded-md hover:from-purple-700 hover:to-pink-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleExchangeCode}
disabled={exchangingCode() || !oauthCode().trim()}
>
{exchangingCode() ? 'Connecting...' : 'Connect'}
</button>
</div>
<button
type="button"
class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
onClick={handleCancelOAuth}
>
Cancel
</button>
</div>
</Show>
{/* Initial state - show sign in button */}
<Show when={!settings()?.oauth_connected && !oauthAuthUrl()}>
<div class="text-center">
<div class="text-sm text-blue-800 dark:text-blue-200 mb-3">
<strong>Use your Claude subscription</strong>
<p class="text-xs mt-1 text-blue-700 dark:text-blue-300">
Sign in with your Anthropic account to use your Pro/Max subscription for AI features instead of API billing.
</p>
</div>
<button
type="button"
class="px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-md hover:from-purple-700 hover:to-pink-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 mx-auto"
onClick={handleStartOAuth}
disabled={startingOAuth() || saving()}
>
<Show when={startingOAuth()}>
<span class="h-4 w-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
</Show>
<Show when={!startingOAuth()}>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
</svg>
</Show>
{startingOAuth() ? 'Starting...' : 'Sign in with Claude'}
</button>
</div>
</Show>
</div>
</Show>
{/* API Key - not shown for Ollama or when using OAuth */}
<Show when={needsApiKey()}>
<div class={formField}>
<div class="flex items-center justify-between">
<label class={labelClass('mb-0')}>API Key</label>
<Show when={settings()?.api_key_set}>
<button
type="button"
class="text-xs text-purple-600 hover:underline dark:text-purple-300"
onClick={() => {
if (!saving()) {
setForm('apiKey', '');
setForm('clearApiKey', true);
notificationStore.info('API key will be cleared on save', 2500);
}
}}
disabled={saving()}
>
Clear stored key
</button>
</Show>
</div>
<label class={labelClass()}>
Default Model
{modelsLoading() && <span class="ml-2 text-xs text-gray-500">(loading models...)</span>}
</label>
<Show when={availableModels().length > 0} fallback={
<input
type="password"
value={form.apiKey}
onInput={(event) => {
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()}
/>
<p class={formHelpText}>
{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'}
</p>
</div>
</Show>
{/* Model */}
<div class={formField}>
<label class={labelClass()}>Model</label>
<input
type="text"
value={form.model}
onInput={(event) => setForm('model', event.currentTarget.value)}
placeholder={DEFAULT_MODELS[form.provider]}
class={controlClass()}
disabled={saving()}
/>
}>
<select
value={form.model}
onChange={(e) => setForm('model', e.currentTarget.value)}
class={controlClass()}
disabled={saving()}
>
<Show when={!form.model || !availableModels().some(m => m.id === form.model)}>
<option value={form.model}>{form.model || 'Select a model...'}</option>
</Show>
<For each={Array.from(groupModelsByProvider(availableModels()).entries())}>
{([provider, models]) => (
<optgroup label={PROVIDER_DISPLAY_NAMES[provider] || provider}>
<For each={models}>
{(model) => (
<option value={model.id}>
{model.name || model.id.split(':').pop()}
</option>
)}
</For>
</optgroup>
)}
</For>
</select>
</Show>
<p class={formHelpText}>
{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.'}
</p>
</div>
{/* Base URL - shown for Ollama (required) and OpenAI (optional) */}
<Show when={showBaseUrl()}>
<div class={formField}>
<label class={labelClass()}>
{form.provider === 'ollama' ? 'Ollama Server URL' : 'API Base URL (optional)'}
</label>
{/* Chat Model Override */}
<div class={formField}>
<label class={labelClass()}>Chat Model (Interactive)</label>
<Show when={availableModels().length > 0} fallback={
<input
type="url"
value={form.baseUrl}
onInput={(event) => 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()}
/>
<p class={formHelpText}>
{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'}
}>
<select
value={form.chatModel}
onChange={(e) => setForm('chatModel', e.currentTarget.value)}
class={controlClass()}
disabled={saving()}
>
<option value="">Use default model ({form.model || 'not set'})</option>
<For each={Array.from(groupModelsByProvider(availableModels()).entries())}>
{([provider, models]) => (
<optgroup label={PROVIDER_DISPLAY_NAMES[provider] || provider}>
<For each={models}>
{(model) => (
<option value={model.id}>
{model.name || model.id.split(':').pop()}
</option>
)}
</For>
</optgroup>
)}
</For>
</select>
</Show>
<p class={formHelpText}>
Model for interactive AI chat. Use a more capable model for complex reasoning.
</p>
</div>
{/* Patrol Model Override */}
<div class={formField}>
<label class={labelClass()}>Patrol Model (Background)</label>
<Show when={availableModels().length > 0} fallback={
<input
type="text"
value={form.patrolModel}
onInput={(e) => setForm('patrolModel', e.currentTarget.value)}
placeholder="Leave empty to use default model"
class={controlClass()}
disabled={saving()}
/>
}>
<select
value={form.patrolModel}
onChange={(e) => setForm('patrolModel', e.currentTarget.value)}
class={controlClass()}
disabled={saving()}
>
<option value="">Use default model ({form.model || 'not set'})</option>
<For each={Array.from(groupModelsByProvider(availableModels()).entries())}>
{([provider, models]) => (
<optgroup label={PROVIDER_DISPLAY_NAMES[provider] || provider}>
<For each={models}>
{(model) => (
<option value={model.id}>
{model.name || model.id.split(':').pop()}
</option>
)}
</For>
</optgroup>
)}
</For>
</select>
</Show>
<p class={formHelpText}>
Model for background patrol analysis. Use a cheaper/faster model to save tokens.
</p>
</div>
{/* AI Provider Configuration - Configure API keys for all providers */}
<div class={`${formField} p-4 rounded-lg border bg-gradient-to-br from-purple-50 to-indigo-50 dark:from-purple-900/20 dark:to-indigo-900/20 border-purple-200 dark:border-purple-800`}>
<div class="mb-3">
<h4 class="font-medium text-gray-900 dark:text-white flex items-center gap-2">
<svg class="w-5 h-5 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
AI Provider Configuration
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
Configure API keys for each AI provider you want to use. Models from all configured providers will appear in the model selectors.
</p>
</div>
</Show>
{/* Provider Accordions */}
<div class="space-y-2">
{/* Anthropic */}
<div class={`border rounded-lg overflow-hidden ${settings()?.anthropic_configured ? 'border-green-300 dark:border-green-700' : 'border-gray-200 dark:border-gray-700'}`}>
<button
type="button"
class="w-full px-3 py-2 flex items-center justify-between bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
onClick={() => {
const current = expandedProviders();
const next = new Set(current);
if (next.has('anthropic')) next.delete('anthropic');
else next.add('anthropic');
setExpandedProviders(next);
}}
>
<div class="flex items-center gap-2">
<span class="font-medium text-sm">Anthropic (Claude)</span>
<Show when={settings()?.anthropic_configured}>
<span class="px-1.5 py-0.5 text-[10px] font-semibold bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded">Configured</span>
</Show>
</div>
<svg class={`w-4 h-4 transition-transform ${expandedProviders().has('anthropic') ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<Show when={expandedProviders().has('anthropic')}>
<div class="px-3 py-3 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700">
<input
type="password"
value={form.anthropicApiKey}
onInput={(e) => setForm('anthropicApiKey', e.currentTarget.value)}
placeholder={settings()?.anthropic_configured ? '••••••••••• (configured)' : 'sk-ant-... (from console.anthropic.com)'}
class={controlClass()}
disabled={saving()}
/>
<p class="text-xs text-gray-500 mt-1">Get your API key from <a href="https://console.anthropic.com" target="_blank" class="text-purple-600 hover:underline">console.anthropic.com</a></p>
</div>
</Show>
</div>
{/* OpenAI */}
<div class={`border rounded-lg overflow-hidden ${settings()?.openai_configured ? 'border-green-300 dark:border-green-700' : 'border-gray-200 dark:border-gray-700'}`}>
<button
type="button"
class="w-full px-3 py-2 flex items-center justify-between bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
onClick={() => {
const current = expandedProviders();
const next = new Set(current);
if (next.has('openai')) next.delete('openai');
else next.add('openai');
setExpandedProviders(next);
}}
>
<div class="flex items-center gap-2">
<span class="font-medium text-sm">OpenAI (GPT-4)</span>
<Show when={settings()?.openai_configured}>
<span class="px-1.5 py-0.5 text-[10px] font-semibold bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded">Configured</span>
</Show>
</div>
<svg class={`w-4 h-4 transition-transform ${expandedProviders().has('openai') ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<Show when={expandedProviders().has('openai')}>
<div class="px-3 py-3 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700 space-y-2">
<input
type="password"
value={form.openaiApiKey}
onInput={(e) => setForm('openaiApiKey', e.currentTarget.value)}
placeholder={settings()?.openai_configured ? '••••••••••• (configured)' : 'sk-... (from platform.openai.com)'}
class={controlClass()}
disabled={saving()}
/>
<input
type="url"
value={form.openaiBaseUrl}
onInput={(e) => setForm('openaiBaseUrl', e.currentTarget.value)}
placeholder="Custom base URL (optional, for Azure OpenAI)"
class={controlClass()}
disabled={saving()}
/>
<p class="text-xs text-gray-500">Get your API key from <a href="https://platform.openai.com" target="_blank" class="text-purple-600 hover:underline">platform.openai.com</a></p>
</div>
</Show>
</div>
{/* DeepSeek */}
<div class={`border rounded-lg overflow-hidden ${settings()?.deepseek_configured ? 'border-green-300 dark:border-green-700' : 'border-gray-200 dark:border-gray-700'}`}>
<button
type="button"
class="w-full px-3 py-2 flex items-center justify-between bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
onClick={() => {
const current = expandedProviders();
const next = new Set(current);
if (next.has('deepseek')) next.delete('deepseek');
else next.add('deepseek');
setExpandedProviders(next);
}}
>
<div class="flex items-center gap-2">
<span class="font-medium text-sm">DeepSeek</span>
<Show when={settings()?.deepseek_configured}>
<span class="px-1.5 py-0.5 text-[10px] font-semibold bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded">Configured</span>
</Show>
</div>
<svg class={`w-4 h-4 transition-transform ${expandedProviders().has('deepseek') ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<Show when={expandedProviders().has('deepseek')}>
<div class="px-3 py-3 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700">
<input
type="password"
value={form.deepseekApiKey}
onInput={(e) => setForm('deepseekApiKey', e.currentTarget.value)}
placeholder={settings()?.deepseek_configured ? '••••••••••• (configured)' : 'sk-... (from platform.deepseek.com)'}
class={controlClass()}
disabled={saving()}
/>
<p class="text-xs text-gray-500 mt-1">Get your API key from <a href="https://platform.deepseek.com" target="_blank" class="text-purple-600 hover:underline">platform.deepseek.com</a></p>
</div>
</Show>
</div>
{/* Ollama */}
<div class={`border rounded-lg overflow-hidden ${settings()?.ollama_configured ? 'border-green-300 dark:border-green-700' : 'border-gray-200 dark:border-gray-700'}`}>
<button
type="button"
class="w-full px-3 py-2 flex items-center justify-between bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
onClick={() => {
const current = expandedProviders();
const next = new Set(current);
if (next.has('ollama')) next.delete('ollama');
else next.add('ollama');
setExpandedProviders(next);
}}
>
<div class="flex items-center gap-2">
<span class="font-medium text-sm">Ollama (Local)</span>
<Show when={settings()?.ollama_configured}>
<span class="px-1.5 py-0.5 text-[10px] font-semibold bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded">Available</span>
</Show>
</div>
<svg class={`w-4 h-4 transition-transform ${expandedProviders().has('ollama') ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<Show when={expandedProviders().has('ollama')}>
<div class="px-3 py-3 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700">
<input
type="url"
value={form.ollamaBaseUrl}
onInput={(e) => setForm('ollamaBaseUrl', e.currentTarget.value)}
placeholder="http://localhost:11434"
class={controlClass()}
disabled={saving()}
/>
<p class="text-xs text-gray-500 mt-1">URL where your Ollama server is running. No API key needed - it's free!</p>
</div>
</Show>
</div>
</div>
</div>
{/* Autonomous Mode */}
<div class={`${formField} p-4 rounded-lg border ${form.autonomousMode ? 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800' : 'bg-gray-50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700'}`}>
@ -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'}
</span>
</div>
</Show>

View file

@ -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 }
);
}}
>

View file

@ -5,6 +5,7 @@ interface AIChatContext {
targetId?: string;
context?: Record<string, unknown>;
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<string, unknown>) {
// 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<string, unknown>) {
const name = (additionalContext?.guestName as string) ||
(additionalContext?.name as string) ||
targetId;
(additionalContext?.name as string) ||
targetId;
this.addContextItem(targetType, targetId, name, additionalContext || {});
setIsAIChatOpen(true);
},

View file

@ -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<string, unknown>;
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

View file

@ -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()

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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()

View file

@ -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 {

View file

@ -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))

View file

@ -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 = ""