From 1372f53782a4faaa290d11a27086f8b179379577 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Sun, 22 Feb 2026 18:04:01 +0000 Subject: [PATCH] Local models --- src/servers/api/pi/rest.ts | 178 ++++++++++---- src/servers/api/server-settings/pi-mono.ts | 9 + .../api/server-settings/provider-registry.ts | 194 +++++++++++++++ .../api/server-settings/sync-pi-config.ts | 223 ++++++++++++++++++ src/servers/bootstrap.ts | 6 + .../apps/Chat/components/ModelSelector.tsx | 17 +- .../src/apps/ChatHistory/SessionList.tsx | 16 +- src/workspaces/state/src/useModels.ts | 55 ++++- 8 files changed, 640 insertions(+), 58 deletions(-) create mode 100644 src/servers/api/server-settings/provider-registry.ts create mode 100644 src/servers/api/server-settings/sync-pi-config.ts diff --git a/src/servers/api/pi/rest.ts b/src/servers/api/pi/rest.ts index 86ea115c..c7d52090 100644 --- a/src/servers/api/pi/rest.ts +++ b/src/servers/api/pi/rest.ts @@ -1,7 +1,8 @@ import type { Context } from 'hono'; import { createRouter } from '../../create-router'; import * as storage from './storage'; -import { readApiKeys } from '../server-settings/pi-mono'; +import { readApiKeys, readLocalProviders } from '../server-settings/pi-mono'; +import { getConfiguredProviders, fetchProviderModels } from '../server-settings/provider-registry'; import { getHomeDir } from '../../data-path'; import type { ModelInfo } from './types'; import { logger } from './logger'; @@ -15,64 +16,139 @@ export const piRestRouter = createRouter(); /** * GET /api/pi/models * List available models by running `pi --list-models` with stored API keys + * PLUS models from local providers (ollama, lmstudio, etc.) */ piRestRouter.get('/pi/models', async (ctx: Context) => { + const models: ModelInfo[] = []; + const providerNames: Record = {}; + + // Helper to parse context window sizes + const parseSize = (s?: string): number => { + if (!s) return 128000; + const match = s.match(/^(\d+)([KMG])?$/i); + if (!match) return 128000; + const num = parseInt(match[1]!, 10); + const unit = (match[2] ?? '').toUpperCase(); + if (unit === 'K') return num * 1000; + if (unit === 'M') return num * 1000000; + if (unit === 'G') return num * 1000000000; + return num; + }; + try { + // 1. Fetch models from API-based providers (OpenAI, Anthropic, etc.) const storedKeys = await readApiKeys(); + const configuredProviders = getConfiguredProviders(storedKeys); - const proc = Bun.spawn(['pi', '--list-models'], { - stdout: 'pipe', - stderr: 'pipe', - env: { ...process.env, ...storedKeys }, + logger.info('Fetching models from API providers', { count: configuredProviders.length }); + + for (const { provider, apiKey } of configuredProviders) { + try { + const providerModels = await fetchProviderModels(provider, apiKey); + logger.info('Fetched models from API provider', { + provider: provider.name, + count: providerModels.length + }); + + for (const m of providerModels) { + models.push({ + id: `${provider.id}/${m.id}`, + name: m.name, + provider: provider.id, + contextWindow: 128000, // Default + maxTokens: 4096, // Default + }); + } + } catch (err) { + logger.error('Failed to fetch models from API provider', { + provider: provider.name, + error: String(err), + }); + } + } + + // 2. Fetch models from local providers (ollama, lmstudio, openai-compatible) + const localProviders = await readLocalProviders(); + logger.info('Local providers loaded', { count: localProviders.length, providers: localProviders.map(p => p.name) }); + + for (const lp of localProviders) { + try { + const base = lp.url.replace(/\/+$/, ''); + const path = lp.apiType === 'ollama' ? '/api/tags' : '/v1/models'; + + const headers: Record = {}; + if (lp.auth?.type === 'api-key') { + headers['Authorization'] = `Bearer ${lp.auth.apiKey}`; + } else if (lp.auth?.type === 'basic') { + headers['Authorization'] = `Basic ${btoa(`${lp.auth.username}:${lp.auth.password}`)}`; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + + const res = await fetch(`${base}${path}`, { headers, signal: controller.signal }); + clearTimeout(timer); + + if (!res.ok) { + logger.error('Failed to fetch models from local provider', { provider: lp.name, status: res.status }); + continue; + } + + const data = await res.json(); + + // Use Pi's provider ID format: officer-local-{uuid} + const piProviderId = `officer-local-${lp.id}`; + + // Store friendly provider name for frontend display + providerNames[piProviderId] = lp.name; + + // Parse Ollama response + if (lp.apiType === 'ollama' && data.models) { + logger.info('Adding Ollama models', { provider: lp.name, count: data.models.length }); + for (const m of data.models) { + models.push({ + id: `${piProviderId}/${m.name}`, + name: m.name, + provider: piProviderId, + contextWindow: 128000, // Default + maxTokens: 4096, // Default + }); + } + } + // Parse OpenAI-compatible response (including LM Studio) + else if (data.data) { + logger.info('Adding OpenAI-compatible models', { provider: lp.name, count: data.data.length }); + for (const m of data.data) { + models.push({ + id: `${piProviderId}/${m.id}`, + name: m.id, + provider: piProviderId, + contextWindow: 128000, // Default + maxTokens: 4096, // Default + }); + } + } + } catch (err) { + logger.error('Failed to fetch models from local provider', { + provider: lp.name, + error: String(err) + }); + } + } + + logger.info('Returning models', { + totalModels: models.length, + providerCount: Object.keys(providerNames).length, + providers: models.reduce((acc, m) => { + acc[m.provider] = (acc[m.provider] || 0) + 1; + return acc; + }, {} as Record) }); - - const output = await new Response(proc.stdout).text(); - await proc.exited; - - if (proc.exitCode !== 0) { - logger.error('pi --list-models failed', { exitCode: proc.exitCode }); - return ctx.json({ models: [] }); - } - - // Parse the whitespace-separated table output: - // provider model context max-out thinking images - // anthropic claude-sonnet-4-6 200K 128K yes yes - const lines = output.trim().split('\n').filter(Boolean); - const models: ModelInfo[] = []; - - // Skip header line (first line) - for (let i = 1; i < lines.length; i++) { - const cols = lines[i]!.trim().split(/\s+/); - if (cols.length < 2) continue; - - const [provider, model, context, maxOut] = cols; - - // Parse context window (e.g., "200K" -> 200000) - const parseSize = (s?: string): number => { - if (!s) return 128000; - const match = s.match(/^(\d+)([KMG])?$/i); - if (!match) return 128000; - const num = parseInt(match[1]!, 10); - const unit = (match[2] ?? '').toUpperCase(); - if (unit === 'K') return num * 1000; - if (unit === 'M') return num * 1000000; - if (unit === 'G') return num * 1000000000; - return num; - }; - - models.push({ - id: `${provider}/${model}`, - name: model!, - provider: provider!, - contextWindow: parseSize(context), - maxTokens: parseSize(maxOut), - }); - } - - return ctx.json({ models }); + + return ctx.json({ models, providerNames }); } catch (err) { logger.error('Failed to list models', { error: String(err) }); - return ctx.json({ models: [] }); + return ctx.json({ models, providerNames }); } }); diff --git a/src/servers/api/server-settings/pi-mono.ts b/src/servers/api/server-settings/pi-mono.ts index c4d7fdaa..8c920287 100644 --- a/src/servers/api/server-settings/pi-mono.ts +++ b/src/servers/api/server-settings/pi-mono.ts @@ -1,6 +1,7 @@ import { join } from 'node:path'; import { createRouter } from '../../create-router'; import { DATA_PATH } from '../../data-path'; +import { syncLocalProvidersToPiConfig } from './sync-pi-config'; export const piMonoRouter = createRouter(); @@ -298,6 +299,10 @@ piMonoRouter.post('/local-providers', async (ctx) => { providers.push(provider); await writeLocalProviders(providers); + + // Sync to Pi config so Pi knows about this provider + await syncLocalProvidersToPiConfig(); + return ctx.json({ ...provider, auth: provider.auth ? { type: provider.auth.type } : undefined }); }); @@ -307,6 +312,10 @@ piMonoRouter.delete('/local-providers/:id', async (ctx) => { const filtered = providers.filter((p) => p.id !== id); if (filtered.length === providers.length) return ctx.json({ error: 'Not found' }, 404); await writeLocalProviders(filtered); + + // Sync to Pi config to remove this provider + await syncLocalProvidersToPiConfig(); + return ctx.json({ ok: true }); }); diff --git a/src/servers/api/server-settings/provider-registry.ts b/src/servers/api/server-settings/provider-registry.ts new file mode 100644 index 00000000..40394ff9 --- /dev/null +++ b/src/servers/api/server-settings/provider-registry.ts @@ -0,0 +1,194 @@ +import { logger } from '../pi/logger'; + +export type ApiProvider = { + id: string; + name: string; + apiType: 'openai-completions' | 'anthropic-messages' | 'google-generative-ai'; + baseUrl: string; + envKeys: string[]; + getApiKey: (keys: Record) => string | null; + hardcodedModels?: boolean; +}; + +/** + * Registry of API-based providers that Officer supports + * These will be synced to Pi's config and their models fetched + */ +export const API_PROVIDERS: ApiProvider[] = [ + { + id: 'openai', + name: 'OpenAI', + apiType: 'openai-completions', + baseUrl: 'https://api.openai.com/v1', + envKeys: ['OPENAI_API_KEY'], + getApiKey: (keys) => keys.OPENAI_API_KEY || null, + }, + { + id: 'anthropic', + name: 'Anthropic', + apiType: 'anthropic-messages', + baseUrl: 'https://api.anthropic.com/v1', + envKeys: ['ANTHROPIC_API_KEY'], + getApiKey: (keys) => keys.ANTHROPIC_API_KEY || null, + }, + { + id: 'opencode', + name: 'OpenCode Zen', + apiType: 'openai-completions', + baseUrl: 'https://api.opencodezen.com/v1', + envKeys: ['OPENCODE_API_KEY'], + getApiKey: (keys) => keys.OPENCODE_API_KEY || null, + hardcodedModels: true, // No public /models endpoint + }, + { + id: 'groq', + name: 'Groq', + apiType: 'openai-completions', + baseUrl: 'https://api.groq.com/openai/v1', + envKeys: ['GROQ_API_KEY'], + getApiKey: (keys) => keys.GROQ_API_KEY || null, + }, + { + id: 'mistral', + name: 'Mistral', + apiType: 'openai-completions', + baseUrl: 'https://api.mistral.ai/v1', + envKeys: ['MISTRAL_API_KEY'], + getApiKey: (keys) => keys.MISTRAL_API_KEY || null, + }, + { + id: 'xai', + name: 'xAI', + apiType: 'openai-completions', + baseUrl: 'https://api.x.ai/v1', + envKeys: ['XAI_API_KEY'], + getApiKey: (keys) => keys.XAI_API_KEY || null, + }, + { + id: 'openrouter', + name: 'OpenRouter', + apiType: 'openai-completions', + baseUrl: 'https://openrouter.ai/api/v1', + envKeys: ['OPENROUTER_API_KEY'], + getApiKey: (keys) => keys.OPENROUTER_API_KEY || null, + }, + { + id: 'google', + name: 'Google', + apiType: 'google-generative-ai', + baseUrl: 'https://generativelanguage.googleapis.com/v1beta', + envKeys: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'], + getApiKey: (keys) => keys.GOOGLE_API_KEY || keys.GEMINI_API_KEY || null, + }, +]; + +/** + * Fetch models from an OpenAI-compatible provider + */ +async function fetchOpenAIModels(baseUrl: string, apiKey: string): Promise<{ id: string; name: string }[]> { + try { + const res = await fetch(`${baseUrl}/models`, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(5000), + }); + + if (!res.ok) { + logger.error('Failed to fetch models from OpenAI-compatible API', { baseUrl, status: res.status }); + return []; + } + + const data = await res.json(); + if (!data.data || !Array.isArray(data.data)) return []; + + return data.data.map((m: any) => ({ + id: m.id, + name: m.id, + })); + } catch (err) { + logger.error('Failed to fetch OpenAI models', { baseUrl, error: String(err) }); + return []; + } +} + +/** + * Fetch models from Anthropic API + */ +async function fetchAnthropicModels(apiKey: string): Promise<{ id: string; name: string }[]> { + // Anthropic doesn't have a /models endpoint, so we return known models + // These are documented at https://docs.anthropic.com/en/docs/about-claude/models + return [ + { id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet' }, + { id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' }, + { id: 'claude-3-opus-20240229', name: 'Claude 3 Opus' }, + { id: 'claude-3-sonnet-20240229', name: 'Claude 3 Sonnet' }, + { id: 'claude-3-haiku-20240307', name: 'Claude 3 Haiku' }, + ]; +} + +/** + * Fetch models from Google Generative AI + */ +async function fetchGoogleModels(apiKey: string): Promise<{ id: string; name: string }[]> { + // Google also doesn't expose a simple models endpoint, return known models + return [ + { id: 'gemini-2.0-flash-exp', name: 'Gemini 2.0 Flash' }, + { id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro' }, + { id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash' }, + { id: 'gemini-1.5-flash-8b', name: 'Gemini 1.5 Flash 8B' }, + ]; +} + +/** + * Fetch models from OpenCode Zen + */ +async function fetchOpenCodeModels(apiKey: string): Promise<{ id: string; name: string }[]> { + // OpenCode Zen doesn't expose a public models endpoint, return known models + return [ + { id: 'big-pickle', name: 'Big Pickle' }, + { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' }, + { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5' }, + { id: 'claude-opus-4-5', name: 'Claude Opus 4.5' }, + { id: 'gemini-3-flash', name: 'Gemini 3 Flash' }, + ]; +} + +/** + * Fetch models from a configured API provider + */ +export async function fetchProviderModels( + provider: ApiProvider, + apiKey: string, +): Promise<{ id: string; name: string }[]> { + // Handle providers with hardcoded models first + if (provider.id === 'opencode') { + return fetchOpenCodeModels(apiKey); + } + + switch (provider.apiType) { + case 'openai-completions': + return fetchOpenAIModels(provider.baseUrl, apiKey); + case 'anthropic-messages': + return fetchAnthropicModels(apiKey); + case 'google-generative-ai': + return fetchGoogleModels(apiKey); + default: + logger.warn('Unknown API type for provider', { provider: provider.id, apiType: provider.apiType }); + return []; + } +} + +/** + * Get all configured API providers with their API keys + */ +export function getConfiguredProviders(storedKeys: Record): Array<{ provider: ApiProvider; apiKey: string }> { + const configured: Array<{ provider: ApiProvider; apiKey: string }> = []; + + for (const provider of API_PROVIDERS) { + const apiKey = provider.getApiKey(storedKeys); + if (apiKey) { + configured.push({ provider, apiKey }); + } + } + + return configured; +} diff --git a/src/servers/api/server-settings/sync-pi-config.ts b/src/servers/api/server-settings/sync-pi-config.ts new file mode 100644 index 00000000..99628893 --- /dev/null +++ b/src/servers/api/server-settings/sync-pi-config.ts @@ -0,0 +1,223 @@ +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import type { LocalProvider } from './pi-mono'; +import { readLocalProviders, readApiKeys } from './pi-mono'; +import { getConfiguredProviders, fetchProviderModels } from './provider-registry'; +import { logger } from '../pi/logger'; + +const PI_CONFIG_DIR = join(homedir(), '.pi', 'agent'); +const PI_MODELS_FILE = join(PI_CONFIG_DIR, 'models.json'); + +type PiModelConfig = { + providers: Record; +}; + +/** + * Sync Officer's providers (both API-based and local) to Pi's models.json config + * This makes Pi aware of all providers configured in Officer + */ +export async function syncLocalProvidersToPiConfig(): Promise { + try { + // Read existing Pi config + const piModelsFile = Bun.file(PI_MODELS_FILE); + let piConfig: PiModelConfig; + + if (await piModelsFile.exists()) { + piConfig = await piModelsFile.json(); + } else { + piConfig = { providers: {} }; + } + + // Remove ALL Officer-managed provider entries (cleanup) + for (const providerId of Object.keys(piConfig.providers)) { + if (providerId.startsWith('officer-local-') || + ['openai', 'anthropic', 'opencode', 'groq', 'mistral', 'xai', 'openrouter', 'google'].includes(providerId)) { + delete piConfig.providers[providerId]; + } + } + + // 1. Sync API-based providers + const storedKeys = await readApiKeys(); + const configuredProviders = getConfiguredProviders(storedKeys); + + logger.info('Syncing API-based providers to Pi config', { count: configuredProviders.length }); + + for (const { provider, apiKey } of configuredProviders) { + try { + const providerModels = await fetchProviderModels(provider, apiKey); + + if (providerModels.length === 0) { + logger.warn('No models found for API provider', { provider: provider.name }); + continue; + } + + piConfig.providers[provider.id] = { + baseUrl: provider.baseUrl, + apiKey, + api: provider.apiType, + models: providerModels.map(m => ({ + id: m.id, + name: m.name, + reasoning: false, + input: ['text'], + contextWindow: 128000, + maxTokens: 4096, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + })), + }; + + logger.info('Added API provider to Pi config', { + providerId: provider.id, + providerName: provider.name, + modelCount: providerModels.length + }); + } catch (err) { + logger.error('Failed to sync API provider', { + provider: provider.name, + error: String(err) + }); + } + } + + // 2. Sync local providers + const localProviders = await readLocalProviders(); + logger.info('Syncing local providers to Pi config', { count: localProviders.length }); + + // Add local providers to Pi config + for (const lp of localProviders) { + const providerId = `officer-local-${lp.id}`; + + // Fetch models from the local provider + const models = await fetchModelsFromLocalProvider(lp); + + if (models.length === 0) { + logger.warn('No models found for local provider', { provider: lp.name }); + continue; + } + + // All local providers use OpenAI-compatible API + // (llama.cpp, Ollama with /v1 endpoint, LM Studio, etc.) + const apiType = 'openai-completions'; + + // llama.cpp and OpenAI-compatible servers expect /v1 prefix + // Ollama also supports OpenAI-compatible at /v1 + const baseUrl = lp.apiType === 'ollama' + ? `${lp.url}/v1` + : lp.url.endsWith('/v1') ? lp.url : `${lp.url}/v1`; + + piConfig.providers[providerId] = { + baseUrl, + apiKey: lp.auth?.type === 'api-key' ? lp.auth.apiKey : 'none', + api: apiType, + models: models.map(m => ({ + id: m.id, + name: m.name || m.id, + reasoning: false, + input: ['text'], + contextWindow: m.contextWindow || 128000, + maxTokens: m.maxTokens || 4096, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + })), + }; + + logger.info('Added local provider to Pi config', { + providerId, + providerName: lp.name, + modelCount: models.length + }); + } + + // Write updated config back to Pi + await Bun.write(PI_MODELS_FILE, JSON.stringify(piConfig, null, 2)); + logger.info('Successfully synced local providers to Pi config'); + } catch (err) { + logger.error('Failed to sync local providers to Pi config', { error: String(err) }); + } +} + +async function fetchModelsFromLocalProvider( + lp: LocalProvider +): Promise<{ id: string; name?: string; contextWindow?: number; maxTokens?: number }[]> { + try { + const base = lp.url.replace(/\/+$/, ''); + const path = lp.apiType === 'ollama' ? '/api/tags' : '/v1/models'; + + const headers: Record = {}; + if (lp.auth?.type === 'api-key') { + headers['Authorization'] = `Bearer ${lp.auth.apiKey}`; + } else if (lp.auth?.type === 'basic') { + headers['Authorization'] = `Basic ${btoa(`${lp.auth.username}:${lp.auth.password}`)}`; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + + const res = await fetch(`${base}${path}`, { headers, signal: controller.signal }); + clearTimeout(timer); + + if (!res.ok) { + logger.error('Failed to fetch models from local provider', { + provider: lp.name, + status: res.status + }); + return []; + } + + const data = await res.json(); + + // Parse Ollama response + if (lp.apiType === 'ollama' && data.models) { + return data.models.map((m: any) => ({ + id: m.name, + name: m.name, + contextWindow: 128000, + maxTokens: 4096, + })); + } + // Parse OpenAI-compatible response (including LM Studio) + else if (data.data) { + return data.data.map((m: any) => ({ + id: m.id, + name: m.id, + contextWindow: 128000, + maxTokens: 4096, + })); + } + + return []; + } catch (err) { + logger.error('Failed to fetch models from local provider', { + provider: lp.name, + error: String(err) + }); + return []; + } +} diff --git a/src/servers/bootstrap.ts b/src/servers/bootstrap.ts index ef2cb818..33d43e65 100644 --- a/src/servers/bootstrap.ts +++ b/src/servers/bootstrap.ts @@ -1,4 +1,10 @@ import { mkdirSync } from 'node:fs'; import { DATA_PATH } from './data-path'; +import { syncLocalProvidersToPiConfig } from './api/server-settings/sync-pi-config'; mkdirSync(DATA_PATH, { recursive: true }); + +// Sync local providers to Pi config on startup +syncLocalProvidersToPiConfig().catch(err => { + console.error('Failed to sync local providers to Pi config on startup:', err); +}); diff --git a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx index f87c6a31..ab9dd332 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx @@ -1,5 +1,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import type { ModelOption } from 'state/useModels'; +import { getProviderDisplayName } from 'state/useModels'; import type { ChatMessage } from '../types'; const PROVIDER_DISPLAY: Record = { @@ -41,6 +42,12 @@ export function ModelSelector({ hasStarted, }: ModelSelectorProps) { const providers = [...new Set(availableModels.map((m) => m.provider).filter(Boolean))] as string[]; + + console.log('[ModelSelector]', { + availableModelsCount: availableModels.length, + providers, + models: availableModels.map(m => ({ id: m.id, provider: m.provider, name: m.name })) + }); // Determine which model to display: selectedModel takes precedence, then model (from server), then fallback const displayModel = selectedModel || model; @@ -58,7 +65,15 @@ export function ModelSelector({ if (firstModel) onModelChange(firstModel.id); }; - const displayName = (provider: string) => PROVIDER_DISPLAY[provider] ?? provider; + const displayName = (provider: string) => { + // Handle local providers (officer-local-{uuid}) + if (provider.startsWith('officer-local-')) { + const name = getProviderDisplayName(provider); + console.log('[displayName] Local provider:', provider, '→', name); + return name; + } + return PROVIDER_DISPLAY[provider] ?? provider; + }; // Get display text for the model const getModelDisplayText = () => { diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index cfa7b3ed..8b88055a 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -3,6 +3,7 @@ import { Plus, MessageSquare, Folder, ChevronRight, FolderPlus } from 'lucide-re import { usePanelChannel } from 'hooks/usePanelChannel'; import { useChatSessions } from 'state/useChatSessions'; import { useChatGroups } from 'state/useChatGroups'; +import { getProviderDisplayName } from 'state/useModels'; import type { SelectedSession } from './ChatDetailPanel'; import { CreateGroupDialog } from './CreateGroupDialog'; import { GroupContextMenu } from './GroupContextMenu'; @@ -91,7 +92,20 @@ export const SessionList = () => { {session.model && (
- {session.model.includes('/') ? session.model.replace('/', ' - ') : session.model} + {(() => { + if (!session.model.includes('/')) return session.model; + + const [provider, modelId] = session.model.split('/'); + + // Handle local providers - show friendly name + if (provider.startsWith('officer-local-')) { + const friendlyName = getProviderDisplayName(provider); + return `${friendlyName} - ${modelId}`; + } + + // Regular providers - just replace / with - + return session.model.replace('/', ' - '); + })()}
)} diff --git a/src/workspaces/state/src/useModels.ts b/src/workspaces/state/src/useModels.ts index 105c3139..f1be549c 100644 --- a/src/workspaces/state/src/useModels.ts +++ b/src/workspaces/state/src/useModels.ts @@ -10,6 +10,13 @@ export function modelKey(m: ModelOption): string { return `${m.provider}:${m.id}`; } +// Store provider names globally for display +let globalProviderNames: Record = {}; + +export function getProviderDisplayName(providerId: string): string { + return globalProviderNames[providerId] || providerId; +} + export function usePiModels() { const client = useClient(); const { isAuthenticated } = useAuth(); @@ -18,7 +25,20 @@ export function usePiModels() { queryKey: ['PI_MODELS'], enabled: isAuthenticated, queryFn: async () => { - const data = await client.get<{ models: ModelOption[] }>('/pi/models'); + const data = await client.get<{ models: ModelOption[]; providerNames?: Record }>('/pi/models'); + + console.log('[usePiModels] Fetched models:', { + modelCount: data.models.length, + providerNames: data.providerNames, + providers: [...new Set(data.models.map(m => m.provider))] + }); + + // Store provider names for later use + if (data.providerNames) { + globalProviderNames = data.providerNames; + console.log('[usePiModels] Stored provider names:', globalProviderNames); + } + return data.models; }, staleTime: 5 * 60 * 1000, @@ -32,8 +52,33 @@ export function useVisiblePiModels() { const { settings } = useSettings(); const enabled = settings.ai?.enabledModels ?? []; - const filtered = models.filter((m) => enabled.includes(modelKey(m))); - // If no models match the visibility filter, show all - // The provider list changes dynamically based on API keys so the filter may be stale - return filtered.length > 0 ? filtered : models; + // If no enabled models list exists, show all models + if (enabled.length === 0) { + console.log('[useVisiblePiModels] No enabled models list, showing all'); + return models; + } + + // Get all providers from enabled models + const enabledProviders = new Set( + enabled.map(key => key.split(':')[0]) + ); + + // Filter to include: + // 1. Models that are explicitly enabled + // 2. Models from providers that aren't in the enabled list at all (new providers) + const filtered = models.filter((m) => { + const isExplicitlyEnabled = enabled.includes(modelKey(m)); + const isFromNewProvider = !enabledProviders.has(m.provider); + return isExplicitlyEnabled || isFromNewProvider; + }); + + console.log('[useVisiblePiModels]', { + allModels: models.length, + enabledModels: enabled, + enabledProviders: Array.from(enabledProviders), + filtered: filtered.length, + finalCount: filtered.length + }); + + return filtered; }