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.
This commit is contained in:
Haileyesus
2026-05-18 12:40:24 +03:00
parent ffaef395e4
commit 556cbd1a03
28 changed files with 1125 additions and 483 deletions

View File

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

View File

@@ -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<Options, 'env' | 'pathToClaudeCodeExecutable' | 'permissionMode'>;
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<string>();
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<ProviderModelsDefinition> {
let queryInstance: ReturnType<typeof query> | 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();
}
}
}

View File

@@ -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();

View File

@@ -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<string>();
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<ProviderModelsDefinition> {
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;
}
}
}

View File

@@ -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();

View File

@@ -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<string> => 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<string>();
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<ProviderModelsDefinition> {
try {
const stdout = await runCursorListModels();
const models = parseModelsOutput(stdout);
return buildCursorModelsDefinition(models);
} catch {
return CURSOR_FALLBACK_MODELS;
}
}
}

View File

@@ -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();

View File

@@ -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<ProviderModelsDefinition> {
return GEMINI_FALLBACK_MODELS;
}
}

View File

@@ -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();

View File

@@ -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<string> => 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<ProviderModelsDefinition> {
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;
}
}
}

View File

@@ -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();

View File

@@ -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 }));
}),
);

View File

@@ -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<Exclude<LLMProvider, 'opencode'>, ProviderModelsDefinition> = {
claude: CLAUDE_MODELS,
cursor: CURSOR_MODELS,
codex: CODEX_MODELS,
gemini: GEMINI_MODELS,
type ProviderModelsServiceDependencies = {
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
cachePath?: string;
now?: () => number;
};
type ProviderModelsOptions = {
cwd?: string;
bypassCache?: boolean;
};
type ProviderModelsLoader = (
provider: LLMProvider,
options?: ProviderModelsOptions,
) => Promise<ProviderModelsDefinition>;
type ProviderModelsCacheEntry = {
updatedAt: number;
expiresAt: number;
models: ProviderModelsDefinition;
};
@@ -125,75 +35,32 @@ type ProviderModelsCacheFile = {
entries: Record<string, ProviderModelsCacheEntry>;
};
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<string, ProviderModelsCacheEntry>,
entries: Map<LLMProvider, ProviderModelsCacheEntry>,
now: number,
): Promise<void> => {
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<string> =>
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<ProviderModelsDefinition> {
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<string, ProviderModelsCacheEntry>();
const pendingRequests = new Map<string, Promise<ProviderModelsDefinition>>();
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<LLMProvider, ProviderModelsCacheEntry>();
const pendingRequests = new Map<LLMProvider, Promise<ProviderModelsResult>>();
let persistedCacheLoaded = false;
let persistedCacheLoadPromise: Promise<void> | null = null;
const loadPersistedCache = async (cachePath: string): Promise<void> => {
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<void> => {
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<void> => {
const persistCache = async (): Promise<void> => {
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<void> => {
const entry = {
expiresAt: now() + PROVIDER_MODELS_CACHE_TTL_MS,
): Promise<ProviderModelsCacheEntry> => {
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<ProviderModelsDefinition> => {
const request = loadModels(provider, options)
): Promise<ProviderModelsResult> => {
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<ProviderModelsDefinition> => {
const cachePath = dependencies.cachePath ?? getProviderModelsCachePath();
const cacheKey = getProviderModelsCacheKey(provider, options);
const cachedModels = pruneExpiredMemoryEntry(cacheKey, now());
options: ProviderModelsOptions = {},
): Promise<ProviderModelsResult> => {
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 => {

View File

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

View File

@@ -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 });

View File

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

View File

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

View File

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

View File

@@ -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']);
});

View File

@@ -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<ProviderModelsDefinition>;
}
// ---------------------------
//----------------- PROVIDER AUTH INTERFACE ------------
/**

View File

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

View File

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

View File

@@ -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<LLMProvider, string> = {
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<PermissionMode>('default');
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]);
@@ -54,63 +68,78 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const [providerModelCatalog, setProviderModelCatalog] = useState<
Partial<Record<LLMProvider, ProviderModelsDefinition>>
>({});
const [providerModelCacheCatalog, setProviderModelCacheCatalog] = useState<
Partial<Record<LLMProvider, ProviderModelsCacheInfo>>
>({});
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<Record<LLMProvider, ProviderModelsDefinition>> = {};
const nextCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>> = {};
providers.forEach((p, i) => {
const entry = results[i];
if (!entry) {
return;
}
const next: Partial<Record<LLMProvider, ProviderModelsDefinition>> = {};
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 }),
};
}

View File

@@ -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({
<CommandResultModal
payload={commandModalPayload}
onClose={closeCommandModal}
providerModelCatalog={providerModelCatalog}
providerModelCacheCatalog={providerModelCacheCatalog}
providerModelsRefreshing={providerModelsRefreshing}
onHardRefreshProviderModels={hardRefreshProviderModels}
/>
</PermissionContext.Provider>
);

View File

@@ -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<Record<LLMProvider, ProviderModelsDefinition>>;
providerModelCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>>;
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}

View File

@@ -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<Record<LLMProvider, ProviderModelsDefinition>>;
providerModelCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>>;
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<string, string> = {
@@ -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 (
<div className="group rounded-2xl border border-border/70 bg-background/75 p-4 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/25 hover:shadow-md">
<div className={`mb-3 inline-flex rounded-xl border p-2 ${toneClass}`}>
<Icon className="h-4 w-4" />
<div
className={`group rounded-2xl border border-border/70 bg-background/75 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/25 hover:shadow-md ${
compact ? 'p-3' : 'p-4'
}`}
>
<div className={`inline-flex rounded-xl border ${compact ? 'mb-2 p-1.5' : 'mb-3 p-2'} ${toneClass}`}>
<Icon className={compact ? 'h-3.5 w-3.5' : 'h-4 w-4'} />
</div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">{label}</p>
<p className="mt-1 break-all text-sm font-semibold text-foreground">{value}</p>
<p className={`${compact ? 'mt-0.5 text-[13px]' : 'mt-1 text-sm'} break-all font-semibold text-foreground`}>{value}</p>
</div>
);
}
@@ -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<Record<LLMProvider, ProviderModelsDefinition>>;
providerModelCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>>;
providerModelsRefreshing: boolean;
onHardRefreshProviderModels: () => void;
}) {
const [query, setQuery] = useState('');
const [copiedModel, setCopiedModel] = useState<string | null>(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<ModelOption[]>(() => {
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 (
<div className="flex h-full min-h-0 flex-col gap-4">
<div className="grid gap-3 md:grid-cols-[1.15fr_0.85fr]">
<div className="relative overflow-hidden rounded-3xl border border-primary/25 bg-primary/10 p-5">
<div className="flex flex-col gap-2 rounded-2xl border border-border/70 bg-muted/20 px-3 py-2.5 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">Hard refresh provider catalogs</p>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
Bypasses the 3-day backend cache and re-fetches models for every provider.
</p>
<p className="mt-1 text-[11px] text-muted-foreground">
Last updated for {providerLabel}: {formatUpdatedAt(currentCache?.updatedAt)}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={onHardRefreshProviderModels}
disabled={providerModelsRefreshing}
className="h-8 shrink-0 rounded-xl px-3"
>
<RefreshCw className={providerModelsRefreshing ? 'animate-spin' : ''} />
{providerModelsRefreshing ? 'Refreshing...' : 'Hard Refresh'}
</Button>
</div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1.35fr)_minmax(0,0.48fr)_minmax(0,0.48fr)]">
<div className="relative overflow-hidden rounded-3xl border border-primary/25 bg-primary/10 p-4">
<div className="pointer-events-none absolute -right-10 -top-12 h-36 w-36 rounded-full bg-primary/20 blur-3xl" />
<div className="relative flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">Active model</p>
<h3 className="mt-2 break-all font-mono text-lg font-semibold text-foreground">{currentModel}</h3>
<h3 className="mt-1.5 break-all font-mono text-base font-semibold text-foreground">{currentModel}</h3>
{activeOption?.label && activeOption.label !== currentModel && (
<p className="mt-1 text-sm text-muted-foreground">{activeOption.label}</p>
<p className="mt-1 text-xs text-muted-foreground">{activeOption.label}</p>
)}
{activeOption?.description && (
<p className="mt-1.5 line-clamp-2 text-xs leading-5 text-muted-foreground">{activeOption.description}</p>
)}
</div>
<Badge className="shrink-0 rounded-full bg-primary text-primary-foreground">Live</Badge>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<MetricCard label="Provider" value={providerLabel} icon={Server} tone="primary" />
<MetricCard label="Models" value={String(availableOptions.length)} icon={Layers3} />
</div>
<MetricCard label="Provider" value={providerLabel} icon={Server} tone="primary" compact />
<MetricCard label="Models" value={String(availableOptions.length)} icon={Layers3} compact />
</div>
<div className="flex min-h-0 flex-1 flex-col rounded-3xl border border-border/70 bg-muted/15 p-3 sm:p-4">
@@ -320,6 +388,9 @@ function ModelsContent({ data }: { data: ModelCommandData }) {
{option.label && option.label !== option.value && (
<span className="mt-1 block text-xs text-muted-foreground">{option.label}</span>
)}
{option.description && (
<span className="mt-1 block text-xs leading-5 text-muted-foreground">{option.description}</span>
)}
{isCurrent && <span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">Current selection</span>}
</span>
<span className="rounded-lg border border-border/70 bg-muted/30 p-2 text-muted-foreground transition-colors group-hover:text-primary">
@@ -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
<DialogContent className="flex h-[min(92dvh,48rem)] w-[calc(100vw-1rem)] max-w-5xl flex-col overflow-hidden rounded-3xl border-border/80 bg-popover/95 p-0 shadow-2xl backdrop-blur-xl sm:w-[min(94vw,64rem)]">
<DialogTitle>{activeMeta?.title || 'Command Result'}</DialogTitle>
<div className="relative shrink-0 overflow-hidden border-b border-border/70 bg-gradient-to-br from-primary/15 via-background to-muted/40 px-4 pb-4 pt-4 sm:px-6 sm:pb-5 sm:pt-5">
<div
className={`relative shrink-0 overflow-hidden border-b border-border/70 bg-gradient-to-br from-primary/15 via-background to-muted/40 ${
isModelsModal ? 'px-4 pb-3 pt-3 sm:px-5 sm:pb-4 sm:pt-4' : 'px-4 pb-4 pt-4 sm:px-6 sm:pb-5 sm:pt-5'
}`}
>
<div className="pointer-events-none absolute -left-20 -top-24 h-56 w-56 rounded-full bg-primary/20 blur-3xl" />
<div className="pointer-events-none absolute right-0 top-0 h-full w-1/2 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary)/0.16),transparent_58%)]" />
<div className="relative flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3 sm:items-center">
<div className="rounded-2xl border border-primary/30 bg-primary/10 p-3 text-primary shadow-sm">
<HeaderIcon className="h-5 w-5" />
<div
className={`rounded-2xl border border-primary/30 bg-primary/10 text-primary shadow-sm ${
isModelsModal ? 'p-2.5' : 'p-3'
}`}
>
<HeaderIcon className={isModelsModal ? 'h-4 w-4' : 'h-5 w-5'} />
</div>
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-primary/80">
{activeMeta?.eyebrow}
</p>
<p className="mt-1 text-xl font-semibold tracking-tight text-foreground sm:text-2xl">
<p className={`mt-1 font-semibold tracking-tight text-foreground ${isModelsModal ? 'text-lg sm:text-xl' : 'text-xl sm:text-2xl'}`}>
{activeMeta?.title}
</p>
<p className="mt-1 max-w-2xl text-sm leading-5 text-muted-foreground">
<p className={`mt-1 max-w-2xl text-muted-foreground ${isModelsModal ? 'text-xs leading-5 sm:text-sm' : 'text-sm leading-5'}`}>
{activeMeta?.subtitle}
</p>
</div>
@@ -519,7 +606,15 @@ export default function CommandResultModal({ payload, onClose }: CommandResultMo
<div className="settings-content-enter min-h-0 flex-1 overflow-hidden px-4 py-4 sm:px-6 sm:py-5">
{payload?.kind === 'help' && <HelpContent data={payload.data as HelpCommandData} />}
{payload?.kind === 'models' && <ModelsContent data={payload.data as ModelCommandData} />}
{payload?.kind === 'models' && (
<ModelsContent
data={payload.data as ModelCommandData}
providerModelCatalog={providerModelCatalog}
providerModelCacheCatalog={providerModelCacheCatalog}
providerModelsRefreshing={providerModelsRefreshing}
onHardRefreshProviderModels={onHardRefreshProviderModels}
/>
)}
{payload?.kind === 'cost' && <CostContent data={payload.data as CostCommandData} />}
{payload?.kind === 'status' && <StatusContent data={payload.data as StatusCommandData} />}
</div>

View File

@@ -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<Record<LLMProvider, ProviderModelsDefinition>>;
providerModelCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>>;
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({
<DialogContent className="max-w-md overflow-hidden p-0">
<DialogTitle>Model Selector</DialogTitle>
<div className="border-b border-border/60 bg-muted/20 px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">
Hard refresh model catalogs
</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
Bypasses the 3-day backend cache and re-fetches models for every provider.
</p>
<p className="mt-1 text-[11px] text-muted-foreground">
Last updated for {getProviderDisplayName(provider)}: {formatUpdatedAt(currentProviderCache?.updatedAt)}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={onHardRefreshProviderModels}
disabled={providerModelsRefreshing}
className="shrink-0 rounded-xl"
>
<RefreshCw className={providerModelsRefreshing ? "animate-spin" : ""} />
{providerModelsRefreshing ? "Refreshing..." : "Hard Refresh"}
</Button>
</div>
</div>
<Command>
<CommandInput
placeholder={t("providerSelection.searchModels", {
@@ -274,11 +327,18 @@ export default function ProviderSelectionEmptyState({
return (
<CommandItem
key={`${group.id}-${model.value}`}
value={`${group.name} ${model.label}`}
value={`${group.name} ${model.label} ${model.description || ''}`}
onSelect={() => handleModelSelect(group.id, model.value)}
className="ml-4 border-l border-border/40 pl-4"
>
<span className="flex-1 truncate">{model.label}</span>
<div className="min-w-0 flex-1">
<div className="truncate">{model.label}</div>
{model.description && (
<div className="truncate text-xs text-muted-foreground">
{model.description}
</div>
)}
</div>
{isSelected && (
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" />
)}

View File

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