diff --git a/src/config.ts b/src/config.ts index fb74cda..b7982ed 100644 --- a/src/config.ts +++ b/src/config.ts @@ -27,6 +27,7 @@ export interface Config { * server actively blocks requests whose version looks stale or absent. */ export const DEFAULT_CC_VERSION = "0.40.3"; +export const DEFAULT_CC_API_BASE = "https://api.commandcode.ai"; const CC_VERSION_REFRESH_MS = 24 * 60 * 60 * 1000; let cachedVersion: string | null = null; @@ -83,7 +84,7 @@ export function loadConfig(): Config { const host = cli.host || process.env.HOST || "127.0.0.1"; const port = parseInt(cli.port || process.env.PORT || "8787", 10); const apiKey = cli["api-key"] || process.env.CC_API_KEY || readAuthKey(); - const ccApiBase = process.env.CC_API_BASE || "https://api.commandcode.ai"; + const ccApiBase = process.env.CC_API_BASE || DEFAULT_CC_API_BASE; const ccVersion = process.env.CC_CLI_VERSION || cachedVersion || DEFAULT_CC_VERSION; const logLevel = process.env.LOG_LEVEL || "info"; // `*` is fine for a localhost proxy; restrict (e.g. to an origin or leave diff --git a/src/models.json b/src/models.json index 210a6e5..1c38b36 100644 --- a/src/models.json +++ b/src/models.json @@ -1,31 +1,50 @@ { + "closedModelOrgs": ["anthropic", "openai", "google", "gemini"], "builtin": [ "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash", + "deepseek/deepseek-v4-flash-vision-exp", + "zai-org/GLM-5.3", + "z-ai/glm-5.3-flash", "zai-org/GLM-5.2", "zai-org/GLM-5.2-Fast", "zai-org/GLM-5.1", "zai-org/GLM-5", "MiniMaxAI/MiniMax-M3", "MiniMaxAI/MiniMax-M2.7", + "minimax/minimax-m3-free", + "minimax/minimax-m2.7-free", "MiniMaxAI/MiniMax-M2.5", "moonshotai/Kimi-K3", "moonshotai/Kimi-K2.7-Code", "moonshotai/Kimi-K2.7-Code-Highspeed", "moonshotai/Kimi-K2.6", "moonshotai/Kimi-K2.5", + "Qwen/Qwen3.8-Max", + "Qwen/Qwen3.8-Flash", + "Qwen/Qwen3.8-27B", "Qwen/Qwen3.7-Max", "Qwen/Qwen3.7-Plus", + "Qwen/Qwen3.7-Flash", "Qwen/Qwen3.6-Max-Preview", "Qwen/Qwen3.6-Plus", "stepfun/Step-3.7-Flash", "stepfun/Step-3.5-Flash", "xiaomi/mimo-v2.5-pro", "xiaomi/mimo-v2.5", + "xai/grok-4.6", "xai/grok-4.5", "nvidia/nemotron-3-ultra-550b-a55b", "thinkingmachines/inkling", - "tencent/Hy3" + "thinkingmachines/inkling-small", + "tencent/hy4-preview", + "tencent/hy3-paid", + "tencent/Hy3", + "meta/muse-spark-1.2", + "meta/muse-spark-1.2-contributor", + "meta/muse-spark-1.1", + "sakana/fugu-ultra", + "poolside/laguna-s-2.1-free" ], "shortAliases": { "deepseek-v4-pro": "deepseek/deepseek-v4-pro", @@ -33,6 +52,12 @@ "deepseek-pro": "deepseek/deepseek-v4-pro", "deepseek-v4-flash": "deepseek/deepseek-v4-flash", "deepseek-flash": "deepseek/deepseek-v4-flash", + "deepseek-v4-flash-vision": "deepseek/deepseek-v4-flash-vision-exp", + "deepseek-vision": "deepseek/deepseek-v4-flash-vision-exp", + "glm-5.3": "zai-org/GLM-5.3", + "glm5.3": "zai-org/GLM-5.3", + "glm-5.3-flash": "z-ai/glm-5.3-flash", + "glm5.3-flash": "z-ai/glm-5.3-flash", "glm-5.2": "zai-org/GLM-5.2", "glm5.2": "zai-org/GLM-5.2", "glm-5.2-fast": "zai-org/GLM-5.2-Fast", @@ -41,8 +66,10 @@ "glm-5": "zai-org/GLM-5", "minimax-m3": "MiniMaxAI/MiniMax-M3", "minimax3": "MiniMaxAI/MiniMax-M3", + "minimax-m3-free": "minimax/minimax-m3-free", "minimax-m2.7": "MiniMaxAI/MiniMax-M2.7", "minimax2.7": "MiniMaxAI/MiniMax-M2.7", + "minimax-m2.7-free": "minimax/minimax-m2.7-free", "minimax-m2.5": "MiniMaxAI/MiniMax-M2.5", "minimax2.5": "MiniMaxAI/MiniMax-M2.5", "kimi-k3": "moonshotai/Kimi-K3", @@ -56,14 +83,22 @@ "kimi2.6": "moonshotai/Kimi-K2.6", "kimi-k2.5": "moonshotai/Kimi-K2.5", "kimi2.5": "moonshotai/Kimi-K2.5", + "qwen3.8-max": "Qwen/Qwen3.8-Max", + "qwen-3.8-max": "Qwen/Qwen3.8-Max", + "qwen3.8-flash": "Qwen/Qwen3.8-Flash", + "qwen-3.8-flash": "Qwen/Qwen3.8-Flash", + "qwen3.8-27b": "Qwen/Qwen3.8-27B", + "qwen-3.8-27b": "Qwen/Qwen3.8-27B", "qwen3.7-max": "Qwen/Qwen3.7-Max", "qwen-3.7-max": "Qwen/Qwen3.7-Max", "qwen3.7-plus": "Qwen/Qwen3.7-Plus", "qwen-3.7-plus": "Qwen/Qwen3.7-Plus", - "qwen-3.6-max": "Qwen/Qwen3.6-Max-Preview", + "qwen3.7-flash": "Qwen/Qwen3.7-Flash", + "qwen-3.7-flash": "Qwen/Qwen3.7-Flash", "qwen3.6-max": "Qwen/Qwen3.6-Max-Preview", - "qwen-3.6-plus": "Qwen/Qwen3.6-Plus", + "qwen-3.6-max": "Qwen/Qwen3.6-Max-Preview", "qwen3.6-plus": "Qwen/Qwen3.6-Plus", + "qwen-3.6-plus": "Qwen/Qwen3.6-Plus", "step-3.7-flash": "stepfun/Step-3.7-Flash", "step3.7": "stepfun/Step-3.7-Flash", "step-3.5-flash": "stepfun/Step-3.5-Flash", @@ -72,73 +107,125 @@ "mimo-pro": "xiaomi/mimo-v2.5-pro", "mimo-v2.5": "xiaomi/mimo-v2.5", "mimo2.5": "xiaomi/mimo-v2.5", + "grok-4.6": "xai/grok-4.6", + "grok4.6": "xai/grok-4.6", "grok-4.5": "xai/grok-4.5", "grok4.5": "xai/grok-4.5", "nemotron": "nvidia/nemotron-3-ultra-550b-a55b", "nemotron-3-ultra": "nvidia/nemotron-3-ultra-550b-a55b", "inkling": "thinkingmachines/inkling", - "hy3": "tencent/Hy3" + "inkling-small": "thinkingmachines/inkling-small", + "hy4": "tencent/hy4-preview", + "hy4-preview": "tencent/hy4-preview", + "hy3": "tencent/Hy3", + "muse-spark": "meta/muse-spark-1.2", + "muse-spark-1.2": "meta/muse-spark-1.2", + "muse-spark-contributor": "meta/muse-spark-1.2-contributor", + "muse-spark-1.1": "meta/muse-spark-1.1", + "fugu": "sakana/fugu-ultra", + "fugu-ultra": "sakana/fugu-ultra", + "laguna": "poolside/laguna-s-2.1-free" }, "contextWindows": { "deepseek/deepseek-v4-pro": 1048576, "deepseek/deepseek-v4-flash": 1048576, + "deepseek/deepseek-v4-flash-vision-exp": 1048576, + "zai-org/GLM-5.3": 1000000, + "z-ai/glm-5.3-flash": 1048576, "zai-org/GLM-5.2": 1048576, "zai-org/GLM-5.2-Fast": 1048576, "zai-org/GLM-5.1": 200000, "zai-org/GLM-5": 200000, "MiniMaxAI/MiniMax-M3": 1048576, "MiniMaxAI/MiniMax-M2.7": 204800, + "minimax/minimax-m3-free": 1048576, + "minimax/minimax-m2.7-free": 197000, "MiniMaxAI/MiniMax-M2.5": 200000, "moonshotai/Kimi-K3": 1048576, "moonshotai/Kimi-K2.7-Code": 256000, "moonshotai/Kimi-K2.7-Code-Highspeed": 262000, "moonshotai/Kimi-K2.6": 256000, "moonshotai/Kimi-K2.5": 256000, + "Qwen/Qwen3.8-Max": 1000000, + "Qwen/Qwen3.8-Flash": 1000000, + "Qwen/Qwen3.8-27B": 262144, "Qwen/Qwen3.7-Max": 1048576, "Qwen/Qwen3.7-Plus": 1048576, + "Qwen/Qwen3.7-Flash": 1048576, "Qwen/Qwen3.6-Max-Preview": 262144, "Qwen/Qwen3.6-Plus": 1048576, "stepfun/Step-3.7-Flash": 256000, "stepfun/Step-3.5-Flash": 1048576, "xiaomi/mimo-v2.5-pro": 1048576, "xiaomi/mimo-v2.5": 1048576, + "xai/grok-4.6": 500000, "xai/grok-4.5": 500000, "nvidia/nemotron-3-ultra-550b-a55b": 1048576, "thinkingmachines/inkling": 256000, - "tencent/Hy3": 262144 + "thinkingmachines/inkling-small": 1000000, + "tencent/hy4-preview": 1048576, + "tencent/hy3-paid": 262144, + "tencent/Hy3": 262144, + "meta/muse-spark-1.2": 1048576, + "meta/muse-spark-1.2-contributor": 1048576, + "meta/muse-spark-1.1": 1048576, + "sakana/fugu-ultra": 1000000, + "poolside/laguna-s-2.1-free": 256000 }, "modelNames": { "deepseek/deepseek-v4-pro": "DeepSeek V4 Pro", "deepseek/deepseek-v4-flash": "DeepSeek V4 Flash", + "deepseek/deepseek-v4-flash-vision-exp": "DeepSeek V4 Flash Vision (exp)", + "zai-org/GLM-5.3": "GLM-5.3", + "z-ai/glm-5.3-flash": "GLM-5.3 Flash", "zai-org/GLM-5.2": "GLM-5.2", "zai-org/GLM-5.2-Fast": "GLM-5.2 Fast", "zai-org/GLM-5.1": "GLM-5.1", "zai-org/GLM-5": "GLM-5", "MiniMaxAI/MiniMax-M3": "MiniMax M3", "MiniMaxAI/MiniMax-M2.7": "MiniMax M2.7", + "minimax/minimax-m3-free": "MiniMax M3 (Free)", + "minimax/minimax-m2.7-free": "MiniMax M2.7 (Free)", "MiniMaxAI/MiniMax-M2.5": "MiniMax M2.5", "moonshotai/Kimi-K3": "Kimi K3", "moonshotai/Kimi-K2.7-Code": "Kimi K2.7 Code", "moonshotai/Kimi-K2.7-Code-Highspeed": "Kimi K2.7 Code Highspeed", "moonshotai/Kimi-K2.6": "Kimi K2.6", "moonshotai/Kimi-K2.5": "Kimi K2.5", + "Qwen/Qwen3.8-Max": "Qwen 3.8 Max", + "Qwen/Qwen3.8-Flash": "Qwen 3.8 Flash", + "Qwen/Qwen3.8-27B": "Qwen 3.8 27B", "Qwen/Qwen3.7-Max": "Qwen 3.7 Max", "Qwen/Qwen3.7-Plus": "Qwen 3.7 Plus", + "Qwen/Qwen3.7-Flash": "Qwen 3.7 Flash", "Qwen/Qwen3.6-Max-Preview": "Qwen 3.6 Max Preview", "Qwen/Qwen3.6-Plus": "Qwen 3.6 Plus", "stepfun/Step-3.7-Flash": "Step 3.7 Flash", "stepfun/Step-3.5-Flash": "Step 3.5 Flash", "xiaomi/mimo-v2.5-pro": "MiMo V2.5 Pro", "xiaomi/mimo-v2.5": "MiMo V2.5", + "xai/grok-4.6": "Grok 4.6", "xai/grok-4.5": "Grok 4.5", "nvidia/nemotron-3-ultra-550b-a55b": "Nemotron 3 Ultra", "thinkingmachines/inkling": "Inkling", - "tencent/Hy3": "Hy3" + "thinkingmachines/inkling-small": "Inkling Small", + "tencent/hy4-preview": "Tencent Hy4 Preview", + "tencent/hy3-paid": "Tencent Hy3", + "tencent/Hy3": "Hy3", + "meta/muse-spark-1.2": "Muse Spark 1.2", + "meta/muse-spark-1.2-contributor": "Muse Spark 1.2 Contributor", + "meta/muse-spark-1.1": "Muse Spark 1.1", + "sakana/fugu-ultra": "Fugu Ultra", + "poolside/laguna-s-2.1-free": "Laguna S 2.1 (Free)" }, "reasoningEfforts": { "deepseek/deepseek-v4-pro": ["high", "max"], "deepseek/deepseek-v4-flash": ["high", "max"], + "deepseek/deepseek-v4-flash-vision-exp": ["high", "max"], + "zai-org/GLM-5.3": ["high", "max"], + "z-ai/glm-5.3-flash": ["high", "max"], "zai-org/GLM-5.2": ["high", "max"], + "xai/grok-4.6": ["low", "medium", "high"], "xai/grok-4.5": ["low", "medium", "high"] }, "maxOutputTokens": { diff --git a/src/server.ts b/src/server.ts index 9ce31de..0972e1a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,7 +8,7 @@ import { AnthropicStreamEncoder, buildAnthropicResponse, } from "@/translate/anthropic.js"; -import { getDefaultModels, fetchModelList } from "@/translate/models.js"; +import { refreshCatalog, getCatalog } from "@/translate/catalog.js"; import type { CCEvent } from "@/translate/types.js"; import { formatSSE, formatSSEDone, formatAnthropicSSE } from "@/stream.js"; import { sendToCC, collectEvents, UpstreamError } from "@/upstream.js"; @@ -26,13 +26,8 @@ import type { AnthropicRequest, AnthropicSSERecord } from "@/translate/anthropic // ────────────────────────────────────────── let config: Config; -let modelList: string[] = getDefaultModels(); let corsOrigin = "*"; -function updateModelList(models: string[]): void { - if (models.length > 0) modelList = models; -} - // ────────────────────────────────────────── // Request body parser // ────────────────────────────────────────── @@ -251,20 +246,21 @@ function handleModels(req: http.IncomingMessage, res: http.ServerResponse): void const isAnthropic = req.headers["anthropic-version"] !== undefined; if (isAnthropic) { - const items = modelList; + const catalog = getCatalog(); + const items = catalog.models; const data = { - data: items.map((id: string) => ({ - id, + data: items.map((m) => ({ + id: m.id, type: "model" as const, - display_name: id, + display_name: m.displayName, created_at: new Date().toISOString(), max_input_tokens: null as number | null, max_tokens: null as number | null, capabilities: null, })), has_more: false, - first_id: items.length > 0 ? items[0] : null, - last_id: items.length > 0 ? items[items.length - 1] : null, + first_id: items.length > 0 ? items[0].id : null, + last_id: items.length > 0 ? items[items.length - 1].id : null, }; sendJson(res, 200, data); return; @@ -272,7 +268,7 @@ function handleModels(req: http.IncomingMessage, res: http.ServerResponse): void const data = { object: "list", - data: modelList.map((id: string) => ({ + data: getCatalog().ids.map((id: string) => ({ id, object: "model", created: Math.floor(Date.now() / 1000), @@ -586,15 +582,12 @@ export function createServer(cfg: Config): http.Server { config = cfg; corsOrigin = cfg.corsOrigin; - // Start fetching model list in background (only if we have a key to use). + // Refresh the model catalog from the CC provider API in background (only if + // we have a key to use). On failure the static fallback stays in place. if (cfg.apiKey) { - fetchModelList(cfg.ccApiBase, cfg.apiKey) - .then((models) => { - if (models.length > 0) updateModelList(models); - }) - .catch(() => { - /* keep defaults */ - }); + refreshCatalog(cfg.ccApiBase, cfg.apiKey).catch(() => { + /* keep defaults */ + }); } const routes: RouteEntry[] = [ diff --git a/src/setup/opencode.ts b/src/setup/opencode.ts index 90ec8e1..137b244 100644 --- a/src/setup/opencode.ts +++ b/src/setup/opencode.ts @@ -1,26 +1,26 @@ import fs from "node:fs"; import path from "node:path"; import modelsData from "@/models.json" with { type: "json" }; +import { getCatalog, refreshCatalog, type ModelCatalog } from "@/translate/catalog.js"; +import { readAuthKey } from "@/auth.js"; +import { DEFAULT_CC_API_BASE } from "@/config.js"; /** Every reasoning-effort tier CC/OpenCode know about, ordered low → max. */ const EFFORT_UNIVERSE = ["low", "medium", "high", "xhigh", "max"] as const; -function buildProviderConfig(): Record { - const contextWindows: Record = modelsData.contextWindows ?? {}; +function buildProviderConfig(catalog: ModelCatalog): Record { const maxOutputTokens: Record = ((modelsData as Record).maxOutputTokens as Record) ?? {}; - const modelNames: Record = - ((modelsData as Record).modelNames as Record) ?? {}; const reasoningEfforts: Record = ((modelsData as Record).reasoningEfforts as Record) ?? {}; const models: Record> = {}; - for (const id of modelsData.builtin) { - const key = id.split("/").pop() ?? id; + for (const m of catalog.models) { + const key = m.id.split("/").pop() ?? m.id; const entry: Record = { - name: modelNames[id] ?? key, + name: m.displayName, limit: { - context: contextWindows[id] ?? 128_000, - output: maxOutputTokens[id] ?? 128_000, + context: m.contextWindow, + output: maxOutputTokens[m.id] ?? 128_000, }, }; // OpenCode's `/variants` picker lists a model's `variants`. OpenCode also @@ -29,7 +29,7 @@ function buildProviderConfig(): Record { // config — which would surface effort levels the model doesn't actually // support. Declare the full effort universe and `disabled:true` the ones // this model rejects; OpenCode filters disabled variants out (pickBy). - const efforts = reasoningEfforts[id]; + const efforts = reasoningEfforts[m.id]; if (efforts && efforts.length > 0) { entry.reasoning = true; const variants: Record> = {}; @@ -71,13 +71,31 @@ function writeConfig(filePath: string, config: Record): void { fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + "\n"); } +/** + * Resolve the live model catalog for the generated config. Uses the CC API + * when a key is available (env or saved auth); otherwise falls back to the + * static catalog from models.json. + */ +async function resolveCatalog(): Promise { + const apiKey = process.env.CC_API_KEY || readAuthKey(); + if (!apiKey) return getCatalog(); + const apiBase = process.env.CC_API_BASE || DEFAULT_CC_API_BASE; + const catalog = await refreshCatalog(apiBase, apiKey); + console.log( + catalog.models.some((m) => m.source === "api") + ? " Model list fetched from Command Code API." + : " Using built-in model list (API unavailable).", + ); + return catalog; +} + export async function setupOpenCodeConfig(scope?: "local" | "global"): Promise { const chosen = scope ?? "global"; const filePath = getConfigPath(chosen); const config = readConfig(filePath); const providers = (config.provider ?? {}) as Record; - providers.commandcode = buildProviderConfig(); + providers.commandcode = buildProviderConfig(await resolveCatalog()); config.provider = providers; writeConfig(filePath, config); diff --git a/src/translate/catalog.ts b/src/translate/catalog.ts new file mode 100644 index 0000000..0a203f5 --- /dev/null +++ b/src/translate/catalog.ts @@ -0,0 +1,168 @@ +// Dynamic model catalog: merges the CC provider API's live model list with the +// hand-maintained metadata in models.json. The API is the source of truth for +// which models exist, their display names, and context windows; models.json +// supplies what the API never returns (aliases, reasoning efforts, max output +// tokens) and doubles as the offline fallback. + +import modelsData from "@/models.json" with { type: "json" }; + +export interface CatalogModel { + id: string; + displayName: string; + contextWindow: number; + source: "static" | "api"; +} + +export interface ModelCatalog { + models: CatalogModel[]; + ids: string[]; + displayNames: Record; + contextWindows: Record; +} + +const STATIC_BUILTIN: string[] = modelsData.builtin; +const STATIC_CONTEXT: Record = modelsData.contextWindows ?? {}; +const STATIC_NAMES: Record = modelsData.modelNames ?? {}; +const CLOSED_MODEL_ORGS: string[] = modelsData.closedModelOrgs ?? [ + "anthropic", + "openai", + "google", + "gemini", +]; + +const CLOSED_ORG_SET = new Set(CLOSED_MODEL_ORGS.map((o) => o.toLowerCase())); + +/** New models land on the API long before anyone updates models.json, so the + * catalog refreshes much more aggressively than the CLI version check. */ +const CATALOG_TTL_MS = 60 * 60 * 1000; + +function buildCatalog(models: CatalogModel[]): ModelCatalog { + return { + models, + ids: models.map((m) => m.id), + displayNames: Object.fromEntries(models.map((m) => [m.id, m.displayName])), + contextWindows: Object.fromEntries(models.map((m) => [m.id, m.contextWindow])), + }; +} + +/** Best display name: API name → static name → bare last path segment. */ +function displayNameFor(id: string, apiName?: string): string { + return apiName ?? STATIC_NAMES[id] ?? id.split("/").pop() ?? id; +} + +export function getStaticCatalog(): ModelCatalog { + return buildCatalog( + STATIC_BUILTIN.map((id) => ({ + id, + displayName: displayNameFor(id), + contextWindow: STATIC_CONTEXT[id] ?? 128_000, + source: "static" as const, + })), + ); +} + +/** + * CC serves closed models (Anthropic/OpenAI/Google) that this proxy + * deliberately does not target. They appear in two shapes: bare ids + * ("claude-opus-5", "gpt-5.5") and org-prefixed ("google/gemini-3.7-flash"). + */ +export function isClosedModel(id: string): boolean { + const lower = id.toLowerCase(); + if (lower.startsWith("claude-") || lower.startsWith("gpt-")) return true; + return CLOSED_ORG_SET.has(lower.split("/")[0]); +} + +let current: ModelCatalog = getStaticCatalog(); +let lastFetchAt = 0; +let inflight: Promise | null = null; + +export function getCatalog(): ModelCatalog { + return current; +} + +interface ApiModel { + id: string; + name?: string; + context_length?: number; +} + +async function fetchApiModels(apiBase: string, apiKey: string): Promise { + const url = `${apiBase}/provider/v1/models`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) return []; + const json = (await res.json()) as { data?: ApiModel[] }; + return json.data ?? []; +} + +/** + * Refresh the catalog from the CC provider API (TTL-guarded, deduped). + * Merge rule: union of static builtin + API open models, static entries keep + * their order first (preserves the default model), API wins for name/context + * window when present. On failure or an empty filtered result the current + * catalog is returned unchanged — never throws to the caller. + */ +export async function refreshCatalog( + apiBase: string, + apiKey: string, + opts?: { force?: boolean }, +): Promise { + if (!opts?.force && Date.now() - lastFetchAt < CATALOG_TTL_MS) return current; + if (inflight) return inflight; + + inflight = (async () => { + try { + const apiModels = await fetchApiModels(apiBase, apiKey); + // Tolerate junk entries (null items, missing ids) rather than letting + // one malformed record discard the whole refresh. + const open = apiModels.filter( + (m): m is ApiModel & { id: string } => + typeof m === "object" && m !== null && typeof m.id === "string" && !isClosedModel(m.id), + ); + if (open.length > 0) { + const apiById = new Map(open.map((m) => [m.id, m])); + const merged: CatalogModel[] = []; + const seen = new Set(); + // Static builtin order first — keeps getDefaultModels()[0] stable. + for (const id of STATIC_BUILTIN) { + const api = apiById.get(id); + merged.push({ + id, + displayName: displayNameFor(id, api?.name), + contextWindow: api?.context_length ?? STATIC_CONTEXT[id] ?? 128_000, + source: api ? "api" : "static", + }); + seen.add(id); + } + for (const m of open) { + // `seen` also absorbs duplicate ids within the API list itself. + if (seen.has(m.id)) continue; + seen.add(m.id); + merged.push({ + id: m.id, + displayName: displayNameFor(m.id, m.name), + contextWindow: m.context_length ?? 128_000, + source: "api", + }); + } + current = buildCatalog(merged); + lastFetchAt = Date.now(); + } + } catch { + // Upstream failure → keep current catalog (static fallback). + } finally { + inflight = null; + } + return current; + })(); + + return inflight; +} + +export function __resetCatalogForTests(): void { + current = getStaticCatalog(); + lastFetchAt = 0; + inflight = null; +} diff --git a/src/translate/models.ts b/src/translate/models.ts index 8b8060c..f9a1021 100644 --- a/src/translate/models.ts +++ b/src/translate/models.ts @@ -1,6 +1,7 @@ // Model resolution, aliasing, and discovery against the CC provider API. import modelsData from "@/models.json" with { type: "json" }; +import { getCatalog } from "@/translate/catalog.js"; const BUILTIN_MODELS: string[] = modelsData.builtin; const SHORT_ALIASES: Record = modelsData.shortAliases; @@ -10,33 +11,13 @@ const REASONING_EFFORTS: Record = modelsData.reasoningEfforts /** Rank ordering of effort levels (low → max). Used to clip to the nearest valid. */ const EFFORT_RANK: Record = { low: 0, medium: 1, high: 2, xhigh: 3, max: 4 }; -/** - * Fetch available models from CC provider API. Returns the model list (used by - * the /v1/models endpoint). Failures yield an empty list — the caller falls - * back to the built-in defaults. - */ -export async function fetchModelList(apiBase: string, apiKey: string): Promise { - try { - const url = `${apiBase}/provider/v1/models`; - const res = await fetch(url, { - headers: { Authorization: `Bearer ${apiKey}` }, - signal: AbortSignal.timeout(5000), - }); - if (!res.ok) return []; - const json = (await res.json()) as { data?: { id: string }[] }; - return json.data?.map((m) => m.id) ?? []; - } catch { - return []; - } -} - export function getDefaultModels(): string[] { - return BUILTIN_MODELS; + return getCatalog().ids; } export function resolveModel(model: string): string { if (!model || model === "default") { - return BUILTIN_MODELS[0]; + return getCatalog().ids[0] ?? BUILTIN_MODELS[0]; } // Alias lookup is case-insensitive so callers can pass the bare model name // with original casing (e.g. "GLM-5.2") as well as the lowercase short alias. @@ -46,9 +27,9 @@ export function resolveModel(model: string): string { if (model.includes("/")) return model; // Bare name without an org prefix (e.g. "GLM-5.2", "Kimi-K3", or // "nemotron-3-ultra-550b-a55b" — which has no short alias). Match it against - // the builtin catalog by last path segment so it still resolves to a full ID. + // the (dynamic) catalog by last path segment so it still resolves to a full ID. const lower = model.toLowerCase(); - for (const id of BUILTIN_MODELS) { + for (const id of getCatalog().ids) { const last = id.split("/").pop() ?? id; if (last.toLowerCase() === lower) return id; } diff --git a/tests/anthropic-models.test.ts b/tests/anthropic-models.test.ts index 94bc3e9..26eae14 100644 --- a/tests/anthropic-models.test.ts +++ b/tests/anthropic-models.test.ts @@ -1,5 +1,7 @@ -import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; import { resolveAnthropicModel } from "@/translate/anthropic-models.js"; +import { refreshCatalog, __resetCatalogForTests } from "@/translate/catalog.js"; +import { mockCcModelsFetch } from "./helpers.js"; describe("resolveAnthropicModel", () => { let originalEnv: NodeJS.ProcessEnv; @@ -7,10 +9,12 @@ describe("resolveAnthropicModel", () => { beforeEach(() => { originalEnv = { ...process.env }; delete process.env.ANTHROPIC_DEFAULT_MODEL; + __resetCatalogForTests(); }); afterEach(() => { process.env = originalEnv; + vi.restoreAllMocks(); }); test("claude-* uses ANTHROPIC_DEFAULT_MODEL env", () => { @@ -27,4 +31,16 @@ describe("resolveAnthropicModel", () => { test("non-claude model passes through", () => { expect(resolveAnthropicModel("custom-model")).toBe("custom-model"); }); + + test("claude-* default stays stable even when the API returns a reordered list", async () => { + // The API lists models in its own order (closed models first in the wild). + // The static-first merge must keep getDefaultModels()[0] — and thus the + // claude-* fallback — pinned to the static builtin's first entry. + mockCcModelsFetch([ + { id: "zai-org/GLM-5.3", name: "GLM-5.3", context_length: 1000000 }, + { id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash", context_length: 1000000 }, + ]); + await refreshCatalog("https://api.test-cc.example", "test-key"); + expect(resolveAnthropicModel("claude-opus-4-1")).toBe("deepseek/deepseek-v4-pro"); + }); }); diff --git a/tests/catalog.test.ts b/tests/catalog.test.ts new file mode 100644 index 0000000..054d892 --- /dev/null +++ b/tests/catalog.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import http from "node:http"; +import { + getCatalog, + refreshCatalog, + isClosedModel, + __resetCatalogForTests, +} from "@/translate/catalog.js"; +import { resolveModel } from "@/translate/models.js"; +import { loadConfig } from "@/config.js"; +import { createServer } from "@/server.js"; +import { mockCcModelsFetch } from "./helpers.js"; + +const API_BASE = "https://api.test-cc.example"; +const API_KEY = "test-key"; + +const mockFetch = mockCcModelsFetch; + +const API_MODELS = [ + { id: "claude-opus-5", name: "Claude Opus 5", context_length: 1000000 }, + { id: "gpt-5.5", name: "GPT 5.5", context_length: 400000 }, + { id: "google/gemini-3.7-flash", name: "Gemini 3.7 Flash", context_length: 1048576 }, + // Known model: API context wins over the static value. + { id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro (latest)", context_length: 999999 }, + // New API-only model. + { id: "zai-org/GLM-5.3", name: "GLM-5.3", context_length: 1000000 }, +]; + +beforeEach(() => { + __resetCatalogForTests(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("isClosedModel", () => { + it("blocks closed models in both bare and org-prefixed shapes", () => { + expect(isClosedModel("claude-opus-5")).toBe(true); + expect(isClosedModel("gpt-5.5")).toBe(true); + expect(isClosedModel("anthropic/claude-x")).toBe(true); + expect(isClosedModel("google/gemini-3.7-flash")).toBe(true); + expect(isClosedModel("openai/gpt-5")).toBe(true); + }); + + it("allows open-source models", () => { + expect(isClosedModel("zai-org/GLM-5.2")).toBe(false); + expect(isClosedModel("deepseek/deepseek-v4-pro")).toBe(false); + expect(isClosedModel("xai/grok-4.6")).toBe(false); + }); +}); + +describe("refreshCatalog", () => { + it("merges API models over static metadata, filtering closed models", async () => { + const calls = { count: 0 }; + mockFetch(API_MODELS, calls); + const catalog = await refreshCatalog(API_BASE, API_KEY); + + // Closed models dropped. + expect(catalog.ids).not.toContain("claude-opus-5"); + expect(catalog.ids).not.toContain("gpt-5.5"); + expect(catalog.ids).not.toContain("google/gemini-3.7-flash"); + + // Static-first ordering preserved (default model stability)… + expect(catalog.ids[0]).toBe("deepseek/deepseek-v4-pro"); + // …with API-only models appended. + expect(catalog.ids).toContain("zai-org/GLM-5.3"); + + // API wins for name + context when present. + expect(catalog.displayNames["deepseek/deepseek-v4-pro"]).toBe("DeepSeek V4 Pro (latest)"); + expect(catalog.contextWindows["deepseek/deepseek-v4-pro"]).toBe(999999); + expect(catalog.contextWindows["zai-org/GLM-5.3"]).toBe(1000000); + }); + + it("keeps the current catalog when the upstream fetch fails", async () => { + const before = getCatalog(); + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network down")); + const catalog = await refreshCatalog(API_BASE, API_KEY); + expect(catalog.ids).toEqual(before.ids); + }); + + it("respects the TTL: an immediate second refresh does not refetch", async () => { + const calls = { count: 0 }; + mockFetch(API_MODELS, calls); + await refreshCatalog(API_BASE, API_KEY); + await refreshCatalog(API_BASE, API_KEY); + expect(calls.count).toBe(1); + // force bypasses the TTL. + await refreshCatalog(API_BASE, API_KEY, { force: true }); + expect(calls.count).toBe(2); + }); + + it("refetches once the TTL has elapsed", async () => { + const calls = { count: 0 }; + mockFetch(API_MODELS, calls); + const now = vi.spyOn(Date, "now"); + await refreshCatalog(API_BASE, API_KEY); + expect(calls.count).toBe(1); + // Jump past the 1h TTL. + now.mockReturnValue(Date.now() + 60 * 60 * 1000 + 1); + try { + await refreshCatalog(API_BASE, API_KEY); + expect(calls.count).toBe(2); + } finally { + now.mockRestore(); + } + }); + + it("keeps static entries the API no longer lists (retired upstream models)", async () => { + // The payload only knows deepseek-v4-pro — every other static builtin + // model has "disappeared" from the API. + mockFetch([{ id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro", context_length: 1 }]); + const catalog = await refreshCatalog(API_BASE, API_KEY); + const glm = catalog.models.find((m) => m.id === "zai-org/GLM-5.2"); + expect(glm).toBeDefined(); + expect(glm?.source).toBe("static"); + // Static fallbacks fill in name + context when the API is silent. + expect(glm?.displayName).toBe("GLM-5.2"); + expect(glm?.contextWindow).toBe(1048576); + // The one model the API still knows is flagged as api-sourced. + expect(catalog.models.find((m) => m.id === "deepseek/deepseek-v4-pro")?.source).toBe("api"); + }); + + it("absorbs duplicate ids within the API list itself", async () => { + const before = getCatalog().ids; + mockFetch([ + { id: "neworg/Dup-Model", name: "Dup Model", context_length: 111 }, + { id: "neworg/Dup-Model", name: "Dup Model", context_length: 222 }, + ]); + const catalog = await refreshCatalog(API_BASE, API_KEY); + expect(catalog.ids.filter((id) => id === "neworg/Dup-Model")).toHaveLength(1); + expect(catalog.ids.length).toBe(before.length + 1); + // First occurrence wins. + expect(catalog.contextWindows["neworg/Dup-Model"]).toBe(111); + }); + + it("tolerates malformed API payloads without corrupting the catalog", async () => { + const before = getCatalog().ids; + + // data: null + mockFetch(null); + expect((await refreshCatalog(API_BASE, API_KEY, { force: true })).ids).toEqual(before); + + // items missing id are skipped; null context_length falls back + mockFetch([ + null, + { name: "No Id Model" }, + { id: "neworg/No-Context", name: "No Context", context_length: null }, + ]); + const catalog = await refreshCatalog(API_BASE, API_KEY, { force: true }); + expect(catalog.ids).toContain("neworg/No-Context"); + expect(catalog.contextWindows["neworg/No-Context"]).toBe(128_000); + // The id-less entries never made it in. + expect(catalog.ids.length).toBe(before.length + 1); + }); +}); + +describe("resolveModel against the dynamic catalog", () => { + it("resolves bare names of API-only models after a refresh", async () => { + // A model the static catalog doesn't know (fabricated id): passes + // through unresolved before refresh, maps to the full id after. + expect(resolveModel("New-Model-X")).toBe("New-Model-X"); + mockFetch([...API_MODELS, { id: "neworg/New-Model-X", name: "New Model X", context_length: 123 }]); + await refreshCatalog(API_BASE, API_KEY); + expect(resolveModel("New-Model-X")).toBe("neworg/New-Model-X"); + }); +}); + +describe("/v1/models endpoint with a live catalog", () => { + it("serves open models only, with display names for Anthropic clients", async () => { + mockFetch(API_MODELS); + const port = 18988; + const config = { ...loadConfig(), port, apiKey: API_KEY, host: "127.0.0.1" }; + const server = createServer(config); + await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve)); + try { + // createServer refreshes in the background; wait for it to land. + await refreshCatalog(API_BASE, API_KEY, { force: true }); + + const openaiRes = await fetch(`http://127.0.0.1:${port}/v1/models`); + const openaiBody = (await openaiRes.json()) as any; + const ids = openaiBody.data.map((m: any) => m.id); + expect(ids).not.toContain("claude-opus-5"); + expect(ids).toContain("zai-org/GLM-5.3"); + + const anthropicRes = await fetch(`http://127.0.0.1:${port}/v1/models`, { + headers: { "anthropic-version": "1" }, + }); + const anthropicBody = (await anthropicRes.json()) as any; + const glm = anthropicBody.data.find((m: any) => m.id === "zai-org/GLM-5.3"); + expect(glm.display_name).toBe("GLM-5.3"); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..e4e8ec4 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,20 @@ +import { vi } from "vitest"; + +/** + * Mock only the CC provider models endpoint. Other fetches (e.g. requests to + * the test server itself) must reach the real network stack — a blanket fetch + * mock silently swallows them and the test reads the mock payload instead of + * the server's actual response. + */ +export function mockCcModelsFetch(models: unknown, calls: { count: number } = { count: 0 }) { + const realFetch = globalThis.fetch.bind(globalThis); + return vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const url = typeof input === "string" ? input : (input as Request).url ?? String(input); + if (!url.includes("/provider/v1/models")) return realFetch(input, init); + calls.count += 1; + return new Response(JSON.stringify({ object: "list", data: models }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) as unknown as Response; + }); +} diff --git a/tests/setup-opencode.test.ts b/tests/setup-opencode.test.ts index 3ab7f0c..3884c29 100644 --- a/tests/setup-opencode.test.ts +++ b/tests/setup-opencode.test.ts @@ -1,12 +1,19 @@ import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; import { setupOpenCodeConfig } from "@/setup/opencode.js"; +import { __resetCatalogForTests } from "@/translate/catalog.js"; +import { mockCcModelsFetch } from "./helpers.js"; import fs from "node:fs"; +// Never hit the real CC API or read the developer's saved auth during tests. +vi.mock("@/auth.js", () => ({ readAuthKey: () => null })); + describe("setupOpenCodeConfig", () => { let written: string | null = null; beforeEach(() => { written = null; + __resetCatalogForTests(); + delete process.env.CC_API_KEY; vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(fs, "existsSync").mockImplementation(() => false); vi.spyOn(fs, "mkdirSync").mockImplementation(() => undefined as unknown as string); @@ -16,6 +23,7 @@ describe("setupOpenCodeConfig", () => { }); afterEach(() => { + delete process.env.CC_API_BASE; vi.restoreAllMocks(); }); @@ -53,4 +61,22 @@ describe("setupOpenCodeConfig", () => { // models without discrete efforts get no variants field expect(models["Qwen3.7-Max"].variants).toBeUndefined(); }); + + test("builds config from the live API catalog when a key is available", async () => { + process.env.CC_API_KEY = "test-key"; + process.env.CC_API_BASE = "https://api.test-cc.example"; + mockCcModelsFetch([ + { id: "claude-opus-5", name: "Claude Opus 5", context_length: 1000000 }, + { id: "zai-org/GLM-5.3", name: "GLM-5.3", context_length: 1000000 }, + ]); + + await setupOpenCodeConfig("local"); + const config = JSON.parse(written as string); + const models = config.provider.commandcode.models; + + // Closed model filtered out; API-only model present with API metadata. + expect(models["claude-opus-5"]).toBeUndefined(); + expect(models["GLM-5.3"].name).toBe("GLM-5.3"); + expect(models["GLM-5.3"].limit).toEqual({ context: 1000000, output: 128000 }); + }); });