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:
parent
29404d4e81
commit
0c3dcf353a
18 changed files with 1414 additions and 422 deletions
|
|
@ -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 }> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
Loading…
Reference in a new issue