From 556cbd1a03bbbdb3d03c2b69a20e8c6090816b91 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Mon, 18 May 2026 12:40:24 +0300 Subject: [PATCH] feat: load models through provider adapters Provider model selection had outgrown a single hardcoded service. The old service mixed shared caching with provider catalogs and CLI lookup details. That made stale model lists more likely as providers changed on separate schedules. Move model discovery behind each provider so lookup lives next to the integration. The shared service now focuses on provider resolution, caching, persistence, and dedupe. Return cache metadata and add bypassCache because model availability changes outside the app. The UI and /models command can show freshness and let users force a provider refresh. Surface model descriptions while keeping fallback catalogs for unavailable CLIs or SDKs. --- server/claude-sdk.js | 4 +- .../list/claude/claude-models.provider.ts | 83 ++++ .../providers/list/claude/claude.provider.ts | 3 + .../list/codex/codex-models.provider.ts | 88 ++++ .../providers/list/codex/codex.provider.ts | 3 + .../list/cursor/cursor-models.provider.ts | 180 ++++++++ .../providers/list/cursor/cursor.provider.ts | 3 + .../list/gemini/gemini-models.provider.ts | 23 + .../providers/list/gemini/gemini.provider.ts | 3 + .../list/opencode/opencode-models.provider.ts | 139 ++++++ .../list/opencode/opencode.provider.ts | 3 + server/modules/providers/provider.routes.ts | 7 +- .../services/provider-models.service.ts | 415 +++++------------- .../shared/base/abstract.provider.ts | 2 + .../tests/provider-models.service.test.ts | 133 ++++-- server/routes/agent.js | 6 +- server/routes/commands.js | 28 +- server/routes/cursor.js | 4 +- server/routes/tests/commands.test.js | 60 +-- server/shared/interfaces.ts | 20 + server/shared/types.ts | 26 ++ .../chat/hooks/useChatComposerState.ts | 4 +- .../chat/hooks/useChatProviderState.ts | 124 ++++-- src/components/chat/view/ChatInterface.tsx | 10 + .../view/subcomponents/ChatMessagesPane.tsx | 17 +- .../view/subcomponents/CommandResultModal.tsx | 143 +++++- .../ProviderSelectionEmptyState.tsx | 70 ++- src/types/app.ts | 7 + 28 files changed, 1125 insertions(+), 483 deletions(-) create mode 100644 server/modules/providers/list/claude/claude-models.provider.ts create mode 100644 server/modules/providers/list/codex/codex-models.provider.ts create mode 100644 server/modules/providers/list/cursor/cursor-models.provider.ts create mode 100644 server/modules/providers/list/gemini/gemini-models.provider.ts create mode 100644 server/modules/providers/list/opencode/opencode-models.provider.ts diff --git a/server/claude-sdk.js b/server/claude-sdk.js index ae626626..dc48a92f 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -17,7 +17,7 @@ import crypto from 'crypto'; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; -import { CLAUDE_MODELS } from './modules/providers/services/provider-models.service.js'; +import { CLAUDE_FALLBACK_MODELS } from './modules/providers/list/claude/claude-models.provider.js'; import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js'; import { createNotificationEvent, @@ -204,7 +204,7 @@ function mapCliOptionsToSDK(options = {}) { // Map model (default to sonnet) // Valid models: sonnet, opus, haiku, opusplan, sonnet[1m] - sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT; + sdkOptions.model = options.model || CLAUDE_FALLBACK_MODELS.DEFAULT; // Model logged at query start below // Map system prompt configuration diff --git a/server/modules/providers/list/claude/claude-models.provider.ts b/server/modules/providers/list/claude/claude-models.provider.ts new file mode 100644 index 00000000..ca8e8eb8 --- /dev/null +++ b/server/modules/providers/list/claude/claude-models.provider.ts @@ -0,0 +1,83 @@ +import { query, type ModelInfo, type Options } from '@anthropic-ai/claude-agent-sdk'; + +import { resolveClaudeCodeExecutablePath } from '@/shared/claude-cli-path.js'; +import type { IProviderModels } from '@/shared/interfaces.js'; +import type { ProviderModelOption, ProviderModelsDefinition } from '@/shared/types.js'; + +export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = { + OPTIONS: [ + { value: 'default', label: 'Default (recommended)' }, + { value: 'sonnet[1m]', label: 'Sonnet (1M context)' }, + { value: 'opus', label: 'Opus' }, + { value: 'opus[1m]', label: 'Opus (1M context)' }, + { value: 'haiku', label: 'Haiku' }, + { value: 'sonnet', label: 'sonnet' }, + ], + DEFAULT: 'default', +}; + +type ClaudeModelQueryOptions = Pick; + +const buildClaudeQueryOptions = (): ClaudeModelQueryOptions => ({ + env: { ...process.env }, + pathToClaudeCodeExecutable: resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH), + permissionMode: 'default', +}); + +const mapClaudeModel = (model: ModelInfo): ProviderModelOption => ({ + value: model.value, + label: model.displayName || model.value, + description: model.description || undefined, +}); + +const buildClaudeModelsDefinition = (models: ModelInfo[]): ProviderModelsDefinition => { + const options: ProviderModelOption[] = []; + const seenValues = new Set(); + + for (const model of models) { + const mappedModel = mapClaudeModel(model); + if (seenValues.has(mappedModel.value)) { + continue; + } + + seenValues.add(mappedModel.value); + options.push(mappedModel); + } + + if (options.length === 0) { + return CLAUDE_FALLBACK_MODELS; + } + + const defaultValue = options.find((option) => option.value === 'default')?.value + ?? options[0]?.value + ?? CLAUDE_FALLBACK_MODELS.DEFAULT; + + return { + OPTIONS: options, + DEFAULT: defaultValue, + }; +}; + +export class ClaudeProviderModels implements IProviderModels { + async getSupportedModels(): Promise { + let queryInstance: ReturnType | null = null; + + try { + // The SDK exposes its runtime model catalog on the initialized query + // instance, so we create a lightweight query and immediately close it + // after reading the control-plane metadata. + queryInstance = query({ + prompt: '', + options: buildClaudeQueryOptions(), + }); + + const supportedModels = await queryInstance.supportedModels(); + + return buildClaudeModelsDefinition(supportedModels); + } catch { + return CLAUDE_FALLBACK_MODELS; + } finally { + queryInstance?.close(); + } + } +} diff --git a/server/modules/providers/list/claude/claude.provider.ts b/server/modules/providers/list/claude/claude.provider.ts index efd3bd4a..33a75210 100644 --- a/server/modules/providers/list/claude/claude.provider.ts +++ b/server/modules/providers/list/claude/claude.provider.ts @@ -1,17 +1,20 @@ import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; import { ClaudeProviderAuth } from '@/modules/providers/list/claude/claude-auth.provider.js'; +import { ClaudeProviderModels } from '@/modules/providers/list/claude/claude-models.provider.js'; import { ClaudeMcpProvider } from '@/modules/providers/list/claude/claude-mcp.provider.js'; import { ClaudeSessionSynchronizer } from '@/modules/providers/list/claude/claude-session-synchronizer.provider.js'; import { ClaudeSessionsProvider } from '@/modules/providers/list/claude/claude-sessions.provider.js'; import { ClaudeSkillsProvider } from '@/modules/providers/list/claude/claude-skills.provider.js'; import type { IProviderAuth, + IProviderModels, IProviderSessionSynchronizer, IProviderSkills, IProviderSessions, } from '@/shared/interfaces.js'; export class ClaudeProvider extends AbstractProvider { + readonly models: IProviderModels = new ClaudeProviderModels(); readonly mcp = new ClaudeMcpProvider(); readonly auth: IProviderAuth = new ClaudeProviderAuth(); readonly skills: IProviderSkills = new ClaudeSkillsProvider(); diff --git a/server/modules/providers/list/codex/codex-models.provider.ts b/server/modules/providers/list/codex/codex-models.provider.ts new file mode 100644 index 00000000..e4dcdf09 --- /dev/null +++ b/server/modules/providers/list/codex/codex-models.provider.ts @@ -0,0 +1,88 @@ +import { readFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import type { IProviderModels } from '@/shared/interfaces.js'; +import type { ProviderModelOption, ProviderModelsDefinition } from '@/shared/types.js'; +import { readObjectRecord, readOptionalString } from '@/shared/utils.js'; + +export const CODEX_FALLBACK_MODELS: ProviderModelsDefinition = { + OPTIONS: [ + { value: 'gpt-5.5', label: 'gpt-5.5' }, + { value: 'gpt-5.4', label: 'gpt-5.4' }, + { value: 'gpt-5.4-mini', label: 'gpt-5.4-mini' }, + { value: 'gpt-5.3-codex', label: 'gpt-5.3-codex' }, + { value: 'gpt-5.2', label: 'gpt-5.2' }, + ], + DEFAULT: 'gpt-5.4', +}; + +type CodexCachedModel = { + slug?: string; + display_name?: string; + description?: string; + priority?: number; + visibility?: string; + supported_in_api?: boolean; +}; + +const CODEX_MODELS_CACHE_PATH = path.join(os.homedir(), '.codex', 'models_cache.json'); + +const isCodexCachedModel = (value: unknown): value is CodexCachedModel => { + const record = readObjectRecord(value); + return Boolean(record && readOptionalString(record.slug)); +}; + +const readCodexPriority = (value: unknown): number => ( + typeof value === 'number' && Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER +); + +const mapCodexModel = (model: CodexCachedModel): ProviderModelOption => ({ + value: model.slug as string, + label: readOptionalString(model.display_name) ?? (model.slug as string), + description: readOptionalString(model.description), +}); + +const buildCodexModelsDefinition = (models: CodexCachedModel[]): ProviderModelsDefinition => { + const sortedModels = [...models] + .filter((model) => model.visibility !== 'hidden' && model.supported_in_api !== false) + .sort((left, right) => readCodexPriority(left.priority) - readCodexPriority(right.priority)); + + const options: ProviderModelOption[] = []; + const seenValues = new Set(); + + for (const model of sortedModels) { + const mappedModel = mapCodexModel(model); + if (seenValues.has(mappedModel.value)) { + continue; + } + + seenValues.add(mappedModel.value); + options.push(mappedModel); + } + + if (options.length === 0) { + return CODEX_FALLBACK_MODELS; + } + + return { + OPTIONS: options, + DEFAULT: options[0]?.value ?? CODEX_FALLBACK_MODELS.DEFAULT, + }; +}; + +export class CodexProviderModels implements IProviderModels { + async getSupportedModels(): Promise { + try { + const raw = await readFile(CODEX_MODELS_CACHE_PATH, 'utf8'); + const parsed = readObjectRecord(JSON.parse(raw)); + const models = Array.isArray(parsed?.models) + ? parsed.models.filter(isCodexCachedModel) + : []; + + return buildCodexModelsDefinition(models); + } catch { + return CODEX_FALLBACK_MODELS; + } + } +} diff --git a/server/modules/providers/list/codex/codex.provider.ts b/server/modules/providers/list/codex/codex.provider.ts index 811ff6de..25fc6bad 100644 --- a/server/modules/providers/list/codex/codex.provider.ts +++ b/server/modules/providers/list/codex/codex.provider.ts @@ -1,17 +1,20 @@ import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; import { CodexProviderAuth } from '@/modules/providers/list/codex/codex-auth.provider.js'; +import { CodexProviderModels } from '@/modules/providers/list/codex/codex-models.provider.js'; import { CodexMcpProvider } from '@/modules/providers/list/codex/codex-mcp.provider.js'; import { CodexSessionSynchronizer } from '@/modules/providers/list/codex/codex-session-synchronizer.provider.js'; import { CodexSessionsProvider } from '@/modules/providers/list/codex/codex-sessions.provider.js'; import { CodexSkillsProvider } from '@/modules/providers/list/codex/codex-skills.provider.js'; import type { IProviderAuth, + IProviderModels, IProviderSessionSynchronizer, IProviderSkills, IProviderSessions, } from '@/shared/interfaces.js'; export class CodexProvider extends AbstractProvider { + readonly models: IProviderModels = new CodexProviderModels(); readonly mcp = new CodexMcpProvider(); readonly auth: IProviderAuth = new CodexProviderAuth(); readonly skills: IProviderSkills = new CodexSkillsProvider(); diff --git a/server/modules/providers/list/cursor/cursor-models.provider.ts b/server/modules/providers/list/cursor/cursor-models.provider.ts new file mode 100644 index 00000000..a5ed4f47 --- /dev/null +++ b/server/modules/providers/list/cursor/cursor-models.provider.ts @@ -0,0 +1,180 @@ +import { spawn } from 'node:child_process'; + +import crossSpawn from 'cross-spawn'; + +import type { IProviderModels } from '@/shared/interfaces.js'; +import type { ProviderModelOption, ProviderModelsDefinition } from '@/shared/types.js'; + +export const CURSOR_FALLBACK_MODELS: ProviderModelsDefinition = { + OPTIONS: [ + { value: 'auto', label: 'Auto' }, + { value: 'composer-2-fast', label: 'Composer 2 Fast' }, + { value: 'composer-2', label: 'Composer 2' }, + { value: 'gpt-5.3-codex', label: 'GPT-5.3' }, + { value: 'gemini-3-pro', label: 'Gemini 3 Pro' }, + ], + DEFAULT: 'composer-2-fast', +}; + +type CursorModelRow = { + name: string; + description: string; + current: boolean; + default: boolean; +}; + +const CURSOR_MODELS_TIMEOUT_MS = 10_000; +const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; +const ANSI_PATTERN = new RegExp( + // eslint-disable-next-line no-control-regex + '[\\u001B\\u009B][[\\]()#;?]*(?:' + + '(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]' + + '|(?:[\\dA-PR-TZcf-ntqry=><~]))', + 'g', +); + +const stripAnsi = (value: string): string => value.replace(ANSI_PATTERN, ''); + +const parseModelLine = (line: string): CursorModelRow | null => { + const trimmed = line.trim(); + + if ( + !trimmed + || trimmed === 'Available models' + || trimmed.startsWith('Loading models') + || trimmed.startsWith('Tip:') + ) { + return null; + } + + const match = trimmed.match(/^(.+?)\s+-\s+(.+)$/); + if (!match) { + return null; + } + + const name = match[1].trim(); + let description = match[2].trim(); + const current = /\(current\)/i.test(description); + const defaultModel = /\(default\)/i.test(description); + + description = description.replace(/\s*\((current|default)\)/gi, '').replace(/\s{2,}/g, ' ').trim(); + + return { + name, + description, + current, + default: defaultModel, + }; +}; + +const parseModelsOutput = (text: string): CursorModelRow[] => { + const models: CursorModelRow[] = []; + + for (const line of stripAnsi(text).split(/\r?\n/)) { + const parsed = parseModelLine(line); + if (parsed) { + models.push(parsed); + } + } + + return models; +}; + +const runCursorListModels = (): Promise => new Promise((resolve, reject) => { + const cursorProcess = spawnFunction('cursor-agent', ['--list-models'], { + env: { ...process.env }, + }); + + let stdout = ''; + let stderr = ''; + let settled = false; + + const timer = setTimeout(() => { + cursorProcess.kill('SIGTERM'); + if (!settled) { + settled = true; + reject(new Error('cursor-agent --list-models timed out')); + } + }, CURSOR_MODELS_TIMEOUT_MS); + + const finish = (error: Error | null, output: string) => { + if (settled) { + return; + } + + settled = true; + clearTimeout(timer); + + if (error) { + reject(error); + return; + } + + resolve(output); + }; + + cursorProcess.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + + cursorProcess.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + cursorProcess.on('error', (error) => { + finish(error instanceof Error ? error : new Error(String(error)), ''); + }); + + cursorProcess.on('close', (code) => { + if (code !== 0) { + finish(new Error(stderr.trim() || `cursor-agent --list-models exited with code ${code}`), ''); + return; + } + + finish(null, stdout); + }); +}); + +const buildCursorModelsDefinition = (models: CursorModelRow[]): ProviderModelsDefinition => { + const options: ProviderModelOption[] = []; + const seenValues = new Set(); + + for (const model of models) { + if (seenValues.has(model.name)) { + continue; + } + + seenValues.add(model.name); + options.push({ + value: model.name, + label: model.name, + description: model.description || undefined, + }); + } + + if (options.length === 0) { + return CURSOR_FALLBACK_MODELS; + } + + const defaultValue = models.find((model) => model.default)?.name + ?? models.find((model) => model.current)?.name + ?? options[0]?.value + ?? CURSOR_FALLBACK_MODELS.DEFAULT; + + return { + OPTIONS: options, + DEFAULT: defaultValue, + }; +}; + +export class CursorProviderModels implements IProviderModels { + async getSupportedModels(): Promise { + try { + const stdout = await runCursorListModels(); + const models = parseModelsOutput(stdout); + return buildCursorModelsDefinition(models); + } catch { + return CURSOR_FALLBACK_MODELS; + } + } +} diff --git a/server/modules/providers/list/cursor/cursor.provider.ts b/server/modules/providers/list/cursor/cursor.provider.ts index 7fc4abf5..ad125815 100644 --- a/server/modules/providers/list/cursor/cursor.provider.ts +++ b/server/modules/providers/list/cursor/cursor.provider.ts @@ -1,17 +1,20 @@ import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; import { CursorProviderAuth } from '@/modules/providers/list/cursor/cursor-auth.provider.js'; +import { CursorProviderModels } from '@/modules/providers/list/cursor/cursor-models.provider.js'; import { CursorMcpProvider } from '@/modules/providers/list/cursor/cursor-mcp.provider.js'; import { CursorSessionSynchronizer } from '@/modules/providers/list/cursor/cursor-session-synchronizer.provider.js'; import { CursorSessionsProvider } from '@/modules/providers/list/cursor/cursor-sessions.provider.js'; import { CursorSkillsProvider } from '@/modules/providers/list/cursor/cursor-skills.provider.js'; import type { IProviderAuth, + IProviderModels, IProviderSessionSynchronizer, IProviderSkills, IProviderSessions, } from '@/shared/interfaces.js'; export class CursorProvider extends AbstractProvider { + readonly models: IProviderModels = new CursorProviderModels(); readonly mcp = new CursorMcpProvider(); readonly auth: IProviderAuth = new CursorProviderAuth(); readonly skills: IProviderSkills = new CursorSkillsProvider(); diff --git a/server/modules/providers/list/gemini/gemini-models.provider.ts b/server/modules/providers/list/gemini/gemini-models.provider.ts new file mode 100644 index 00000000..4e2eb480 --- /dev/null +++ b/server/modules/providers/list/gemini/gemini-models.provider.ts @@ -0,0 +1,23 @@ +import type { IProviderModels } from '@/shared/interfaces.js'; +import type { ProviderModelsDefinition } from '@/shared/types.js'; + +export const GEMINI_FALLBACK_MODELS: ProviderModelsDefinition = { + OPTIONS: [ + { value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' }, + { value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' }, + { value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' }, + { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, + { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }, + { value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' }, + { value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' }, + { value: 'gemini-2.0-pro-exp', label: 'Gemini 2.0 Pro Experimental' }, + { value: 'gemini-2.0-flash-thinking-exp', label: 'Gemini 2.0 Flash Thinking' }, + ], + DEFAULT: 'gemini-3.1-pro-preview', +}; + +export class GeminiProviderModels implements IProviderModels { + async getSupportedModels(): Promise { + return GEMINI_FALLBACK_MODELS; + } +} diff --git a/server/modules/providers/list/gemini/gemini.provider.ts b/server/modules/providers/list/gemini/gemini.provider.ts index 626cacf6..38d25245 100644 --- a/server/modules/providers/list/gemini/gemini.provider.ts +++ b/server/modules/providers/list/gemini/gemini.provider.ts @@ -1,17 +1,20 @@ import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; import { GeminiProviderAuth } from '@/modules/providers/list/gemini/gemini-auth.provider.js'; +import { GeminiProviderModels } from '@/modules/providers/list/gemini/gemini-models.provider.js'; import { GeminiMcpProvider } from '@/modules/providers/list/gemini/gemini-mcp.provider.js'; import { GeminiSessionSynchronizer } from '@/modules/providers/list/gemini/gemini-session-synchronizer.provider.js'; import { GeminiSessionsProvider } from '@/modules/providers/list/gemini/gemini-sessions.provider.js'; import { GeminiSkillsProvider } from '@/modules/providers/list/gemini/gemini-skills.provider.js'; import type { IProviderAuth, + IProviderModels, IProviderSessionSynchronizer, IProviderSkills, IProviderSessions, } from '@/shared/interfaces.js'; export class GeminiProvider extends AbstractProvider { + readonly models: IProviderModels = new GeminiProviderModels(); readonly mcp = new GeminiMcpProvider(); readonly auth: IProviderAuth = new GeminiProviderAuth(); readonly skills: IProviderSkills = new GeminiSkillsProvider(); diff --git a/server/modules/providers/list/opencode/opencode-models.provider.ts b/server/modules/providers/list/opencode/opencode-models.provider.ts new file mode 100644 index 00000000..ef5b7c7b --- /dev/null +++ b/server/modules/providers/list/opencode/opencode-models.provider.ts @@ -0,0 +1,139 @@ +import { spawn } from 'node:child_process'; + +import crossSpawn from 'cross-spawn'; + +import type { IProviderModels } from '@/shared/interfaces.js'; +import type { ProviderModelOption, ProviderModelsDefinition } from '@/shared/types.js'; + +export const OPENCODE_FALLBACK_MODELS: ProviderModelsDefinition = { + OPTIONS: [ + { value: 'anthropic/claude-sonnet-4-5', label: 'Claude Sonnet 4.5' }, + { value: 'anthropic/claude-opus-4-1', label: 'Claude Opus 4.1' }, + { value: 'anthropic/claude-haiku-4-5', label: 'Claude Haiku 4.5' }, + { value: 'openai/gpt-5.1', label: 'GPT-5.1' }, + { value: 'openai/gpt-5.1-codex', label: 'GPT-5.1 Codex' }, + { value: 'openai/gpt-5.4-mini', label: 'GPT-5.4 Mini' }, + { value: 'google/gemini-2.5-pro', label: 'Gemini 2.5 Pro' }, + { value: 'google/gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, + ], + DEFAULT: 'anthropic/claude-sonnet-4-5', +}; + +const OPEN_CODE_MODELS_TIMEOUT_MS = 20_000; +const MODEL_ID_LINE = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i; +const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; + +const parseOpenCodeModelsStdout = (stdout: string): string[] => { + const ids: string[] = []; + + for (const rawLine of stdout.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('{') || line.startsWith('[')) { + continue; + } + + if (MODEL_ID_LINE.test(line)) { + ids.push(line); + } + } + + return [...new Set(ids)]; +}; + +const labelForOpenCodeModelId = (id: string): string => { + const fallbackLabel = OPENCODE_FALLBACK_MODELS.OPTIONS.find((option) => option.value === id)?.label; + if (fallbackLabel) { + return fallbackLabel; + } + + const tail = id.includes('/') ? id.slice(id.indexOf('/') + 1) : id; + return tail.replace(/-/g, ' '); +}; + +const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDefinition => { + const options: ProviderModelOption[] = ids.map((value) => ({ + value, + label: labelForOpenCodeModelId(value), + })); + + const defaultValue = options.find((option) => option.value === OPENCODE_FALLBACK_MODELS.DEFAULT)?.value + ?? options[0]?.value + ?? OPENCODE_FALLBACK_MODELS.DEFAULT; + + return { + OPTIONS: options, + DEFAULT: defaultValue, + }; +}; + +const runOpenCodeModelsCommand = (): Promise => new Promise((resolve, reject) => { + const openCodeProcess = spawnFunction('opencode', ['models'], { + cwd: process.cwd(), + env: { ...process.env }, + }); + + let stdout = ''; + let stderr = ''; + let settled = false; + + const timer = setTimeout(() => { + openCodeProcess.kill('SIGTERM'); + if (!settled) { + settled = true; + reject(new Error('opencode models timed out')); + } + }, OPEN_CODE_MODELS_TIMEOUT_MS); + + const finish = (error: Error | null, output: string) => { + if (settled) { + return; + } + + settled = true; + clearTimeout(timer); + + if (error) { + reject(error); + return; + } + + resolve(output); + }; + + openCodeProcess.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + + openCodeProcess.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + openCodeProcess.on('error', (error) => { + finish(error instanceof Error ? error : new Error(String(error)), ''); + }); + + openCodeProcess.on('close', (code) => { + if (code !== 0) { + finish(new Error(stderr.trim() || `opencode models exited with code ${code}`), ''); + return; + } + + finish(null, stdout); + }); +}); + +export class OpenCodeProviderModels implements IProviderModels { + async getSupportedModels(): Promise { + try { + const stdout = await runOpenCodeModelsCommand(); + const ids = parseOpenCodeModelsStdout(stdout); + if (ids.length === 0) { + return OPENCODE_FALLBACK_MODELS; + } + + return buildOpenCodeDefinitionFromIds(ids); + } catch { + return OPENCODE_FALLBACK_MODELS; + } + } +} diff --git a/server/modules/providers/list/opencode/opencode.provider.ts b/server/modules/providers/list/opencode/opencode.provider.ts index fc5fe0ce..38f0efd1 100644 --- a/server/modules/providers/list/opencode/opencode.provider.ts +++ b/server/modules/providers/list/opencode/opencode.provider.ts @@ -1,4 +1,5 @@ import { OpenCodeProviderAuth } from '@/modules/providers/list/opencode/opencode-auth.provider.js'; +import { OpenCodeProviderModels } from '@/modules/providers/list/opencode/opencode-models.provider.js'; import { OpenCodeMcpProvider } from '@/modules/providers/list/opencode/opencode-mcp.provider.js'; import { OpenCodeSessionSynchronizer } from '@/modules/providers/list/opencode/opencode-session-synchronizer.provider.js'; import { OpenCodeSessionsProvider } from '@/modules/providers/list/opencode/opencode-sessions.provider.js'; @@ -6,12 +7,14 @@ import { OpenCodeSkillsProvider } from '@/modules/providers/list/opencode/openco import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; import type { IProviderAuth, + IProviderModels, IProviderSessionSynchronizer, IProviderSkills, IProviderSessions, } from '@/shared/interfaces.js'; export class OpenCodeProvider extends AbstractProvider { + readonly models: IProviderModels = new OpenCodeProviderModels(); readonly mcp = new OpenCodeMcpProvider(); readonly auth: IProviderAuth = new OpenCodeProviderAuth(); readonly skills: IProviderSkills = new OpenCodeSkillsProvider(); diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index 16cb5ecb..1d128882 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -259,10 +259,9 @@ router.get( '/:provider/models', asyncHandler(async (req: Request, res: Response) => { const provider = parseProvider(req.params.provider); - const workspacePath = readOptionalQueryString(req.query.workspacePath); - const cwd = workspacePath; - const models = await providerModelsService.getProviderModels(provider, { cwd }); - res.json(createApiSuccessResponse({ provider, models })); + const bypassCache = parseOptionalBooleanQuery(req.query.bypassCache, 'bypassCache') ?? false; + const result = await providerModelsService.getProviderModels(provider, { bypassCache }); + res.json(createApiSuccessResponse({ provider, models: result.models, cache: result.cache })); }), ); diff --git a/server/modules/providers/services/provider-models.service.ts b/server/modules/providers/services/provider-models.service.ts index abceb4ab..031ae30c 100644 --- a/server/modules/providers/services/provider-models.service.ts +++ b/server/modules/providers/services/provider-models.service.ts @@ -1,121 +1,31 @@ -import { spawn } from 'node:child_process'; -import fsSync from 'node:fs'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import crossSpawn from 'cross-spawn'; +import { providerRegistry } from '@/modules/providers/provider.registry.js'; +import type { IProvider } from '@/shared/interfaces.js'; +import type { + LLMProvider, + ProviderModelsCacheInfo, + ProviderModelsDefinition, + ProviderModelsResult, +} from '@/shared/types.js'; -import type { LLMProvider, ProviderModelOption, ProviderModelsDefinition } from '@/shared/types.js'; - -const OPEN_CODE_MODELS_TIMEOUT_MS = 20_000; -export const PROVIDER_MODELS_CACHE_TTL_MS = 2 * 24 * 60 * 60 * 1000; +export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000; const PROVIDER_MODELS_CACHE_VERSION = 1; -/** - * Claude (Anthropic) — SDK-style ids used by the UI and claude-sdk.js. - */ -export const CLAUDE_MODELS: ProviderModelsDefinition = { - OPTIONS: [ - { value: 'opus', label: 'Opus' }, - { value: 'sonnet', label: 'Sonnet' }, - { value: 'haiku', label: 'Haiku' }, - { value: 'claude-opus-4-6', label: 'Opus 4.6' }, - { value: 'opusplan', label: 'Opus Plan' }, - { value: 'sonnet[1m]', label: 'Sonnet [1M]' }, - { value: 'opus[1m]', label: 'Opus [1M]' }, - ], - DEFAULT: 'opus', -}; - -export const CURSOR_MODELS: ProviderModelsDefinition = { - OPTIONS: [ - { value: 'opus-4.6-thinking', label: 'Claude 4.6 Opus (Thinking)' }, - { value: 'gpt-5.3-codex', label: 'GPT-5.3' }, - { value: 'gpt-5.2-high', label: 'GPT-5.2 High' }, - { value: 'gemini-3-pro', label: 'Gemini 3 Pro' }, - { value: 'opus-4.5-thinking', label: 'Claude 4.5 Opus (Thinking)' }, - { value: 'gpt-5.2', label: 'GPT-5.2' }, - { value: 'gpt-5.1', label: 'GPT-5.1' }, - { value: 'gpt-5.1-high', label: 'GPT-5.1 High' }, - { value: 'composer-1', label: 'Composer 1' }, - { value: 'auto', label: 'Auto' }, - { value: 'sonnet-4.5', label: 'Claude 4.5 Sonnet' }, - { value: 'sonnet-4.5-thinking', label: 'Claude 4.5 Sonnet (Thinking)' }, - { value: 'opus-4.5', label: 'Claude 4.5 Opus' }, - { value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' }, - { value: 'gpt-5.1-codex-high', label: 'GPT-5.1 Codex High' }, - { value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' }, - { value: 'gpt-5.1-codex-max-high', label: 'GPT-5.1 Codex Max High' }, - { value: 'opus-4.1', label: 'Claude 4.1 Opus' }, - { value: 'grok', label: 'Grok' }, - ], - DEFAULT: 'gpt-5.3-codex', -}; - -export const CODEX_MODELS: ProviderModelsDefinition = { - OPTIONS: [ - { value: 'gpt-5.5', label: 'GPT-5.5' }, - { value: 'gpt-5.4', label: 'GPT-5.4' }, - { value: 'gpt-5.4-mini', label: 'GPT-5.4 mini' }, - { value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' }, - { value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' }, - { value: 'gpt-5.2', label: 'GPT-5.2' }, - { value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' }, - { value: 'o3', label: 'O3' }, - { value: 'o4-mini', label: 'O4-mini' }, - ], - DEFAULT: 'gpt-5.4', -}; - -export const GEMINI_MODELS: ProviderModelsDefinition = { - OPTIONS: [ - { value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' }, - { value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' }, - { value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' }, - { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, - { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }, - { value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' }, - { value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' }, - { value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' }, - { value: 'gemini-2.0-pro-exp', label: 'Gemini 2.0 Pro Experimental' }, - { value: 'gemini-2.0-flash-thinking-exp', label: 'Gemini 2.0 Flash Thinking' }, - ], - DEFAULT: 'gemini-3.1-pro-preview', -}; - -/** Static OpenCode defaults when `opencode models` is unavailable or returns nothing. */ -export const OPENCODE_MODELS: ProviderModelsDefinition = { - OPTIONS: [ - { value: 'anthropic/claude-sonnet-4-5', label: 'Claude Sonnet 4.5' }, - { value: 'anthropic/claude-opus-4-1', label: 'Claude Opus 4.1' }, - { value: 'anthropic/claude-haiku-4-5', label: 'Claude Haiku 4.5' }, - { value: 'openai/gpt-5.1', label: 'GPT-5.1' }, - { value: 'openai/gpt-5.1-codex', label: 'GPT-5.1 Codex' }, - { value: 'openai/gpt-5.4-mini', label: 'GPT-5.4 Mini' }, - { value: 'google/gemini-2.5-pro', label: 'Gemini 2.5 Pro' }, - { value: 'google/gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, - ], - DEFAULT: 'anthropic/claude-sonnet-4-5', -}; - -const BUILTIN_BY_PROVIDER: Record, ProviderModelsDefinition> = { - claude: CLAUDE_MODELS, - cursor: CURSOR_MODELS, - codex: CODEX_MODELS, - gemini: GEMINI_MODELS, +type ProviderModelsServiceDependencies = { + resolveProvider?: (provider: LLMProvider) => Pick; + cachePath?: string; + now?: () => number; }; type ProviderModelsOptions = { - cwd?: string; + bypassCache?: boolean; }; -type ProviderModelsLoader = ( - provider: LLMProvider, - options?: ProviderModelsOptions, -) => Promise; - type ProviderModelsCacheEntry = { + updatedAt: number; expiresAt: number; models: ProviderModelsDefinition; }; @@ -125,75 +35,32 @@ type ProviderModelsCacheFile = { entries: Record; }; -type ProviderModelsServiceDependencies = { - cachePath?: string; - loadModels?: ProviderModelsLoader; - now?: () => number; -}; +const getProviderModelsCachePath = (): string => path.join( + os.homedir(), + '.cloudcli', + 'provider-models-cache.json', +); -const MODEL_ID_LINE = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i; +const toProviderModelsCacheInfo = ( + entry: ProviderModelsCacheEntry, + source: ProviderModelsCacheInfo['source'], +): ProviderModelsCacheInfo => ({ + updatedAt: new Date(entry.updatedAt).toISOString(), + expiresAt: new Date(entry.expiresAt).toISOString(), + source, +}); -const parseOpenCodeModelsStdout = (stdout: string): string[] => { - const ids: string[] = []; - for (const rawLine of stdout.split(/\r?\n/)) { - const line = rawLine.trim(); - if (!line || line.startsWith('{') || line.startsWith('[')) { - continue; - } - if (MODEL_ID_LINE.test(line)) { - ids.push(line); - } - } - return [...new Set(ids)]; -}; - -const labelForOpenCodeModelId = (id: string): string => { - const fromStatic = OPENCODE_MODELS.OPTIONS.find((o) => o.value === id)?.label; - if (fromStatic) { - return fromStatic; - } - const tail = id.includes('/') ? id.slice(id.indexOf('/') + 1) : id; - return tail.replace(/-/g, ' '); -}; - -const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDefinition => { - const options: ProviderModelOption[] = ids.map((value) => ({ - value, - label: labelForOpenCodeModelId(value), - })); - const defaultValue = options.some((o) => o.value === OPENCODE_MODELS.DEFAULT) - ? OPENCODE_MODELS.DEFAULT - : (options[0]?.value ?? OPENCODE_MODELS.DEFAULT); - return { OPTIONS: options, DEFAULT: defaultValue }; -}; - -const resolveOpenCodeCwd = (cwd?: string): string => { - if (cwd && fsSync.existsSync(cwd)) { - return cwd; - } - return process.cwd(); -}; - -const getProviderModelsCachePath = (): string => - process.env.CLOUDCLI_PROVIDER_MODELS_CACHE_PATH - || path.join(os.homedir(), '.cloudcli', 'provider-models-cache.json'); - -const getProviderModelsCacheKey = ( - provider: LLMProvider, - options?: ProviderModelsOptions, -): string => { - if (provider === 'opencode') { - return `${provider}:${resolveOpenCodeCwd(options?.cwd)}`; - } - - return provider; -}; - -const isProviderModelOption = (value: unknown): value is ProviderModelOption => ( +const isProviderModelOption = ( + value: unknown, +): value is ProviderModelsDefinition['OPTIONS'][number] => ( Boolean(value) && typeof value === 'object' - && typeof (value as ProviderModelOption).value === 'string' - && typeof (value as ProviderModelOption).label === 'string' + && typeof (value as ProviderModelsDefinition['OPTIONS'][number]).value === 'string' + && typeof (value as ProviderModelsDefinition['OPTIONS'][number]).label === 'string' + && ( + typeof (value as ProviderModelsDefinition['OPTIONS'][number]).description === 'undefined' + || typeof (value as ProviderModelsDefinition['OPTIONS'][number]).description === 'string' + ) ); const isProviderModelsDefinition = (value: unknown): value is ProviderModelsDefinition => ( @@ -207,6 +74,7 @@ const isProviderModelsDefinition = (value: unknown): value is ProviderModelsDefi const isProviderModelsCacheEntry = (value: unknown): value is ProviderModelsCacheEntry => ( Boolean(value) && typeof value === 'object' + && typeof (value as ProviderModelsCacheEntry).updatedAt === 'number' && typeof (value as ProviderModelsCacheEntry).expiresAt === 'number' && isProviderModelsDefinition((value as ProviderModelsCacheEntry).models) ); @@ -226,7 +94,11 @@ const readProviderModelsCacheFile = async ( isProviderModelsCacheEntry(entry[1]), ), ); - return { version: PROVIDER_MODELS_CACHE_VERSION, entries }; + + return { + version: PROVIDER_MODELS_CACHE_VERSION, + entries, + }; } catch { return null; } @@ -234,7 +106,7 @@ const readProviderModelsCacheFile = async ( const writeProviderModelsCacheFile = async ( cachePath: string, - entries: Map, + entries: Map, now: number, ): Promise => { const serializableEntries = Object.fromEntries( @@ -249,93 +121,44 @@ const writeProviderModelsCacheFile = async ( await writeFile(cachePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); }; -const runOpenCodeModelsCommand = (cwd?: string): Promise => - new Promise((resolve, reject) => { - const spawnFn = process.platform === 'win32' ? crossSpawn : spawn; - const child = spawnFn('opencode', ['models'], { - cwd: resolveOpenCodeCwd(cwd), - env: { ...process.env }, - }); - - let stdout = ''; - let stderr = ''; - let settled = false; - - const timer = setTimeout(() => { - child.kill('SIGTERM'); - if (!settled) { - settled = true; - reject(new Error('opencode models timed out')); - } - }, OPEN_CODE_MODELS_TIMEOUT_MS); - - const finish = (err: Error | null, out: string) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - if (err) { - reject(err); - } else { - resolve(out); - } - }; - - child.stdout?.on('data', (chunk: Buffer) => { - stdout += chunk.toString(); - }); - child.stderr?.on('data', (chunk: Buffer) => { - stderr += chunk.toString(); - }); - child.on('error', (error) => { - finish(error instanceof Error ? error : new Error(String(error)), ''); - }); - child.on('close', (code) => { - if (code !== 0) { - finish(new Error(stderr.trim() || `opencode models exited with code ${code}`), ''); - return; - } - finish(null, stdout); - }); - }); - -const getBuiltinProviderDefinition = (provider: LLMProvider): ProviderModelsDefinition => { - if (provider === 'opencode') { - return OPENCODE_MODELS; - } - return BUILTIN_BY_PROVIDER[provider]; -}; - -async function getProviderModelsInternal( - provider: LLMProvider, - options?: { cwd?: string }, -): Promise { - if (provider !== 'opencode') { - return getBuiltinProviderDefinition(provider); - } - - try { - const stdout = await runOpenCodeModelsCommand(options?.cwd); - const ids = parseOpenCodeModelsStdout(stdout); - if (ids.length === 0) { - return OPENCODE_MODELS; - } - return buildOpenCodeDefinitionFromIds(ids); - } catch { - return OPENCODE_MODELS; - } -} - +/** + * Provider model lookup service. + * + * Routes and other service callers use this layer instead of resolving provider + * classes directly so the provider-registry dependency stays centralized in one + * place. + */ export const createProviderModelsService = (dependencies: ProviderModelsServiceDependencies = {}) => { - const memoryCache = new Map(); - const pendingRequests = new Map>(); - const loadModels = dependencies.loadModels ?? getProviderModelsInternal; + const resolveProvider = dependencies.resolveProvider ?? providerRegistry.resolveProvider; + const cachePath = dependencies.cachePath ?? getProviderModelsCachePath(); const now = dependencies.now ?? (() => Date.now()); + const memoryCache = new Map(); + const pendingRequests = new Map>(); let persistedCacheLoaded = false; let persistedCacheLoadPromise: Promise | null = null; - const loadPersistedCache = async (cachePath: string): Promise => { + const pruneExpiredMemoryEntry = ( + provider: LLMProvider, + currentTime: number, + source: ProviderModelsCacheInfo['source'], + ): ProviderModelsResult | null => { + const cachedEntry = memoryCache.get(provider); + if (!cachedEntry) { + return null; + } + + if (cachedEntry.expiresAt > currentTime) { + return { + models: cachedEntry.models, + cache: toProviderModelsCacheInfo(cachedEntry, source), + }; + } + + memoryCache.delete(provider); + return null; + }; + + const loadPersistedCache = async (): Promise => { if (persistedCacheLoaded) { return; } @@ -344,11 +167,13 @@ export const createProviderModelsService = (dependencies: ProviderModelsServiceD persistedCacheLoadPromise = (async () => { const cacheFile = await readProviderModelsCacheFile(cachePath); const currentTime = now(); - for (const [key, entry] of Object.entries(cacheFile?.entries ?? {})) { + + for (const [provider, entry] of Object.entries(cacheFile?.entries ?? {})) { if (entry.expiresAt > currentTime) { - memoryCache.set(key, entry); + memoryCache.set(provider as LLMProvider, entry); } } + persistedCacheLoaded = true; })().finally(() => { persistedCacheLoadPromise = null; @@ -358,7 +183,7 @@ export const createProviderModelsService = (dependencies: ProviderModelsServiceD await persistedCacheLoadPromise; }; - const persistCache = async (cachePath: string): Promise => { + const persistCache = async (): Promise => { try { await writeProviderModelsCacheFile(cachePath, memoryCache, now()); } catch (error) { @@ -367,80 +192,76 @@ export const createProviderModelsService = (dependencies: ProviderModelsServiceD }; const setCacheEntry = async ( - cachePath: string, - cacheKey: string, + provider: LLMProvider, models: ProviderModelsDefinition, - ): Promise => { - const entry = { - expiresAt: now() + PROVIDER_MODELS_CACHE_TTL_MS, + ): Promise => { + const currentTime = now(); + const entry: ProviderModelsCacheEntry = { + updatedAt: currentTime, + expiresAt: currentTime + PROVIDER_MODELS_CACHE_TTL_MS, models, }; - memoryCache.set(cacheKey, entry); - await persistCache(cachePath); + memoryCache.set(provider, entry); + await persistCache(); + return entry; }; const loadAndCacheModels = ( provider: LLMProvider, - options: ProviderModelsOptions | undefined, - cachePath: string, - cacheKey: string, - ): Promise => { - const request = loadModels(provider, options) + ): Promise => { + const request = resolveProvider(provider).models.getSupportedModels() .then(async (models) => { - await setCacheEntry(cachePath, cacheKey, models); - return models; + const entry = await setCacheEntry(provider, models); + return { + models, + cache: toProviderModelsCacheInfo(entry, 'fresh'), + }; }) .finally(() => { - pendingRequests.delete(cacheKey); + pendingRequests.delete(provider); }); - pendingRequests.set(cacheKey, request); + pendingRequests.set(provider, request); return request; }; - const pruneExpiredMemoryEntry = (cacheKey: string, currentTime: number): ProviderModelsDefinition | null => { - const cachedEntry = memoryCache.get(cacheKey); - if (!cachedEntry) { - return null; - } - - if (cachedEntry.expiresAt > currentTime) { - return cachedEntry.models; - } - - memoryCache.delete(cacheKey); - return null; - }; - const getProviderModels = async ( provider: LLMProvider, - options?: ProviderModelsOptions, - ): Promise => { - const cachePath = dependencies.cachePath ?? getProviderModelsCachePath(); - const cacheKey = getProviderModelsCacheKey(provider, options); - const cachedModels = pruneExpiredMemoryEntry(cacheKey, now()); + options: ProviderModelsOptions = {}, + ): Promise => { + if (options.bypassCache) { + const pendingRequest = pendingRequests.get(provider); + if (pendingRequest) { + return pendingRequest; + } + + return loadAndCacheModels(provider); + } + + const cachedModels = pruneExpiredMemoryEntry(provider, now(), 'memory'); if (cachedModels) { return cachedModels; } - const pendingRequest = pendingRequests.get(cacheKey); + const pendingRequest = pendingRequests.get(provider); if (pendingRequest) { return pendingRequest; } - await loadPersistedCache(cachePath); - const persistedModels = pruneExpiredMemoryEntry(cacheKey, now()); + await loadPersistedCache(); + + const persistedModels = pruneExpiredMemoryEntry(provider, now(), 'disk'); if (persistedModels) { return persistedModels; } - const postLoadPendingRequest = pendingRequests.get(cacheKey); + const postLoadPendingRequest = pendingRequests.get(provider); if (postLoadPendingRequest) { return postLoadPendingRequest; } - return loadAndCacheModels(provider, options, cachePath, cacheKey); + return loadAndCacheModels(provider); }; const clearCache = (): void => { diff --git a/server/modules/providers/shared/base/abstract.provider.ts b/server/modules/providers/shared/base/abstract.provider.ts index 03701d3e..56054e14 100644 --- a/server/modules/providers/shared/base/abstract.provider.ts +++ b/server/modules/providers/shared/base/abstract.provider.ts @@ -2,6 +2,7 @@ import type { IProvider, IProviderAuth, IProviderMcp, + IProviderModels, IProviderSessionSynchronizer, IProviderSkills, IProviderSessions, @@ -17,6 +18,7 @@ import type { LLMProvider } from '@/shared/types.js'; */ export abstract class AbstractProvider implements IProvider { readonly id: LLMProvider; + abstract readonly models: IProviderModels; abstract readonly mcp: IProviderMcp; abstract readonly auth: IProviderAuth; abstract readonly skills: IProviderSkills; diff --git a/server/modules/providers/tests/provider-models.service.test.ts b/server/modules/providers/tests/provider-models.service.test.ts index 003d91be..2ca107bd 100644 --- a/server/modules/providers/tests/provider-models.service.test.ts +++ b/server/modules/providers/tests/provider-models.service.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -15,7 +15,49 @@ const createModels = (value: string): ProviderModelsDefinition => ({ DEFAULT: value, }); -test('provider models are cached for the two-day ttl', async () => { +test('provider models service delegates to the resolved provider model adapter', async () => { + const calls: LLMProvider[] = []; + const service = createProviderModelsService({ + resolveProvider: (provider) => { + calls.push(provider); + return { + models: { + getSupportedModels: async () => createModels(`${provider}-models`), + }, + }; + }, + }); + + const models = await service.getProviderModels('codex'); + + assert.deepEqual(calls, ['codex']); + assert.equal(models.models.DEFAULT, 'codex-models'); + assert.equal(models.cache.source, 'fresh'); +}); + +test('provider models service returns each provider adapter result without rewriting it', async () => { + const expectedModels: ProviderModelsDefinition = { + OPTIONS: [ + { value: 'cursor-a', label: 'Cursor A' }, + { value: 'cursor-b', label: 'Cursor B' }, + ], + DEFAULT: 'cursor-b', + }; + + const service = createProviderModelsService({ + resolveProvider: () => ({ + models: { + getSupportedModels: async () => expectedModels, + }, + }), + }); + + const models = await service.getProviderModels('cursor'); + + assert.deepEqual(models.models, expectedModels); +}); + +test('provider models are cached for the three-day ttl', async () => { const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-ttl-')); let currentTime = 1_000; let loadCount = 0; @@ -24,16 +66,21 @@ test('provider models are cached for the two-day ttl', async () => { const service = createProviderModelsService({ cachePath: path.join(tempRoot, 'models-cache.json'), now: () => currentTime, - loadModels: async (provider: LLMProvider) => { - loadCount += 1; - return createModels(`${provider}-${loadCount}`); - }, + resolveProvider: (provider) => ({ + models: { + getSupportedModels: async () => { + loadCount += 1; + return createModels(`${provider}-${loadCount}`); + }, + }, + }), }); const first = await service.getProviderModels('codex'); const cached = await service.getProviderModels('codex'); assert.equal(loadCount, 1); - assert.equal(cached.DEFAULT, first.DEFAULT); + assert.equal(cached.models.DEFAULT, first.models.DEFAULT); + assert.equal(cached.cache.source, 'memory'); currentTime += PROVIDER_MODELS_CACHE_TTL_MS - 1; await service.getProviderModels('codex'); @@ -42,7 +89,7 @@ test('provider models are cached for the two-day ttl', async () => { currentTime += 2; const refreshed = await service.getProviderModels('codex'); assert.equal(loadCount, 2); - assert.equal(refreshed.DEFAULT, 'codex-2'); + assert.equal(refreshed.models.DEFAULT, 'codex-2'); } finally { await rm(tempRoot, { recursive: true, force: true }); } @@ -55,18 +102,27 @@ test('provider model cache is persisted across service instances', async () => { try { const writer = createProviderModelsService({ cachePath, - loadModels: async () => createModels('gemini-cached'), + resolveProvider: () => ({ + models: { + getSupportedModels: async () => createModels('gemini-cached'), + }, + }), }); await writer.getProviderModels('gemini'); const reader = createProviderModelsService({ cachePath, - loadModels: async () => { - throw new Error('loader should not be called for persisted cache hits'); - }, + resolveProvider: () => ({ + models: { + getSupportedModels: async () => { + throw new Error('loader should not be called for persisted cache hits'); + }, + }, + }), }); const models = await reader.getProviderModels('gemini'); - assert.equal(models.DEFAULT, 'gemini-cached'); + assert.equal(models.models.DEFAULT, 'gemini-cached'); + assert.equal(models.cache.source, 'disk'); } finally { await rm(tempRoot, { recursive: true, force: true }); } @@ -79,11 +135,15 @@ test('concurrent provider model requests share one load operation', async () => try { const service = createProviderModelsService({ cachePath: path.join(tempRoot, 'models-cache.json'), - loadModels: async () => { - loadCount += 1; - await new Promise((resolve) => setTimeout(resolve, 20)); - return createModels('claude-cached'); - }, + resolveProvider: () => ({ + models: { + getSupportedModels: async () => { + loadCount += 1; + await new Promise((resolve) => setTimeout(resolve, 20)); + return createModels('claude-cached'); + }, + }, + }), }); const [first, second] = await Promise.all([ @@ -92,35 +152,40 @@ test('concurrent provider model requests share one load operation', async () => ]); assert.equal(loadCount, 1); - assert.equal(first.DEFAULT, 'claude-cached'); - assert.equal(second.DEFAULT, 'claude-cached'); + assert.equal(first.models.DEFAULT, 'claude-cached'); + assert.equal(second.models.DEFAULT, 'claude-cached'); } finally { await rm(tempRoot, { recursive: true, force: true }); } }); -test('opencode model cache is scoped by workspace cwd', async () => { - const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-opencode-')); - const workspaceA = path.join(tempRoot, 'workspace-a'); - const workspaceB = path.join(tempRoot, 'workspace-b'); +test('bypassCache forces a fresh provider fetch and updates cache metadata', async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-refresh-')); + let currentTime = 1_000; let loadCount = 0; try { - await mkdir(workspaceA, { recursive: true }); - await mkdir(workspaceB, { recursive: true }); - const service = createProviderModelsService({ cachePath: path.join(tempRoot, 'models-cache.json'), - loadModels: async () => { - loadCount += 1; - return createModels(`opencode-${loadCount}`); - }, + now: () => currentTime, + resolveProvider: (provider) => ({ + models: { + getSupportedModels: async () => { + loadCount += 1; + return createModels(`${provider}-${loadCount}`); + }, + }, + }), }); - await service.getProviderModels('opencode', { cwd: workspaceA }); - await service.getProviderModels('opencode', { cwd: workspaceA }); - await service.getProviderModels('opencode', { cwd: workspaceB }); + const first = await service.getProviderModels('claude'); + currentTime += 50; + const refreshed = await service.getProviderModels('claude', { bypassCache: true }); + assert.equal(first.models.DEFAULT, 'claude-1'); + assert.equal(refreshed.models.DEFAULT, 'claude-2'); + assert.equal(refreshed.cache.source, 'fresh'); + assert.notEqual(refreshed.cache.updatedAt, first.cache.updatedAt); assert.equal(loadCount, 2); } finally { await rm(tempRoot, { recursive: true, force: true }); diff --git a/server/routes/agent.js b/server/routes/agent.js index 4edd3ceb..a5607588 100644 --- a/server/routes/agent.js +++ b/server/routes/agent.js @@ -939,9 +939,9 @@ router.post('/', validateExternalApiKey, async (req, res) => { }); } - const codexModels = await providerModelsService.getProviderModels('codex'); - const geminiModels = await providerModelsService.getProviderModels('gemini'); - const opencodeModels = await providerModelsService.getProviderModels('opencode', { cwd: finalProjectPath }); + const codexModels = (await providerModelsService.getProviderModels('codex')).models; + const geminiModels = (await providerModelsService.getProviderModels('gemini')).models; + const opencodeModels = (await providerModelsService.getProviderModels('opencode')).models; // Start the appropriate session if (provider === 'claude') { diff --git a/server/routes/commands.js b/server/routes/commands.js index c9e18198..72df1a86 100644 --- a/server/routes/commands.js +++ b/server/routes/commands.js @@ -34,26 +34,15 @@ const readModelProvider = (value) => { return MODEL_PROVIDERS.includes(normalized) ? normalized : "claude"; }; -const getProviderModelOptions = (provider, context) => { - if (provider !== "opencode") { - return undefined; - } - - const cwd = - typeof context?.projectPath === "string" ? context.projectPath : undefined; - return { cwd }; -}; - export const executeModelsCommand = async (args, context) => { const currentProvider = readModelProvider(context?.provider); - const catalog = await providerModelsService.getProviderModels( - currentProvider, - getProviderModelOptions(currentProvider, context), - ); + const result = await providerModelsService.getProviderModels(currentProvider); + const catalog = result.models; const availableModels = catalog.OPTIONS.map((option) => option.value); const availableOptions = catalog.OPTIONS.map((option) => ({ value: option.value, label: option.label, + description: option.description, })); const currentModel = typeof context?.model === "string" && context.model @@ -75,6 +64,7 @@ export const executeModelsCommand = async (args, context) => { availableModels, availableOptions, defaultModel: catalog.DEFAULT, + cache: result.cache, message: `Current model: ${currentModel}`, }, }; @@ -249,10 +239,7 @@ Custom commands can be created in: "/cost": async (args, context) => { const tokenUsage = context?.tokenUsage || {}; const provider = readModelProvider(context?.provider); - const catalog = await providerModelsService.getProviderModels( - provider, - getProviderModelOptions(provider, context), - ); + const catalog = (await providerModelsService.getProviderModels(provider)).models; const model = context?.model || catalog.DEFAULT; const used = @@ -361,10 +348,7 @@ Custom commands can be created in: : `${uptimeMinutes}m`; const statusProvider = readModelProvider(context?.provider); - const statusCatalog = await providerModelsService.getProviderModels( - statusProvider, - getProviderModelOptions(statusProvider, context), - ); + const statusCatalog = (await providerModelsService.getProviderModels(statusProvider)).models; const memoryUsage = process.memoryUsage(); return { diff --git a/server/routes/cursor.js b/server/routes/cursor.js index dfe67414..bcb2d61b 100644 --- a/server/routes/cursor.js +++ b/server/routes/cursor.js @@ -2,7 +2,7 @@ import express from 'express'; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; -import { CURSOR_MODELS } from '../modules/providers/services/provider-models.service.js'; +import { CURSOR_FALLBACK_MODELS } from '../modules/providers/list/cursor/cursor-models.provider.js'; const router = express.Router(); @@ -29,7 +29,7 @@ router.get('/config', async (req, res) => { config: { version: 1, model: { - modelId: CURSOR_MODELS.DEFAULT, + modelId: CURSOR_FALLBACK_MODELS.DEFAULT, displayName: 'GPT-5', }, permissions: { diff --git a/server/routes/tests/commands.test.js b/server/routes/tests/commands.test.js index 041fdf38..0dd481a1 100644 --- a/server/routes/tests/commands.test.js +++ b/server/routes/tests/commands.test.js @@ -1,54 +1,30 @@ import assert from 'node:assert/strict'; -import { mkdtemp, rm } from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; import test from 'node:test'; import { executeModelsCommand } from '../commands.js'; -const withTemporaryModelsCache = async (callback) => { - const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'commands-model-cache-')); - const previousCachePath = process.env.CLOUDCLI_PROVIDER_MODELS_CACHE_PATH; - process.env.CLOUDCLI_PROVIDER_MODELS_CACHE_PATH = path.join(tempRoot, 'models-cache.json'); - - try { - await callback(); - } finally { - if (previousCachePath === undefined) { - delete process.env.CLOUDCLI_PROVIDER_MODELS_CACHE_PATH; - } else { - process.env.CLOUDCLI_PROVIDER_MODELS_CACHE_PATH = previousCachePath; - } - await rm(tempRoot, { recursive: true, force: true }); - } -}; - test('models command returns available models only for the active provider', async () => { - await withTemporaryModelsCache(async () => { - const result = await executeModelsCommand([], { - provider: 'codex', - model: 'gpt-5.4', - }); - - assert.equal(result.type, 'builtin'); - assert.equal(result.action, 'models'); - assert.equal(result.data.current.provider, 'codex'); - assert.equal(result.data.current.model, 'gpt-5.4'); - assert.deepEqual(Object.keys(result.data.available), ['codex']); - assert.deepEqual(result.data.available.codex, result.data.availableModels); - assert.ok(result.data.availableModels.includes('gpt-5.4')); - assert.equal(result.data.available.claude, undefined); - assert.equal(result.data.available.cursor, undefined); + const result = await executeModelsCommand([], { + provider: 'codex', + model: 'gpt-5.4', }); + + assert.equal(result.type, 'builtin'); + assert.equal(result.action, 'models'); + assert.equal(result.data.current.provider, 'codex'); + assert.equal(result.data.current.model, 'gpt-5.4'); + assert.deepEqual(Object.keys(result.data.available), ['codex']); + assert.deepEqual(result.data.available.codex, result.data.availableModels); + assert.ok(result.data.availableModels.includes('gpt-5.4')); + assert.equal(result.data.available.claude, undefined); + assert.equal(result.data.available.cursor, undefined); }); test('models command falls back to claude for unsupported providers', async () => { - await withTemporaryModelsCache(async () => { - const result = await executeModelsCommand([], { - provider: 'unknown-provider', - }); - - assert.equal(result.data.current.provider, 'claude'); - assert.deepEqual(Object.keys(result.data.available), ['claude']); + const result = await executeModelsCommand([], { + provider: 'unknown-provider', }); + + assert.equal(result.data.current.provider, 'claude'); + assert.deepEqual(Object.keys(result.data.available), ['claude']); }); diff --git a/server/shared/interfaces.ts b/server/shared/interfaces.ts index bc97ffb3..acf8d516 100644 --- a/server/shared/interfaces.ts +++ b/server/shared/interfaces.ts @@ -7,6 +7,7 @@ import type { ProviderSkill, ProviderSkillListOptions, ProviderAuthStatus, + ProviderModelsDefinition, ProviderMcpServer, UpsertProviderMcpServerInput, } from '@/shared/types.js'; @@ -20,6 +21,7 @@ import type { */ export interface IProvider { readonly id: LLMProvider; + readonly models: IProviderModels; readonly mcp: IProviderMcp; readonly auth: IProviderAuth; readonly skills: IProviderSkills; @@ -27,6 +29,24 @@ export interface IProvider { readonly sessionSynchronizer: IProviderSessionSynchronizer; } +// --------------------------- +//----------------- PROVIDER MODEL INTERFACE ------------ +/** + * Model catalog contract for one provider. + * + * Implementations are responsible for resolving the provider's currently + * supported models and converting them into the shared + * `ProviderModelsDefinition` shape used by backend routes and frontend model + * pickers. The `DEFAULT` field should be the most appropriate default selection + * for that provider at the time the catalog is read. + */ +export interface IProviderModels { + /** + * Returns the provider's currently supported model catalog. + */ + getSupportedModels(): Promise; +} + // --------------------------- //----------------- PROVIDER AUTH INTERFACE ------------ /** diff --git a/server/shared/types.ts b/server/shared/types.ts index cc6f20e4..c9fb2878 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -73,6 +73,7 @@ export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode'; export type ProviderModelOption = { value: string; label: string; + description?: string; }; /** @@ -83,6 +84,31 @@ export type ProviderModelsDefinition = { DEFAULT: string; }; +/** + * Cache metadata returned alongside one provider model catalog. + * + * `updatedAt` is when the current cached snapshot was last refreshed from the + * provider itself. `expiresAt` is the backend cache expiry timestamp, and + * `source` tells callers whether the current response came from in-memory cache, + * persisted disk cache, or a fresh provider fetch. + */ +export type ProviderModelsCacheInfo = { + updatedAt: string; + expiresAt: string; + source: 'memory' | 'disk' | 'fresh'; +}; + +/** + * Full provider model lookup result returned by the backend service layer. + * + * Use this shape when a caller needs both the selectable model catalog and the + * cache metadata that explains how current the catalog is. + */ +export type ProviderModelsResult = { + models: ProviderModelsDefinition; + cache: ProviderModelsCacheInfo; +}; + /** * Message/event variants emitted by provider adapters and normalized transports. * diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 65da1f18..49b610cc 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -20,7 +20,7 @@ import type { PendingPermissionRequest, PermissionMode, } from '../types/types'; -import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; +import type { Project, ProjectSession, LLMProvider, ProviderModelsCacheInfo } from '../../../types/app'; import { escapeRegExp } from '../utils/chatFormatting'; import { useFileMentions } from './useFileMentions'; @@ -87,8 +87,10 @@ export type ModelCommandData = { availableOptions?: Array<{ value: string; label?: string; + description?: string; }>; defaultModel?: string; + cache?: ProviderModelsCacheInfo; }; export type CostCommandData = { diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts index f9815089..e8f0ed5c 100644 --- a/src/components/chat/hooks/useChatProviderState.ts +++ b/src/components/chat/hooks/useChatProviderState.ts @@ -1,7 +1,13 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { authenticatedFetch } from '../../../utils/api'; import type { PendingPermissionRequest, PermissionMode } from '../types/types'; -import type { ProjectSession, LLMProvider, Project, ProviderModelsDefinition } from '../../../types/app'; +import type { + ProjectSession, + LLMProvider, + Project, + ProviderModelsCacheInfo, + ProviderModelsDefinition, +} from '../../../types/app'; const FALLBACK_DEFAULT_MODEL: Record = { claude: 'opus', @@ -29,6 +35,14 @@ interface UseChatProviderStateArgs { selectedProject: Project | null; } +type ProviderModelsApiResponse = { + success?: boolean; + data?: { + models?: ProviderModelsDefinition; + cache?: ProviderModelsCacheInfo; + }; +}; + export function useChatProviderState({ selectedSession, selectedProject }: UseChatProviderStateArgs) { const [permissionMode, setPermissionMode] = useState('default'); const [pendingPermissionRequests, setPendingPermissionRequests] = useState([]); @@ -54,63 +68,78 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh const [providerModelCatalog, setProviderModelCatalog] = useState< Partial> >({}); + const [providerModelCacheCatalog, setProviderModelCacheCatalog] = useState< + Partial> + >({}); const [providerModelsLoading, setProviderModelsLoading] = useState(true); + const [providerModelsRefreshing, setProviderModelsRefreshing] = useState(false); const lastProviderRef = useRef(provider); + const providerModelsRequestIdRef = useRef(0); - const workspacePath = selectedProject?.fullPath || selectedProject?.path || ''; - - useEffect(() => { - let cancelled = false; + const loadProviderModels = useCallback(async (options: { bypassCache?: boolean } = {}) => { const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode']; + const requestId = providerModelsRequestIdRef.current + 1; + providerModelsRequestIdRef.current = requestId; + const isHardRefresh = options.bypassCache === true; - const load = async () => { + if (isHardRefresh) { + setProviderModelsRefreshing(true); + } else { setProviderModelsLoading(true); - try { - const results = await Promise.all( - providers.map(async (p) => { - const qs = - p === 'opencode' && workspacePath - ? `?workspacePath=${encodeURIComponent(workspacePath)}` - : ''; - const response = await authenticatedFetch(`/api/providers/${p}/models${qs}`); - const body = (await response.json()) as { - success?: boolean; - data?: { models?: ProviderModelsDefinition }; - }; - if (!body.success || !body.data?.models) { - return null; - } - return body.data.models; - }), - ); + } - if (cancelled) { + try { + const results = await Promise.all( + providers.map(async (p) => { + const params = new URLSearchParams(); + if (options.bypassCache) { + params.set('bypassCache', 'true'); + } + + const queryString = params.toString(); + const response = await authenticatedFetch(`/api/providers/${p}/models${queryString ? `?${queryString}` : ''}`); + const body = (await response.json()) as ProviderModelsApiResponse; + if (!body.success || !body.data?.models || !body.data?.cache) { + return null; + } + + return body.data; + }), + ); + + if (providerModelsRequestIdRef.current !== requestId) { + return; + } + + const nextCatalog: Partial> = {}; + const nextCacheCatalog: Partial> = {}; + + providers.forEach((p, i) => { + const entry = results[i]; + if (!entry) { return; } - const next: Partial> = {}; - providers.forEach((p, i) => { - const entry = results[i]; - if (entry) { - next[p] = entry; - } - }); - setProviderModelCatalog(next); - } catch (error) { - console.error('Error loading provider models:', error); - } finally { - if (!cancelled) { - setProviderModelsLoading(false); - } - } - }; + nextCatalog[p] = entry.models; + nextCacheCatalog[p] = entry.cache; + }); - void load(); - return () => { - cancelled = true; - }; - }, [workspacePath]); + setProviderModelCatalog(nextCatalog); + setProviderModelCacheCatalog(nextCacheCatalog); + } catch (error) { + console.error('Error loading provider models:', error); + } finally { + if (providerModelsRequestIdRef.current === requestId) { + setProviderModelsLoading(false); + setProviderModelsRefreshing(false); + } + } + }, []); + + useEffect(() => { + void loadProviderModels(); + }, [loadProviderModels]); const pickStoredOrCurrent = ( storageKey: string, @@ -279,6 +308,9 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh setPendingPermissionRequests, cyclePermissionMode, providerModelCatalog, + providerModelCacheCatalog, providerModelsLoading, + providerModelsRefreshing, + hardRefreshProviderModels: () => loadProviderModels({ bypassCache: true }), }; } diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index eab9cba9..c724bc9c 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -79,7 +79,10 @@ function ChatInterface({ setPendingPermissionRequests, cyclePermissionMode, providerModelCatalog, + providerModelCacheCatalog, providerModelsLoading, + providerModelsRefreshing, + hardRefreshProviderModels, } = useChatProviderState({ selectedSession, selectedProject, @@ -328,7 +331,10 @@ function ChatInterface({ opencodeModel={opencodeModel} setOpenCodeModel={setOpenCodeModel} providerModelCatalog={providerModelCatalog} + providerModelCacheCatalog={providerModelCacheCatalog} providerModelsLoading={providerModelsLoading} + providerModelsRefreshing={providerModelsRefreshing} + onHardRefreshProviderModels={hardRefreshProviderModels} tasksEnabled={tasksEnabled} isTaskMasterInstalled={isTaskMasterInstalled} onShowAllTasks={onShowAllTasks} @@ -431,6 +437,10 @@ function ChatInterface({ ); diff --git a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx index 1d2c4711..edd9528f 100644 --- a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx +++ b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx @@ -2,7 +2,13 @@ import { useTranslation } from 'react-i18next'; import { useCallback, useRef } from 'react'; import type { Dispatch, RefObject, SetStateAction } from 'react'; import type { ChatMessage } from '../../types/types'; -import type { Project, ProjectSession, LLMProvider, ProviderModelsDefinition } from '../../../../types/app'; +import type { + Project, + ProjectSession, + LLMProvider, + ProviderModelsCacheInfo, + ProviderModelsDefinition, +} from '../../../../types/app'; import { getIntrinsicMessageKey } from '../../utils/messageKeys'; import MessageComponent from './MessageComponent'; import ProviderSelectionEmptyState from './ProviderSelectionEmptyState'; @@ -29,7 +35,10 @@ interface ChatMessagesPaneProps { opencodeModel: string; setOpenCodeModel: (model: string) => void; providerModelCatalog: Partial>; + providerModelCacheCatalog: Partial>; providerModelsLoading: boolean; + providerModelsRefreshing: boolean; + onHardRefreshProviderModels: () => void; tasksEnabled: boolean; isTaskMasterInstalled: boolean | null; onShowAllTasks?: (() => void) | null; @@ -78,7 +87,10 @@ export default function ChatMessagesPane({ opencodeModel, setOpenCodeModel, providerModelCatalog, + providerModelCacheCatalog, providerModelsLoading, + providerModelsRefreshing, + onHardRefreshProviderModels, tasksEnabled, isTaskMasterInstalled, onShowAllTasks, @@ -165,7 +177,10 @@ export default function ChatMessagesPane({ opencodeModel={opencodeModel} setOpenCodeModel={setOpenCodeModel} providerModelCatalog={providerModelCatalog} + providerModelCacheCatalog={providerModelCacheCatalog} providerModelsLoading={providerModelsLoading} + providerModelsRefreshing={providerModelsRefreshing} + onHardRefreshProviderModels={onHardRefreshProviderModels} tasksEnabled={tasksEnabled} isTaskMasterInstalled={isTaskMasterInstalled} onShowAllTasks={onShowAllTasks} diff --git a/src/components/chat/view/subcomponents/CommandResultModal.tsx b/src/components/chat/view/subcomponents/CommandResultModal.tsx index cff49957..f0163bba 100644 --- a/src/components/chat/view/subcomponents/CommandResultModal.tsx +++ b/src/components/chat/view/subcomponents/CommandResultModal.tsx @@ -16,11 +16,13 @@ import { Sparkles, TerminalSquare, Timer, + RefreshCw, X, Zap, } from 'lucide-react'; import { Badge, Button, Dialog, DialogContent, DialogTitle, Input } from '../../../../shared/view/ui'; +import type { LLMProvider, ProviderModelsCacheInfo, ProviderModelsDefinition } from '../../../../types/app'; import type { CommandModalPayload, CostCommandData, @@ -32,6 +34,10 @@ import type { type CommandResultModalProps = { payload: CommandModalPayload | null; onClose: () => void; + providerModelCatalog: Partial>; + providerModelCacheCatalog: Partial>; + providerModelsRefreshing: boolean; + onHardRefreshProviderModels: () => void; }; type CommandEntry = { @@ -43,6 +49,20 @@ type CommandEntry = { type ModelOption = { value: string; label?: string; + description?: string; +}; + +const formatUpdatedAt = (value?: string) => { + if (!value) { + return 'Not cached yet'; + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return 'Not cached yet'; + } + + return parsed.toLocaleString(); }; const PROVIDER_LABELS: Record = { @@ -94,11 +114,13 @@ function MetricCard({ value, icon: Icon, tone = 'neutral', + compact = false, }: { label: string; value: string; icon: typeof Activity; tone?: 'neutral' | 'primary' | 'success'; + compact?: boolean; }) { const toneClass = tone === 'primary' @@ -108,12 +130,16 @@ function MetricCard({ : 'border-border/70 bg-background/75 text-muted-foreground'; return ( -
-
- +
+
+

{label}

-

{value}

+

{value}

); } @@ -222,21 +248,39 @@ function HelpContent({ data }: { data: HelpCommandData }) { ); } -function ModelsContent({ data }: { data: ModelCommandData }) { +function ModelsContent({ + data, + providerModelCatalog, + providerModelCacheCatalog, + providerModelsRefreshing, + onHardRefreshProviderModels, +}: { + data: ModelCommandData; + providerModelCatalog: Partial>; + providerModelCacheCatalog: Partial>; + providerModelsRefreshing: boolean; + onHardRefreshProviderModels: () => void; +}) { const [query, setQuery] = useState(''); const [copiedModel, setCopiedModel] = useState(null); - const currentProvider = data?.current?.provider || 'claude'; + const currentProvider = (data?.current?.provider || 'claude') as LLMProvider; const currentModel = data?.current?.model || 'Unknown'; - const defaultModel = data?.defaultModel || currentModel; const providerLabel = data?.current?.providerLabel || getProviderLabel(currentProvider); + const liveDefinition = providerModelCatalog[currentProvider]; + const currentCache = providerModelCacheCatalog[currentProvider] ?? data?.cache; const availableOptions = useMemo(() => { + if (liveDefinition?.OPTIONS && liveDefinition.OPTIONS.length > 0) { + return liveDefinition.OPTIONS; + } + if (Array.isArray(data?.availableOptions) && data.availableOptions.length > 0) { return data.availableOptions; } const availableModels = Array.isArray(data?.availableModels) ? data.availableModels : []; return availableModels.map((model) => ({ value: model, label: model })); - }, [data]); + }, [data, liveDefinition]); + const defaultModel = liveDefinition?.DEFAULT || data?.defaultModel || currentModel; const filteredOptions = useMemo(() => { const normalized = query.trim().toLowerCase(); @@ -245,7 +289,7 @@ function ModelsContent({ data }: { data: ModelCommandData }) { } return availableOptions.filter((option) => { - const haystack = `${option.value} ${option.label || ''}`.toLowerCase(); + const haystack = `${option.value} ${option.label || ''} ${option.description || ''}`.toLowerCase(); return haystack.includes(normalized); }); }, [availableOptions, query]); @@ -264,25 +308,49 @@ function ModelsContent({ data }: { data: ModelCommandData }) { return (
-
-
+
+
+

Hard refresh provider catalogs

+

+ Bypasses the 3-day backend cache and re-fetches models for every provider. +

+

+ Last updated for {providerLabel}: {formatUpdatedAt(currentCache?.updatedAt)} +

+
+ +
+ +
+

Active model

-

{currentModel}

+

{currentModel}

{activeOption?.label && activeOption.label !== currentModel && ( -

{activeOption.label}

+

{activeOption.label}

+ )} + {activeOption?.description && ( +

{activeOption.description}

)}
Live
-
- - -
+ +
@@ -320,6 +388,9 @@ function ModelsContent({ data }: { data: ModelCommandData }) { {option.label && option.label !== option.value && ( {option.label} )} + {option.description && ( + {option.description} + )} {isCurrent && Current selection} @@ -443,9 +514,17 @@ function StatusContent({ data }: { data: StatusCommandData }) { ); } -export default function CommandResultModal({ payload, onClose }: CommandResultModalProps) { +export default function CommandResultModal({ + payload, + onClose, + providerModelCatalog, + providerModelCacheCatalog, + providerModelsRefreshing, + onHardRefreshProviderModels, +}: CommandResultModalProps) { const isOpen = Boolean(payload); const kind = payload?.kind; + const isModelsModal = kind === 'models'; const modalMeta = { help: { @@ -482,23 +561,31 @@ export default function CommandResultModal({ payload, onClose }: CommandResultMo {activeMeta?.title || 'Command Result'} -
+
-
- +
+

{activeMeta?.eyebrow}

-

+

{activeMeta?.title}

-

+

{activeMeta?.subtitle}

@@ -519,7 +606,15 @@ export default function CommandResultModal({ payload, onClose }: CommandResultMo
{payload?.kind === 'help' && } - {payload?.kind === 'models' && } + {payload?.kind === 'models' && ( + + )} {payload?.kind === 'cost' && } {payload?.kind === 'status' && }
diff --git a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx index c3045ccd..99e385ac 100644 --- a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx +++ b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx @@ -1,12 +1,18 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { Check, ChevronDown } from "lucide-react"; +import { Check, ChevronDown, RefreshCw } from "lucide-react"; import { Trans, useTranslation } from "react-i18next"; import { useServerPlatform } from "../../../../hooks/useServerPlatform"; -import type { ProjectSession, LLMProvider, ProviderModelsDefinition } from "../../../../types/app"; +import type { + ProjectSession, + LLMProvider, + ProviderModelsCacheInfo, + ProviderModelsDefinition, +} from "../../../../types/app"; import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo"; import { NextTaskBanner } from "../../../task-master"; import { + Button, Dialog, DialogTrigger, DialogContent, @@ -48,7 +54,10 @@ type ProviderSelectionEmptyStateProps = { opencodeModel: string; setOpenCodeModel: (model: string) => void; providerModelCatalog: Partial>; + providerModelCacheCatalog: Partial>; providerModelsLoading: boolean; + providerModelsRefreshing: boolean; + onHardRefreshProviderModels: () => void; tasksEnabled: boolean; isTaskMasterInstalled: boolean | null; onShowAllTasks?: (() => void) | null; @@ -58,7 +67,7 @@ type ProviderSelectionEmptyStateProps = { type ProviderGroup = { id: LLMProvider; name: string; - models: { value: string; label: string }[]; + models: { value: string; label: string; description?: string }[]; }; function getModelConfig( @@ -92,6 +101,19 @@ function getProviderDisplayName(p: LLMProvider) { return "Gemini"; } +function formatUpdatedAt(value?: string) { + if (!value) { + return "Not cached yet"; + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return "Not cached yet"; + } + + return parsed.toLocaleString(); +} + export default function ProviderSelectionEmptyState({ selectedSession, currentSessionId, @@ -109,7 +131,10 @@ export default function ProviderSelectionEmptyState({ opencodeModel, setOpenCodeModel, providerModelCatalog, + providerModelCacheCatalog, providerModelsLoading, + providerModelsRefreshing, + onHardRefreshProviderModels, tasksEnabled, isTaskMasterInstalled, onShowAllTasks, @@ -156,6 +181,8 @@ export default function ProviderSelectionEmptyState({ return found?.label || currentModel; }, [provider, currentModel, providerModelCatalog]); + const currentProviderCache = providerModelCacheCatalog[provider]; + const setModelForProvider = useCallback( (providerId: LLMProvider, modelValue: string) => { if (providerId === "claude") { @@ -237,6 +264,32 @@ export default function ProviderSelectionEmptyState({ Model Selector +
+
+
+

+ Hard refresh model catalogs +

+

+ Bypasses the 3-day backend cache and re-fetches models for every provider. +

+

+ Last updated for {getProviderDisplayName(provider)}: {formatUpdatedAt(currentProviderCache?.updatedAt)} +

+
+ +
+
handleModelSelect(group.id, model.value)} className="ml-4 border-l border-border/40 pl-4" > - {model.label} +
+
{model.label}
+ {model.description && ( +
+ {model.description} +
+ )} +
{isSelected && ( )} diff --git a/src/types/app.ts b/src/types/app.ts index bd251545..aed51fd4 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -3,6 +3,7 @@ export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode'; export type ProviderModelOption = { value: string; label: string; + description?: string; }; export type ProviderModelsDefinition = { @@ -10,6 +11,12 @@ export type ProviderModelsDefinition = { DEFAULT: string; }; +export type ProviderModelsCacheInfo = { + updatedAt: string; + expiresAt: string; + source: 'memory' | 'disk' | 'fresh'; +}; + export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview' | `plugin:${string}`; export interface ProjectSession {