Preserve provider metadata in AI model lists (#1320)

This commit is contained in:
rcourtman 2026-03-25 13:08:15 +00:00
parent 5f372e257f
commit 1de1392c9b
13 changed files with 97 additions and 129 deletions

View file

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

View file

@ -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<ModelSelectorProps> = (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 (

View file

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

View file

@ -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', () => {

View file

@ -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<string, string> = {
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<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;
}
export { PROVIDER_DISPLAY_NAMES, getProviderForModel, getProviderFromModelId, groupModelsByProvider } from '@/utils/aiModels';
// Configure marked for safe rendering
marked.setOptions({

View file

@ -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<string, string> = {
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<string, { id: string; name: string; description?: string }[]> {
const grouped = new Map<string, { id: string; name: string; description?: string }[]>();
for (const model of models) {
const provider = getProviderFromModelId(model.id);
const existing = grouped.get(provider) || [];
existing.push(model);
grouped.set(provider, existing);
}
return grouped;
}
export const AISettings: Component = () => {
const navigate = useNavigate();
const [settings, setSettings] = createSignal<AISettingsType | null>(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<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = createSignal(false);
const [chatSessions, setChatSessions] = createSignal<ChatSession[]>([]);

View file

@ -25,6 +25,7 @@ interface ModelInfo {
name: string;
description: string;
notable: boolean;
provider?: 'anthropic' | 'openai' | 'ollama' | 'deepseek' | 'gemini';
}
interface AISettings {

View file

@ -9,6 +9,7 @@ export interface ModelInfo {
description?: string;
is_default?: boolean;
notable?: boolean;
provider?: AIProvider;
}
export interface AISettings {

View file

@ -0,0 +1,60 @@
import type { AIProvider, ModelInfo } from '@/types/ai';
export const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
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<ModelInfo, 'id'> & Partial<Pick<ModelInfo, 'provider'>>): string {
if (model.provider && KNOWN_PROVIDERS.includes(model.provider)) {
return model.provider;
}
return getProviderFromModelId(model.id);
}
export function groupModelsByProvider<T extends Pick<ModelInfo, 'id'> & Partial<Pick<ModelInfo, 'provider'>>>(models: T[]): Map<string, T[]> {
const grouped = new Map<string, T[]>();
for (const model of models) {
const provider = getProviderForModel(model);
const existing = grouped.get(provider) || [];
existing.push(model);
grouped.set(provider, existing);
}
return grouped;
}