diff --git a/frontend-modern/src/api/ai.ts b/frontend-modern/src/api/ai.ts index b91dadb2e..4adec2b93 100644 --- a/frontend-modern/src/api/ai.ts +++ b/frontend-modern/src/api/ai.ts @@ -7,6 +7,7 @@ import type { AIExecuteResponse, AIStreamEvent, AICostSummary, + ModelInfo, } from '@/types/ai'; import type { AnomaliesResponse, @@ -44,8 +45,8 @@ export class AIAPI { } // Get available models from the AI provider - static async getModels(): Promise<{ models: { id: string; name: string; description?: string; notable?: boolean }[]; error?: string }> { - return apiFetchJSON(`${this.baseUrl}/ai/models`) as Promise<{ models: { id: string; name: string; description?: string; notable?: boolean }[]; error?: string }>; + static async getModels(): Promise<{ models: ModelInfo[]; error?: string }> { + return apiFetchJSON(`${this.baseUrl}/ai/models`) as Promise<{ models: ModelInfo[]; error?: string }>; } // Get AI cost/usage summary diff --git a/frontend-modern/src/components/AI/Chat/ModelSelector.tsx b/frontend-modern/src/components/AI/Chat/ModelSelector.tsx index 414bae085..f4984b53e 100644 --- a/frontend-modern/src/components/AI/Chat/ModelSelector.tsx +++ b/frontend-modern/src/components/AI/Chat/ModelSelector.tsx @@ -1,5 +1,5 @@ import { Component, For, Show, createSignal, createMemo, onMount, onCleanup } from 'solid-js'; -import { PROVIDER_DISPLAY_NAMES, getProviderFromModelId, groupModelsByProvider } from '../aiChatUtils'; +import { PROVIDER_DISPLAY_NAMES, getProviderForModel, groupModelsByProvider } from '../aiChatUtils'; import type { ModelInfo } from './types'; export interface ModelSelectorProps { @@ -46,7 +46,7 @@ export const ModelSelector: Component = (props) => { if (!query) return notableFilteredModels(); const baseModels = props.models; return baseModels.filter((model) => { - const provider = getProviderFromModelId(model.id); + const provider = getProviderForModel(model); const providerName = PROVIDER_DISPLAY_NAMES[provider] || provider; const modelName = model.name || ''; return ( diff --git a/frontend-modern/src/components/AI/Chat/types.ts b/frontend-modern/src/components/AI/Chat/types.ts index 454b06c95..9f6b5cb21 100644 --- a/frontend-modern/src/components/AI/Chat/types.ts +++ b/frontend-modern/src/components/AI/Chat/types.ts @@ -1,3 +1,5 @@ +import type { AIProvider } from '@/types/ai'; + // Chat component types export interface ToolExecution { @@ -78,6 +80,7 @@ export interface ModelInfo { name: string; description?: string; notable?: boolean; + provider?: AIProvider; } // Stream event types from backend diff --git a/frontend-modern/src/components/AI/__tests__/aiChatUtils.test.ts b/frontend-modern/src/components/AI/__tests__/aiChatUtils.test.ts index 9ec93d682..07528b1b5 100644 --- a/frontend-modern/src/components/AI/__tests__/aiChatUtils.test.ts +++ b/frontend-modern/src/components/AI/__tests__/aiChatUtils.test.ts @@ -51,6 +51,17 @@ describe('aiChatUtils', () => { expect(grouped.get('openai')?.map((m) => m.id)).toEqual(['openai:gpt-4o']); expect(grouped.get('anthropic')?.map((m) => m.id)).toEqual(['claude-3-5-sonnet']); }); + + it('prefers explicit provider metadata over name heuristics', () => { + const models: ModelInfo[] = [ + { id: 'llama3-8b', name: 'Llama 3 8B', provider: 'openai' }, + { id: 'qwen3.5-27b', name: 'Qwen 3.5 27B', provider: 'openai' }, + ]; + + const grouped = utils.groupModelsByProvider(models); + expect(Array.from(grouped.keys())).toEqual(['openai']); + expect(grouped.get('openai')?.map((m) => m.id)).toEqual(['llama3-8b', 'qwen3.5-27b']); + }); }); describe('sanitizeThinking', () => { diff --git a/frontend-modern/src/components/AI/aiChatUtils.ts b/frontend-modern/src/components/AI/aiChatUtils.ts index 04f22b05d..9f9b58e46 100644 --- a/frontend-modern/src/components/AI/aiChatUtils.ts +++ b/frontend-modern/src/components/AI/aiChatUtils.ts @@ -1,66 +1,6 @@ import { marked } from 'marked'; import DOMPurify from 'dompurify'; -import type { ModelInfo } from '@/types/ai'; - -// Provider display names for grouped model selection -export const PROVIDER_DISPLAY_NAMES: Record = { - anthropic: 'Anthropic', - openai: 'OpenAI', - deepseek: 'DeepSeek', - gemini: 'Google Gemini', - ollama: 'Ollama', -}; - -// Known provider prefixes — only these are treated as explicit "provider:model" delimiters. -// This avoids misinterpreting colons in model names like "llama3.2:latest" or "model:free". -const KNOWN_PROVIDERS = ['anthropic', 'openai', 'deepseek', 'gemini', 'ollama']; - -// Parse provider from model ID (format: "provider:model-name") -export function getProviderFromModelId(modelId: string): string { - // Check for explicit known provider prefix (e.g. "openai:gpt-4o") - const colonIndex = modelId.indexOf(':'); - if (colonIndex > 0) { - const prefix = modelId.substring(0, colonIndex); - if (KNOWN_PROVIDERS.includes(prefix)) { - return prefix; - } - } - // Vendor-prefixed names like "google/gemini-*" or "meta-llama/llama-*" are - // OpenRouter model IDs routed through the OpenAI-compatible provider. - if (modelId.includes('/')) { - return 'openai'; - } - // Strip colon suffix for detection (e.g. "llama3.2:latest" → "llama3.2") - const name = colonIndex > 0 ? modelId.substring(0, colonIndex) : modelId; - // Default detection for models without prefix - if (name.startsWith('claude') || name.startsWith('opus') || name.startsWith('sonnet') || name.startsWith('haiku')) { - return 'anthropic'; - } - if (name.startsWith('gpt') || name.startsWith('o1') || name.startsWith('o3') || name.startsWith('o4')) { - return 'openai'; - } - if (name.startsWith('deepseek')) { - return 'deepseek'; - } - if (name.startsWith('gemini')) { - return 'gemini'; - } - return 'ollama'; -} - -// Group models by provider for grouped rendering -export function groupModelsByProvider(models: ModelInfo[]): Map { - const grouped = new Map(); - - for (const model of models) { - const provider = getProviderFromModelId(model.id); - const existing = grouped.get(provider) || []; - existing.push(model); - grouped.set(provider, existing); - } - - return grouped; -} +export { PROVIDER_DISPLAY_NAMES, getProviderForModel, getProviderFromModelId, groupModelsByProvider } from '@/utils/aiModels'; // Configure marked for safe rendering marked.setOptions({ diff --git a/frontend-modern/src/components/Settings/AISettings.tsx b/frontend-modern/src/components/Settings/AISettings.tsx index f87d4b4c5..2ed2f7659 100644 --- a/frontend-modern/src/components/Settings/AISettings.tsx +++ b/frontend-modern/src/components/Settings/AISettings.tsx @@ -11,20 +11,12 @@ import { logger } from '@/utils/logger'; import { AIAPI } from '@/api/ai'; import { AIChatAPI, type ChatSession, type FileChange } from '@/api/aiChat'; import { hasFeature, loadLicenseStatus } from '@/stores/license'; -import type { AISettings as AISettingsType, AIProvider, AuthMethod } from '@/types/ai'; +import type { AISettings as AISettingsType, AIProvider, AuthMethod, ModelInfo } from '@/types/ai'; import { normalizeChatSessions } from '@/components/Settings/aiSettingsChatSessions'; +import { PROVIDER_DISPLAY_NAMES, getProviderFromModelId, groupModelsByProvider } from '@/utils/aiModels'; // Providers are now configured via accordion sections, not a single-provider selector -// Provider display names for optgroup labels -const PROVIDER_DISPLAY_NAMES: Record = { - anthropic: 'Anthropic', - openai: 'OpenAI', - deepseek: 'DeepSeek', - gemini: 'Google Gemini', - ollama: 'Ollama', -}; - type ControlLevel = 'read_only' | 'controlled' | 'autonomous'; const normalizeControlLevel = (value?: string): ControlLevel => { @@ -37,43 +29,6 @@ const normalizeControlLevel = (value?: string): ControlLevel => { return 'read_only'; }; -// Known provider prefixes — only these are treated as explicit "provider:model" delimiters. -// This avoids misinterpreting colons in model names like "llama3.2:latest" or "model:free". -const KNOWN_PROVIDERS = ['anthropic', 'openai', 'deepseek', 'gemini', 'ollama']; - -// Parse provider from model ID (format: "provider:model-name") -function getProviderFromModelId(modelId: string): string { - // Check for explicit known provider prefix (e.g. "openai:gpt-4o") - const colonIndex = modelId.indexOf(':'); - if (colonIndex > 0) { - const prefix = modelId.substring(0, colonIndex); - if (KNOWN_PROVIDERS.includes(prefix)) { - return prefix; - } - } - // Vendor-prefixed names like "google/gemini-*" or "meta-llama/llama-*" are - // OpenRouter model IDs routed through the OpenAI-compatible provider. - if (modelId.includes('/')) { - return 'openai'; - } - // Strip colon suffix for detection (e.g. "llama3.2:latest" → "llama3.2") - const name = colonIndex > 0 ? modelId.substring(0, colonIndex) : modelId; - // Default detection for models without prefix - if (name.startsWith('claude') || name.startsWith('opus') || name.startsWith('sonnet') || name.startsWith('haiku')) { - return 'anthropic'; - } - if (name.startsWith('gpt') || name.startsWith('o1') || name.startsWith('o3') || name.startsWith('o4')) { - return 'openai'; - } - if (name.startsWith('deepseek')) { - return 'deepseek'; - } - if (name.startsWith('gemini')) { - return 'gemini'; - } - return 'ollama'; -} - // Check if a provider is configured based on settings function isProviderConfigured(provider: string, settings: AISettingsType | null): boolean { if (!settings) return false; @@ -93,20 +48,6 @@ function isModelProviderConfigured(modelId: string, settings: AISettingsType | n return isProviderConfigured(provider, settings); } -// Group models by provider for optgroup rendering -function groupModelsByProvider(models: { id: string; name: string; description?: string }[]): Map { - const grouped = new Map(); - - for (const model of models) { - const provider = getProviderFromModelId(model.id); - const existing = grouped.get(provider) || []; - existing.push(model); - grouped.set(provider, existing); - } - - return grouped; -} - export const AISettings: Component = () => { const navigate = useNavigate(); const [settings, setSettings] = createSignal(null); @@ -115,7 +56,7 @@ export const AISettings: Component = () => { const [testing, setTesting] = createSignal(false); // Dynamic model list from provider API - const [availableModels, setAvailableModels] = createSignal<{ id: string; name: string; description?: string }[]>([]); + const [availableModels, setAvailableModels] = createSignal([]); const [modelsLoading, setModelsLoading] = createSignal(false); const [chatSessions, setChatSessions] = createSignal([]); diff --git a/frontend-modern/src/pages/AIIntelligence.tsx b/frontend-modern/src/pages/AIIntelligence.tsx index 2b340b8a2..5e266a83e 100644 --- a/frontend-modern/src/pages/AIIntelligence.tsx +++ b/frontend-modern/src/pages/AIIntelligence.tsx @@ -25,6 +25,7 @@ interface ModelInfo { name: string; description: string; notable: boolean; + provider?: 'anthropic' | 'openai' | 'ollama' | 'deepseek' | 'gemini'; } interface AISettings { diff --git a/frontend-modern/src/types/ai.ts b/frontend-modern/src/types/ai.ts index 3220e2411..cd3d1f23c 100644 --- a/frontend-modern/src/types/ai.ts +++ b/frontend-modern/src/types/ai.ts @@ -9,6 +9,7 @@ export interface ModelInfo { description?: string; is_default?: boolean; notable?: boolean; + provider?: AIProvider; } export interface AISettings { diff --git a/frontend-modern/src/utils/aiModels.ts b/frontend-modern/src/utils/aiModels.ts new file mode 100644 index 000000000..f906348c8 --- /dev/null +++ b/frontend-modern/src/utils/aiModels.ts @@ -0,0 +1,60 @@ +import type { AIProvider, ModelInfo } from '@/types/ai'; + +export const PROVIDER_DISPLAY_NAMES: Record = { + anthropic: 'Anthropic', + openai: 'OpenAI', + deepseek: 'DeepSeek', + gemini: 'Google Gemini', + ollama: 'Ollama', +}; + +const KNOWN_PROVIDERS: AIProvider[] = ['anthropic', 'openai', 'deepseek', 'gemini', 'ollama']; + +export function getProviderFromModelId(modelId: string): string { + const colonIndex = modelId.indexOf(':'); + if (colonIndex > 0) { + const prefix = modelId.substring(0, colonIndex); + if (KNOWN_PROVIDERS.includes(prefix as AIProvider)) { + return prefix; + } + } + + if (modelId.includes('/')) { + return 'openai'; + } + + const name = colonIndex > 0 ? modelId.substring(0, colonIndex) : modelId; + if (name.startsWith('claude') || name.startsWith('opus') || name.startsWith('sonnet') || name.startsWith('haiku')) { + return 'anthropic'; + } + if (name.startsWith('gpt') || name.startsWith('o1') || name.startsWith('o3') || name.startsWith('o4')) { + return 'openai'; + } + if (name.startsWith('deepseek')) { + return 'deepseek'; + } + if (name.startsWith('gemini')) { + return 'gemini'; + } + return 'ollama'; +} + +export function getProviderForModel(model: Pick & Partial>): string { + if (model.provider && KNOWN_PROVIDERS.includes(model.provider)) { + return model.provider; + } + return getProviderFromModelId(model.id); +} + +export function groupModelsByProvider & Partial>>(models: T[]): Map { + const grouped = new Map(); + + for (const model of models) { + const provider = getProviderForModel(model); + const existing = grouped.get(provider) || []; + existing.push(model); + grouped.set(provider, existing); + } + + return grouped; +} diff --git a/internal/ai/providers/provider.go b/internal/ai/providers/provider.go index 78ed766c1..c3932061d 100644 --- a/internal/ai/providers/provider.go +++ b/internal/ai/providers/provider.go @@ -88,6 +88,7 @@ type ModelInfo struct { Description string `json:"description,omitempty"` CreatedAt int64 `json:"created_at,omitempty"` Notable bool `json:"notable"` // Whether this is a "latest and greatest" model + Provider string `json:"provider,omitempty"` } // Provider defines the interface for AI providers diff --git a/internal/ai/service.go b/internal/ai/service.go index 48f270731..58cc4b4bc 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -3850,6 +3850,7 @@ func (s *Service) ListModelsWithCache(ctx context.Context) ([]providers.ModelInf Description: providerDisplayName(providerName) + ": " + m.ID, CreatedAt: m.CreatedAt, Notable: m.Notable, + Provider: providerName, }) } diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index 99fd7bb64..d64215e55 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -1738,6 +1738,7 @@ func (h *AISettingsHandler) HandleListModels(w http.ResponseWriter, r *http.Requ Description string `json:"description,omitempty"` CreatedAt int64 `json:"created_at,omitempty"` Notable bool `json:"notable"` + Provider string `json:"provider,omitempty"` } type Response struct { @@ -1772,6 +1773,7 @@ func (h *AISettingsHandler) HandleListModels(w http.ResponseWriter, r *http.Requ Description: m.Description, CreatedAt: m.CreatedAt, Notable: m.Notable, + Provider: m.Provider, }) } diff --git a/internal/api/ai_handlers_test.go b/internal/api/ai_handlers_test.go index 6620186ec..8483282ec 100644 --- a/internal/api/ai_handlers_test.go +++ b/internal/api/ai_handlers_test.go @@ -146,8 +146,9 @@ func TestAISettingsHandler_ListModels_Ollama(t *testing.T) { var resp struct { Models []struct { - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` } `json:"models"` Error string `json:"error"` } @@ -160,6 +161,11 @@ func TestAISettingsHandler_ListModels_Ollama(t *testing.T) { if len(resp.Models) != 2 { t.Fatalf("expected 2 models, got %+v", resp.Models) } + for _, model := range resp.Models { + if model.Provider != config.AIProviderOllama { + t.Fatalf("expected ollama provider for model %+v", model) + } + } } func TestAISettingsHandler_Execute_Ollama(t *testing.T) {