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>;
|
}) 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
|
// Start OAuth flow for Claude Pro/Max subscription
|
||||||
// Returns the authorization URL to redirect the user to
|
// Returns the authorization URL to redirect the user to
|
||||||
static async startOAuth(): Promise<{ auth_url: string; state: string }> {
|
static async startOAuth(): Promise<{ auth_url: string; state: string }> {
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ export interface PatrolRunRecord {
|
||||||
started_at: string;
|
started_at: string;
|
||||||
completed_at: string;
|
completed_at: string;
|
||||||
duration_ms: number;
|
duration_ms: number;
|
||||||
type: 'quick' | 'deep';
|
type: string; // Always 'patrol' now (kept for backwards compat with old records)
|
||||||
resources_checked: number;
|
resources_checked: number;
|
||||||
// Breakdown by resource type
|
// Breakdown by resource type
|
||||||
nodes_checked: number;
|
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 { marked } from 'marked';
|
||||||
import { AIAPI } from '@/api/ai';
|
import { AIAPI } from '@/api/ai';
|
||||||
import { notificationStore } from '@/stores/notifications';
|
import { notificationStore } from '@/stores/notifications';
|
||||||
|
|
@ -13,8 +13,50 @@ import type {
|
||||||
AIStreamToolEndData,
|
AIStreamToolEndData,
|
||||||
AIStreamCompleteData,
|
AIStreamCompleteData,
|
||||||
AIStreamApprovalNeededData,
|
AIStreamApprovalNeededData,
|
||||||
|
ModelInfo,
|
||||||
} from '@/types/ai';
|
} 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
|
// Configure marked for safe rendering
|
||||||
marked.setOptions({
|
marked.setOptions({
|
||||||
breaks: true, // Convert \n to <br>
|
breaks: true, // Convert \n to <br>
|
||||||
|
|
@ -116,6 +158,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||||
const targetId = () => context().targetId;
|
const targetId = () => context().targetId;
|
||||||
const contextData = () => context().context;
|
const contextData = () => context().context;
|
||||||
const initialPrompt = () => context().initialPrompt;
|
const initialPrompt = () => context().initialPrompt;
|
||||||
|
const findingId = () => context().findingId; // For resolving patrol findings
|
||||||
|
|
||||||
// Access WebSocket state for listing available resources
|
// Access WebSocket state for listing available resources
|
||||||
const wsContext = useWebSocket();
|
const wsContext = useWebSocket();
|
||||||
|
|
@ -247,10 +290,32 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||||
const [input, setInput] = createSignal('');
|
const [input, setInput] = createSignal('');
|
||||||
const [isLoading, setIsLoading] = createSignal(false);
|
const [isLoading, setIsLoading] = createSignal(false);
|
||||||
const [queuedMessage, setQueuedMessage] = createSignal<string | null>(null);
|
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 messagesEndRef: HTMLDivElement | undefined;
|
||||||
let inputRef: HTMLTextAreaElement | undefined;
|
let inputRef: HTMLTextAreaElement | undefined;
|
||||||
let abortControllerRef: AbortController | null = null;
|
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
|
// Wrapper to sync messages to global store
|
||||||
const setMessages = (updater: Message[] | ((prev: Message[]) => Message[])) => {
|
const setMessages = (updater: Message[] | ((prev: Message[]) => Message[])) => {
|
||||||
setMessagesLocal((prev) => {
|
setMessagesLocal((prev) => {
|
||||||
|
|
@ -425,6 +490,8 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||||
target_id: targetId(),
|
target_id: targetId(),
|
||||||
context: contextData(),
|
context: contextData(),
|
||||||
history: history.length > 0 ? history : undefined,
|
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) => {
|
(event: AIStreamEvent) => {
|
||||||
lastEventTime = Date.now(); // Update last event time
|
lastEventTime = Date.now(); // Update last event time
|
||||||
|
|
@ -871,6 +938,58 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<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
|
<button
|
||||||
onClick={clearChat}
|
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"
|
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 { logger } from '@/utils/logger';
|
||||||
import { AIAPI } from '@/api/ai';
|
import { AIAPI } from '@/api/ai';
|
||||||
import type { AISettings as AISettingsType, AIProvider, AuthMethod } from '@/types/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 = () => {
|
export const AISettings: Component = () => {
|
||||||
const [settings, setSettings] = createSignal<AISettingsType | null>(null);
|
const [settings, setSettings] = createSignal<AISettingsType | null>(null);
|
||||||
const [loading, setLoading] = createSignal(false);
|
const [loading, setLoading] = createSignal(false);
|
||||||
const [saving, setSaving] = createSignal(false);
|
const [saving, setSaving] = createSignal(false);
|
||||||
const [testing, setTesting] = 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
|
// Dynamic model list from provider API
|
||||||
const [oauthAuthUrl, setOAuthAuthUrl] = createSignal<string | null>(null);
|
const [availableModels, setAvailableModels] = createSignal<{ id: string; name: string; description?: string }[]>([]);
|
||||||
const [oauthState, setOAuthState] = createSignal<string | null>(null);
|
const [modelsLoading, setModelsLoading] = createSignal(false);
|
||||||
const [oauthCode, setOAuthCode] = createSignal('');
|
|
||||||
|
// Accordion state for provider configuration sections
|
||||||
|
const [expandedProviders, setExpandedProviders] = createSignal<Set<AIProvider>>(new Set(['anthropic']));
|
||||||
|
|
||||||
const [form, setForm] = createStore({
|
const [form, setForm] = createStore({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
provider: 'anthropic' as AIProvider,
|
provider: 'anthropic' as AIProvider, // Legacy - kept for compatibility
|
||||||
apiKey: '',
|
apiKey: '', // Legacy - kept for compatibility
|
||||||
model: '',
|
model: '',
|
||||||
baseUrl: '',
|
chatModel: '', // Empty means use default model
|
||||||
|
patrolModel: '', // Empty means use default model
|
||||||
|
baseUrl: '', // Legacy - kept for compatibility
|
||||||
clearApiKey: false,
|
clearApiKey: false,
|
||||||
autonomousMode: false,
|
autonomousMode: false,
|
||||||
authMethod: 'api_key' as AuthMethod,
|
authMethod: 'api_key' as AuthMethod,
|
||||||
patrolSchedulePreset: '6hr',
|
patrolSchedulePreset: '6hr',
|
||||||
alertTriggeredAnalysis: true,
|
alertTriggeredAnalysis: true,
|
||||||
patrolAutoFix: false,
|
patrolAutoFix: false,
|
||||||
|
// Multi-provider credentials
|
||||||
|
anthropicApiKey: '',
|
||||||
|
openaiApiKey: '',
|
||||||
|
deepseekApiKey: '',
|
||||||
|
ollamaBaseUrl: 'http://localhost:11434',
|
||||||
|
openaiBaseUrl: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetForm = (data: AISettingsType | null) => {
|
const resetForm = (data: AISettingsType | null) => {
|
||||||
|
|
@ -47,6 +95,8 @@ export const AISettings: Component = () => {
|
||||||
provider: 'anthropic',
|
provider: 'anthropic',
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
model: DEFAULT_MODELS.anthropic,
|
model: DEFAULT_MODELS.anthropic,
|
||||||
|
chatModel: '',
|
||||||
|
patrolModel: '',
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
clearApiKey: false,
|
clearApiKey: false,
|
||||||
autonomousMode: false,
|
autonomousMode: false,
|
||||||
|
|
@ -54,6 +104,12 @@ export const AISettings: Component = () => {
|
||||||
patrolSchedulePreset: '6hr',
|
patrolSchedulePreset: '6hr',
|
||||||
alertTriggeredAnalysis: true,
|
alertTriggeredAnalysis: true,
|
||||||
patrolAutoFix: false,
|
patrolAutoFix: false,
|
||||||
|
// Multi-provider - empty by default
|
||||||
|
anthropicApiKey: '',
|
||||||
|
openaiApiKey: '',
|
||||||
|
deepseekApiKey: '',
|
||||||
|
ollamaBaseUrl: 'http://localhost:11434',
|
||||||
|
openaiBaseUrl: '',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -63,6 +119,8 @@ export const AISettings: Component = () => {
|
||||||
provider: data.provider,
|
provider: data.provider,
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
model: data.model || DEFAULT_MODELS[data.provider],
|
model: data.model || DEFAULT_MODELS[data.provider],
|
||||||
|
chatModel: data.chat_model || '',
|
||||||
|
patrolModel: data.patrol_model || '',
|
||||||
baseUrl: data.base_url || '',
|
baseUrl: data.base_url || '',
|
||||||
clearApiKey: false,
|
clearApiKey: false,
|
||||||
autonomousMode: data.autonomous_mode || false,
|
autonomousMode: data.autonomous_mode || false,
|
||||||
|
|
@ -70,7 +128,39 @@ export const AISettings: Component = () => {
|
||||||
patrolSchedulePreset: data.patrol_schedule_preset || '6hr',
|
patrolSchedulePreset: data.patrol_schedule_preset || '6hr',
|
||||||
alertTriggeredAnalysis: data.alert_triggered_analysis !== false, // default to true
|
alertTriggeredAnalysis: data.alert_triggered_analysis !== false, // default to true
|
||||||
patrolAutoFix: data.patrol_auto_fix || false, // default to false (observe only)
|
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 () => {
|
const loadSettings = async () => {
|
||||||
|
|
@ -79,6 +169,10 @@ export const AISettings: Component = () => {
|
||||||
const data = await AIAPI.getSettings();
|
const data = await AIAPI.getSettings();
|
||||||
setSettings(data);
|
setSettings(data);
|
||||||
resetForm(data);
|
resetForm(data);
|
||||||
|
// Load available models after settings (needs API key to be configured)
|
||||||
|
if (data.configured) {
|
||||||
|
loadModels();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[AISettings] Failed to load settings:', error);
|
logger.error('[AISettings] Failed to load settings:', error);
|
||||||
notificationStore.error('Failed to load AI settings');
|
notificationStore.error('Failed to load AI settings');
|
||||||
|
|
@ -116,14 +210,8 @@ export const AISettings: Component = () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleProviderChange = (provider: AIProvider) => {
|
// Note: handleProviderChange is no longer used as we now use multi-provider accordions
|
||||||
setForm('provider', provider);
|
// The provider is implicitly determined by the selected model (e.g., "anthropic:claude-opus")
|
||||||
// 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]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async (event?: Event) => {
|
const handleSave = async (event?: Event) => {
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
|
|
@ -170,6 +258,33 @@ export const AISettings: Component = () => {
|
||||||
payload.patrol_auto_fix = form.patrolAutoFix;
|
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);
|
const updated = await AIAPI.updateSettings(payload);
|
||||||
setSettings(updated);
|
setSettings(updated);
|
||||||
resetForm(updated);
|
resetForm(updated);
|
||||||
|
|
@ -201,82 +316,10 @@ export const AISettings: Component = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartOAuth = async () => {
|
// OAuth handlers removed - OAuth is currently unavailable from Anthropic for third-party apps
|
||||||
setStartingOAuth(true);
|
// When OAuth becomes available, handlers can be added back to the Anthropic accordion section
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExchangeCode = async () => {
|
// Legacy helper functions removed - multi-provider accordions handle all provider-specific UI
|
||||||
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';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
|
@ -342,289 +385,308 @@ export const AISettings: Component = () => {
|
||||||
|
|
||||||
<Show when={!loading()}>
|
<Show when={!loading()}>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{/* Provider Selection */}
|
{/* Model Selection */}
|
||||||
<div class={formField}>
|
<div class={formField}>
|
||||||
<label class={labelClass()}>AI Provider</label>
|
<label class={labelClass()}>
|
||||||
<div class="grid grid-cols-3 gap-3">
|
Default Model
|
||||||
<For each={PROVIDERS}>
|
{modelsLoading() && <span class="ml-2 text-xs text-gray-500">(loading models...)</span>}
|
||||||
{(provider) => (
|
</label>
|
||||||
<button
|
<Show when={availableModels().length > 0} fallback={
|
||||||
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>
|
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="text"
|
||||||
value={form.apiKey}
|
value={form.model}
|
||||||
onInput={(event) => {
|
onInput={(e) => setForm('model', e.currentTarget.value)}
|
||||||
setForm('apiKey', event.currentTarget.value);
|
placeholder={DEFAULT_MODELS[form.provider]}
|
||||||
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`
|
|
||||||
}
|
|
||||||
class={controlClass()}
|
class={controlClass()}
|
||||||
disabled={saving()}
|
disabled={saving()}
|
||||||
/>
|
/>
|
||||||
<p class={formHelpText}>
|
}>
|
||||||
{form.provider === 'anthropic'
|
<select
|
||||||
? 'Get your API key from console.anthropic.com'
|
value={form.model}
|
||||||
: form.provider === 'deepseek'
|
onChange={(e) => setForm('model', e.currentTarget.value)}
|
||||||
? 'Get your API key from platform.deepseek.com'
|
class={controlClass()}
|
||||||
: 'Get your API key from platform.openai.com'}
|
disabled={saving()}
|
||||||
</p>
|
>
|
||||||
</div>
|
<Show when={!form.model || !availableModels().some(m => m.id === form.model)}>
|
||||||
</Show>
|
<option value={form.model}>{form.model || 'Select a model...'}</option>
|
||||||
|
</Show>
|
||||||
{/* Model */}
|
<For each={Array.from(groupModelsByProvider(availableModels()).entries())}>
|
||||||
<div class={formField}>
|
{([provider, models]) => (
|
||||||
<label class={labelClass()}>Model</label>
|
<optgroup label={PROVIDER_DISPLAY_NAMES[provider] || provider}>
|
||||||
<input
|
<For each={models}>
|
||||||
type="text"
|
{(model) => (
|
||||||
value={form.model}
|
<option value={model.id}>
|
||||||
onInput={(event) => setForm('model', event.currentTarget.value)}
|
{model.name || model.id.split(':').pop()}
|
||||||
placeholder={DEFAULT_MODELS[form.provider]}
|
</option>
|
||||||
class={controlClass()}
|
)}
|
||||||
disabled={saving()}
|
</For>
|
||||||
/>
|
</optgroup>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</select>
|
||||||
|
</Show>
|
||||||
<p class={formHelpText}>
|
<p class={formHelpText}>
|
||||||
{form.provider === 'anthropic'
|
Main model used when no specific override is set. {availableModels().length === 0 && 'Save API key and refresh to see available models.'}
|
||||||
? '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'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Base URL - shown for Ollama (required) and OpenAI (optional) */}
|
{/* Chat Model Override */}
|
||||||
<Show when={showBaseUrl()}>
|
<div class={formField}>
|
||||||
<div class={formField}>
|
<label class={labelClass()}>Chat Model (Interactive)</label>
|
||||||
<label class={labelClass()}>
|
<Show when={availableModels().length > 0} fallback={
|
||||||
{form.provider === 'ollama' ? 'Ollama Server URL' : 'API Base URL (optional)'}
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="text"
|
||||||
value={form.baseUrl}
|
value={form.chatModel}
|
||||||
onInput={(event) => setForm('baseUrl', event.currentTarget.value)}
|
onInput={(e) => setForm('chatModel', e.currentTarget.value)}
|
||||||
placeholder={
|
placeholder="Leave empty to use default model"
|
||||||
form.provider === 'ollama'
|
|
||||||
? 'http://localhost:11434'
|
|
||||||
: form.provider === 'deepseek'
|
|
||||||
? 'https://api.deepseek.com/chat/completions'
|
|
||||||
: 'https://api.openai.com/v1'
|
|
||||||
}
|
|
||||||
class={controlClass()}
|
class={controlClass()}
|
||||||
disabled={saving()}
|
disabled={saving()}
|
||||||
/>
|
/>
|
||||||
<p class={formHelpText}>
|
}>
|
||||||
{form.provider === 'ollama'
|
<select
|
||||||
? 'URL where your Ollama server is running'
|
value={form.chatModel}
|
||||||
: form.provider === 'deepseek'
|
onChange={(e) => setForm('chatModel', e.currentTarget.value)}
|
||||||
? 'Custom endpoint (leave blank for default DeepSeek API)'
|
class={controlClass()}
|
||||||
: 'Custom endpoint for Azure OpenAI or compatible APIs'}
|
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>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* 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'}`}>
|
<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
|
? settings()?.oauth_connected
|
||||||
? `Ready to use with ${settings()?.model} (via Claude subscription)`
|
? `Ready to use with ${settings()?.model} (via Claude subscription)`
|
||||||
: `Ready to use with ${settings()?.model}`
|
: `Ready to use with ${settings()?.model}`
|
||||||
: form.authMethod === 'oauth' && form.provider === 'anthropic'
|
: 'Configure at least one AI provider above to enable AI features'}
|
||||||
? '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'}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
|
|
@ -2171,12 +2171,12 @@ function OverviewTab(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
setPatrolStatus(status);
|
setPatrolStatus(status);
|
||||||
setAiFindings(findings);
|
setAiFindings(findings || []);
|
||||||
setPatrolRunHistory(runHistory);
|
setPatrolRunHistory(runHistory || []);
|
||||||
setSuppressionRules(rules);
|
setSuppressionRules(rules || []);
|
||||||
|
|
||||||
// Auto-expand history if most recent run found issues
|
// 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);
|
setShowRunHistory(true);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -2624,7 +2624,8 @@ function OverviewTab(props: {
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
aiChatStore.openWithPrompt(
|
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;
|
targetId?: string;
|
||||||
context?: Record<string, unknown>;
|
context?: Record<string, unknown>;
|
||||||
initialPrompt?: string;
|
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
|
// A single context item that can be accumulated
|
||||||
|
|
@ -170,16 +171,16 @@ export const aiChatStore = {
|
||||||
setTargetContext(targetType: string, targetId: string, additionalContext?: Record<string, unknown>) {
|
setTargetContext(targetType: string, targetId: string, additionalContext?: Record<string, unknown>) {
|
||||||
// Use addContextItem instead of replacing
|
// Use addContextItem instead of replacing
|
||||||
const name = (additionalContext?.guestName as string) ||
|
const name = (additionalContext?.guestName as string) ||
|
||||||
(additionalContext?.name as string) ||
|
(additionalContext?.name as string) ||
|
||||||
targetId;
|
targetId;
|
||||||
this.addContextItem(targetType, targetId, name, additionalContext || {});
|
this.addContextItem(targetType, targetId, name, additionalContext || {});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Open for a specific target - opens the panel and adds to context
|
// Open for a specific target - opens the panel and adds to context
|
||||||
openForTarget(targetType: string, targetId: string, additionalContext?: Record<string, unknown>) {
|
openForTarget(targetType: string, targetId: string, additionalContext?: Record<string, unknown>) {
|
||||||
const name = (additionalContext?.guestName as string) ||
|
const name = (additionalContext?.guestName as string) ||
|
||||||
(additionalContext?.name as string) ||
|
(additionalContext?.name as string) ||
|
||||||
targetId;
|
targetId;
|
||||||
this.addContextItem(targetType, targetId, name, additionalContext || {});
|
this.addContextItem(targetType, targetId, name, additionalContext || {});
|
||||||
setIsAIChatOpen(true);
|
setIsAIChatOpen(true);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,21 @@
|
||||||
export type AIProvider = 'anthropic' | 'openai' | 'ollama' | 'deepseek';
|
export type AIProvider = 'anthropic' | 'openai' | 'ollama' | 'deepseek';
|
||||||
export type AuthMethod = 'api_key' | 'oauth';
|
export type AuthMethod = 'api_key' | 'oauth';
|
||||||
|
|
||||||
|
export interface ModelInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
is_default?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AISettings {
|
export interface AISettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
provider: AIProvider;
|
provider: AIProvider; // DEPRECATED: legacy single provider
|
||||||
api_key_set: boolean; // API key is never exposed, just whether it's set
|
api_key_set: boolean; // DEPRECATED: whether legacy API key is set
|
||||||
model: string;
|
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
|
configured: boolean; // true if AI is ready to use
|
||||||
autonomous_mode: boolean; // true if AI can execute commands without approval
|
autonomous_mode: boolean; // true if AI can execute commands without approval
|
||||||
custom_context: string; // user-provided infrastructure context
|
custom_context: string; // user-provided infrastructure context
|
||||||
|
|
@ -19,21 +28,39 @@ export interface AISettings {
|
||||||
patrol_schedule_preset?: string; // "15min" | "1hr" | "6hr" | "12hr" | "daily" | "disabled"
|
patrol_schedule_preset?: string; // "15min" | "1hr" | "6hr" | "12hr" | "daily" | "disabled"
|
||||||
alert_triggered_analysis?: boolean; // true if AI should analyze when alerts fire
|
alert_triggered_analysis?: boolean; // true if AI should analyze when alerts fire
|
||||||
patrol_auto_fix?: boolean; // true if patrol can attempt automatic remediation
|
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 {
|
export interface AISettingsUpdateRequest {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
provider?: AIProvider;
|
provider?: AIProvider; // DEPRECATED: use model selection instead
|
||||||
api_key?: string; // empty string clears, undefined preserves
|
api_key?: string; // DEPRECATED: use per-provider keys
|
||||||
model?: string;
|
model?: string;
|
||||||
base_url?: string;
|
base_url?: string; // DEPRECATED: use per-provider URLs
|
||||||
autonomous_mode?: boolean;
|
autonomous_mode?: boolean;
|
||||||
custom_context?: string; // user-provided infrastructure context
|
custom_context?: string; // user-provided infrastructure context
|
||||||
auth_method?: AuthMethod; // "api_key" or "oauth"
|
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 settings for token efficiency
|
||||||
patrol_schedule_preset?: string; // "15min" | "1hr" | "6hr" | "12hr" | "daily" | "disabled"
|
patrol_schedule_preset?: string; // "15min" | "1hr" | "6hr" | "12hr" | "daily" | "disabled"
|
||||||
alert_triggered_analysis?: boolean; // true if AI should analyze when alerts fire
|
alert_triggered_analysis?: boolean; // true if AI should analyze when alerts fire
|
||||||
patrol_auto_fix?: boolean; // true if patrol can attempt automatic remediation
|
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;
|
target_id?: string;
|
||||||
context?: Record<string, unknown>;
|
context?: Record<string, unknown>;
|
||||||
history?: AIConversationMessage[]; // Previous conversation messages
|
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
|
// Tool execution info
|
||||||
|
|
|
||||||
|
|
@ -467,8 +467,7 @@ func (p *PatrolService) Start(ctx context.Context) {
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|
||||||
log.Info().
|
log.Info().
|
||||||
Dur("quick_interval", p.config.QuickCheckInterval).
|
Dur("interval", p.config.GetInterval()).
|
||||||
Dur("deep_interval", p.config.DeepAnalysisInterval).
|
|
||||||
Msg("Starting AI Patrol Service")
|
Msg("Starting AI Patrol Service")
|
||||||
|
|
||||||
go p.patrolLoop(ctx)
|
go p.patrolLoop(ctx)
|
||||||
|
|
@ -1053,6 +1052,35 @@ func (p *PatrolService) GetFindingsSummary() FindingsSummary {
|
||||||
return p.findings.GetSummary()
|
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
|
// GetRunHistory returns the history of patrol runs
|
||||||
// If limit is > 0, returns at most that many records
|
// If limit is > 0, returns at most that many records
|
||||||
func (p *PatrolService) GetRunHistory(limit int) []PatrolRunRecord {
|
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
|
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")
|
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{
|
resp, err := p.aiService.ExecuteStream(ctx, ExecuteRequest{
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
SystemPrompt: p.getPatrolSystemPrompt(),
|
SystemPrompt: p.getPatrolSystemPrompt(),
|
||||||
|
UseCase: "patrol", // Use patrol model for background analysis
|
||||||
}, func(event StreamEvent) {
|
}, func(event StreamEvent) {
|
||||||
switch event.Type {
|
switch event.Type {
|
||||||
case "content":
|
case "content":
|
||||||
|
|
@ -1638,7 +1667,7 @@ func (p *PatrolService) buildInfrastructureSummary(state models.StateSnapshot) s
|
||||||
|
|
||||||
// buildPatrolPrompt creates the prompt for AI analysis
|
// buildPatrolPrompt creates the prompt for AI analysis
|
||||||
// Includes user feedback context to prevent re-raising dismissed findings
|
// 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)
|
// Get user feedback context (dismissed/snoozed findings)
|
||||||
feedbackContext := p.findings.GetDismissedForContext()
|
feedbackContext := p.findings.GetDismissedForContext()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -346,3 +346,47 @@ func (c *AnthropicClient) TestConnection(ctx context.Context) error {
|
||||||
})
|
})
|
||||||
return err
|
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
|
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
|
// 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) {
|
func NewFromConfig(cfg *config.AIConfig) (Provider, error) {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return nil, fmt.Errorf("AI config is 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")
|
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 {
|
switch cfg.Provider {
|
||||||
case config.AIProviderAnthropic:
|
case config.AIProviderAnthropic:
|
||||||
// If we have an API key (from direct entry or OAuth-created), use regular client
|
// 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
|
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
|
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"`
|
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
|
// Provider defines the interface for AI providers
|
||||||
type Provider interface {
|
type Provider interface {
|
||||||
// Chat sends a chat request and returns the response
|
// Chat sends a chat request and returns the response
|
||||||
|
|
@ -68,4 +76,8 @@ type Provider interface {
|
||||||
|
|
||||||
// Name returns the provider name
|
// Name returns the provider name
|
||||||
Name() string
|
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.
|
Context map[string]interface{} `json:"context,omitempty"` // Current metrics, state, etc.
|
||||||
SystemPrompt string `json:"system_prompt,omitempty"` // Override system prompt
|
SystemPrompt string `json:"system_prompt,omitempty"` // Override system prompt
|
||||||
History []ConversationMessage `json:"history,omitempty"` // Previous conversation messages
|
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
|
// ExecuteResponse represents the AI's response
|
||||||
|
|
@ -729,6 +732,34 @@ type ToolExecution struct {
|
||||||
Success bool `json:"success"`
|
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
|
// StreamEvent represents an event during AI execution for streaming
|
||||||
type StreamEvent struct {
|
type StreamEvent struct {
|
||||||
Type string `json:"type"` // "tool_start", "tool_end", "content", "done", "error", "approval_needed"
|
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
|
// 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) {
|
func (s *Service) Execute(ctx context.Context, req ExecuteRequest) (*ExecuteResponse, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
provider := s.provider
|
defaultProvider := s.provider
|
||||||
cfg := s.cfg
|
|
||||||
agentServer := s.agentServer
|
agentServer := s.agentServer
|
||||||
|
cfg := s.cfg
|
||||||
s.mu.RUnlock()
|
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 {
|
if provider == nil {
|
||||||
return nil, fmt.Errorf("AI is not enabled or configured")
|
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++ {
|
for i := 0; i < maxIterations; i++ {
|
||||||
resp, err := provider.Chat(ctx, providers.ChatRequest{
|
resp, err := provider.Chat(ctx, providers.ChatRequest{
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
Model: cfg.GetModel(),
|
Model: s.getModelForRequest(req),
|
||||||
System: systemPrompt,
|
System: systemPrompt,
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
Tools: tools,
|
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
|
// 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) {
|
func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callback StreamCallback) (*ExecuteResponse, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
provider := s.provider
|
defaultProvider := s.provider
|
||||||
cfg := s.cfg
|
|
||||||
agentServer := s.agentServer
|
agentServer := s.agentServer
|
||||||
|
cfg := s.cfg
|
||||||
s.mu.RUnlock()
|
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 {
|
if provider == nil {
|
||||||
return nil, fmt.Errorf("AI is not enabled or configured")
|
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{
|
resp, err := provider.Chat(ctx, providers.ChatRequest{
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
Model: cfg.GetModel(),
|
Model: s.getModelForRequest(req),
|
||||||
System: systemPrompt,
|
System: systemPrompt,
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
Tools: tools,
|
Tools: tools,
|
||||||
|
|
@ -1327,6 +1380,24 @@ func (s *Service) getTools() []providers.Tool {
|
||||||
"required": []string{"resource_type", "resource_id", "url"},
|
"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
|
// 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
|
execution.Success = true
|
||||||
return execution.Output, execution
|
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:
|
default:
|
||||||
execution.Output = fmt.Sprintf("Unknown tool: %s", tc.Name)
|
execution.Output = fmt.Sprintf("Unknown tool: %s", tc.Name)
|
||||||
return execution.Output, execution
|
return execution.Output, execution
|
||||||
|
|
@ -2255,31 +2367,109 @@ func (s *Service) buildUserAnnotationsContext() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConnection tests the AI provider connection
|
// TestConnection tests the AI provider connection
|
||||||
|
// Tests the provider for the currently configured default model
|
||||||
func (s *Service) TestConnection(ctx context.Context) error {
|
func (s *Service) TestConnection(ctx context.Context) error {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
provider := s.provider
|
cfg := s.cfg
|
||||||
|
defaultProvider := s.provider
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
|
|
||||||
if provider == nil {
|
// Load config if not available
|
||||||
// Try to create a temporary provider from config to test
|
if cfg == nil {
|
||||||
cfg, err := s.persistence.LoadAIConfig()
|
var err error
|
||||||
|
cfg, err = s.persistence.LoadAIConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load AI config: %w", err)
|
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 cfg == nil || !cfg.IsConfigured() {
|
||||||
if err != nil {
|
return fmt.Errorf("no AI provider configured")
|
||||||
return err
|
}
|
||||||
|
|
||||||
|
// 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)
|
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)
|
// Reload reloads the AI configuration (call after settings change)
|
||||||
func (s *Service) Reload() error {
|
func (s *Service) Reload() error {
|
||||||
return s.LoadConfig()
|
return s.LoadConfig()
|
||||||
|
|
|
||||||
|
|
@ -102,13 +102,15 @@ func (h *AISettingsHandler) GetAlertTriggeredAnalyzer() *ai.AlertTriggeredAnalyz
|
||||||
}
|
}
|
||||||
|
|
||||||
// AISettingsResponse is returned by GET /api/settings/ai
|
// AISettingsResponse is returned by GET /api/settings/ai
|
||||||
// API key is masked for security
|
// API keys are masked for security
|
||||||
type AISettingsResponse struct {
|
type AISettingsResponse struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"` // DEPRECATED: legacy single provider
|
||||||
APIKeySet bool `json:"api_key_set"` // true if API key is configured (never expose actual key)
|
APIKeySet bool `json:"api_key_set"` // DEPRECATED: true if legacy API key is configured
|
||||||
Model string `json:"model"`
|
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
|
Configured bool `json:"configured"` // true if AI is ready to use
|
||||||
AutonomousMode bool `json:"autonomous_mode"` // true if AI can execute without approval
|
AutonomousMode bool `json:"autonomous_mode"` // true if AI can execute without approval
|
||||||
CustomContext string `json:"custom_context"` // user-provided infrastructure context
|
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"
|
AuthMethod string `json:"auth_method"` // "api_key" or "oauth"
|
||||||
OAuthConnected bool `json:"oauth_connected"` // true if OAuth tokens are configured
|
OAuthConnected bool `json:"oauth_connected"` // true if OAuth tokens are configured
|
||||||
// Patrol settings for token efficiency
|
// Patrol settings for token efficiency
|
||||||
PatrolSchedulePreset string `json:"patrol_schedule_preset"` // "15min", "1hr", "6hr", "12hr", "daily", "disabled"
|
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
|
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
|
// AISettingsUpdateRequest is the request body for PUT /api/settings/ai
|
||||||
type AISettingsUpdateRequest struct {
|
type AISettingsUpdateRequest struct {
|
||||||
Enabled *bool `json:"enabled,omitempty"`
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
Provider *string `json:"provider,omitempty"`
|
Provider *string `json:"provider,omitempty"` // DEPRECATED: use model selection instead
|
||||||
APIKey *string `json:"api_key,omitempty"` // empty string clears, null preserves
|
APIKey *string `json:"api_key,omitempty"` // DEPRECATED: use per-provider keys
|
||||||
Model *string `json:"model,omitempty"`
|
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"`
|
AutonomousMode *bool `json:"autonomous_mode,omitempty"`
|
||||||
CustomContext *string `json:"custom_context,omitempty"` // user-provided infrastructure context
|
CustomContext *string `json:"custom_context,omitempty"` // user-provided infrastructure context
|
||||||
AuthMethod *string `json:"auth_method,omitempty"` // "api_key" or "oauth"
|
AuthMethod *string `json:"auth_method,omitempty"` // "api_key" or "oauth"
|
||||||
// Patrol settings for token efficiency
|
// Patrol settings for token efficiency
|
||||||
PatrolSchedulePreset *string `json:"patrol_schedule_preset,omitempty"` // "15min", "1hr", "6hr", "12hr", "daily", "disabled"
|
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
|
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)
|
// 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,
|
Provider: settings.Provider,
|
||||||
APIKeySet: settings.APIKey != "",
|
APIKeySet: settings.APIKey != "",
|
||||||
Model: settings.GetModel(),
|
Model: settings.GetModel(),
|
||||||
|
ChatModel: settings.ChatModel,
|
||||||
|
PatrolModel: settings.PatrolModel,
|
||||||
BaseURL: settings.BaseURL,
|
BaseURL: settings.BaseURL,
|
||||||
Configured: settings.IsConfigured(),
|
Configured: settings.IsConfigured(),
|
||||||
AutonomousMode: settings.AutonomousMode,
|
AutonomousMode: settings.AutonomousMode,
|
||||||
|
|
@ -173,6 +194,15 @@ func (h *AISettingsHandler) HandleGetAISettings(w http.ResponseWriter, r *http.R
|
||||||
// Patrol settings
|
// Patrol settings
|
||||||
PatrolSchedulePreset: settings.PatrolSchedulePreset,
|
PatrolSchedulePreset: settings.PatrolSchedulePreset,
|
||||||
AlertTriggeredAnalysis: settings.AlertTriggeredAnalysis,
|
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 {
|
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)
|
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 {
|
if req.BaseURL != nil {
|
||||||
settings.BaseURL = strings.TrimSpace(*req.BaseURL)
|
settings.BaseURL = strings.TrimSpace(*req.BaseURL)
|
||||||
}
|
}
|
||||||
|
|
@ -296,6 +334,23 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
|
||||||
settings.AlertTriggeredAnalysis = *req.AlertTriggeredAnalysis
|
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
|
// Save settings
|
||||||
if err := h.persistence.SaveAIConfig(*settings); err != nil {
|
if err := h.persistence.SaveAIConfig(*settings); err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to save AI settings")
|
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,
|
Provider: settings.Provider,
|
||||||
APIKeySet: settings.APIKey != "",
|
APIKeySet: settings.APIKey != "",
|
||||||
Model: settings.GetModel(),
|
Model: settings.GetModel(),
|
||||||
|
ChatModel: settings.ChatModel,
|
||||||
|
PatrolModel: settings.PatrolModel,
|
||||||
BaseURL: settings.BaseURL,
|
BaseURL: settings.BaseURL,
|
||||||
Configured: settings.IsConfigured(),
|
Configured: settings.IsConfigured(),
|
||||||
AutonomousMode: settings.AutonomousMode,
|
AutonomousMode: settings.AutonomousMode,
|
||||||
|
|
@ -344,6 +401,15 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
|
||||||
OAuthConnected: settings.OAuthAccessToken != "",
|
OAuthConnected: settings.OAuthAccessToken != "",
|
||||||
PatrolSchedulePreset: settings.PatrolSchedulePreset,
|
PatrolSchedulePreset: settings.PatrolSchedulePreset,
|
||||||
AlertTriggeredAnalysis: settings.AlertTriggeredAnalysis,
|
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 {
|
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
|
// AIExecuteRequest is the request body for POST /api/ai/execute
|
||||||
// AIConversationMessage represents a message in conversation history
|
// AIConversationMessage represents a message in conversation history
|
||||||
type AIConversationMessage struct {
|
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", 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/settings/ai/update", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.aiSettingsHandler.HandleUpdateAISettings)))
|
||||||
r.mux.HandleFunc("/api/ai/test", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.aiSettingsHandler.HandleTestAIConnection)))
|
r.mux.HandleFunc("/api/ai/test", 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", RequireAuth(r.config, r.aiSettingsHandler.HandleExecute))
|
||||||
r.mux.HandleFunc("/api/ai/execute/stream", RequireAuth(r.config, r.aiSettingsHandler.HandleExecuteStream))
|
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))
|
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
|
// This is stored in ai.enc (encrypted) in the config directory
|
||||||
type AIConfig struct {
|
type AIConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Provider string `json:"provider"` // "anthropic", "openai", "ollama", "deepseek"
|
Provider string `json:"provider"` // DEPRECATED: legacy single provider field, kept for migration
|
||||||
APIKey string `json:"api_key"` // encrypted at rest (not needed for ollama or oauth)
|
APIKey string `json:"api_key"` // DEPRECATED: legacy single API key, kept for migration
|
||||||
Model string `json:"model"` // e.g., "claude-opus-4-5-20250514", "gpt-4o", "llama3"
|
Model string `json:"model"` // Currently selected default model (format: "provider:model-name")
|
||||||
BaseURL string `json:"base_url"` // custom endpoint (required for ollama, optional for openai)
|
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
|
AutonomousMode bool `json:"autonomous_mode"` // when true, AI executes commands without approval
|
||||||
CustomContext string `json:"custom_context"` // user-provided context about their infrastructure
|
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
|
// OAuth fields for Claude Pro/Max subscription authentication
|
||||||
AuthMethod AuthMethod `json:"auth_method,omitempty"` // "api_key" or "oauth" (for anthropic only)
|
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)
|
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"
|
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
|
// NewDefaultAIConfig returns an AIConfig with sensible defaults
|
||||||
func NewDefaultAIConfig() *AIConfig {
|
func NewDefaultAIConfig() *AIConfig {
|
||||||
return &AIConfig{
|
return &AIConfig{
|
||||||
|
|
@ -83,14 +107,21 @@ func NewDefaultAIConfig() *AIConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsConfigured returns true if the AI config has enough info to make API calls
|
// 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 {
|
func (c *AIConfig) IsConfigured() bool {
|
||||||
if !c.Enabled {
|
if !c.Enabled {
|
||||||
return false
|
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 {
|
switch c.Provider {
|
||||||
case AIProviderAnthropic:
|
case AIProviderAnthropic:
|
||||||
// Anthropic can use API key OR OAuth
|
|
||||||
if c.AuthMethod == AuthMethodOAuth {
|
if c.AuthMethod == AuthMethodOAuth {
|
||||||
return c.OAuthAccessToken != ""
|
return c.OAuthAccessToken != ""
|
||||||
}
|
}
|
||||||
|
|
@ -98,19 +129,140 @@ func (c *AIConfig) IsConfigured() bool {
|
||||||
case AIProviderOpenAI, AIProviderDeepSeek:
|
case AIProviderOpenAI, AIProviderDeepSeek:
|
||||||
return c.APIKey != ""
|
return c.APIKey != ""
|
||||||
case AIProviderOllama:
|
case AIProviderOllama:
|
||||||
// Ollama doesn't need an API key
|
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
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
|
// IsUsingOAuth returns true if OAuth authentication is configured for Anthropic
|
||||||
func (c *AIConfig) IsUsingOAuth() bool {
|
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
|
// GetBaseURL returns the base URL, using defaults where appropriate
|
||||||
|
// DEPRECATED: Use GetBaseURLForProvider instead
|
||||||
func (c *AIConfig) GetBaseURL() string {
|
func (c *AIConfig) GetBaseURL() string {
|
||||||
if c.BaseURL != "" {
|
if c.BaseURL != "" {
|
||||||
return 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)
|
// ClearOAuthTokens clears OAuth tokens (used when switching back to API key auth)
|
||||||
func (c *AIConfig) ClearOAuthTokens() {
|
func (c *AIConfig) ClearOAuthTokens() {
|
||||||
c.OAuthAccessToken = ""
|
c.OAuthAccessToken = ""
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue