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 {