mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
Preserve provider metadata in AI model lists (#1320)
This commit is contained in:
parent
5f372e257f
commit
1de1392c9b
13 changed files with 97 additions and 129 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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[]>([]);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ interface ModelInfo {
|
|||
name: string;
|
||||
description: string;
|
||||
notable: boolean;
|
||||
provider?: 'anthropic' | 'openai' | 'ollama' | 'deepseek' | 'gemini';
|
||||
}
|
||||
|
||||
interface AISettings {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export interface ModelInfo {
|
|||
description?: string;
|
||||
is_default?: boolean;
|
||||
notable?: boolean;
|
||||
provider?: AIProvider;
|
||||
}
|
||||
|
||||
export interface AISettings {
|
||||
|
|
|
|||
60
frontend-modern/src/utils/aiModels.ts
Normal file
60
frontend-modern/src/utils/aiModels.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue