From 3c4fe290c99efb3267409cd0a6ed5349984d6b26 Mon Sep 17 00:00:00 2001 From: thaolaptrinh Date: Sun, 30 Aug 2026 18:59:20 +0700 Subject: [PATCH 1/4] feat(models): dynamic open-source model catalog from CC provider API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the fully hand-maintained models.json catalog with a hybrid: the CC provider /provider/v1/models endpoint is now the source of truth for the model list, display names, and context windows (1h TTL cache, modeled on fetchLatestCliVersion), while models.json keeps what the API never returns — aliases, reasoning efforts, max output tokens — and doubles as the offline fallback. Closed models (claude-*, gpt-*, google/* and other closed orgs) are filtered out so the proxy only exposes open-source models available on the GO plan. New CC models now appear without a proxy release. - new src/translate/catalog.ts: merge (static-first order preserves the default model), isClosedModel, TTL + inflight-deduped refreshCatalog - models.ts/server.ts/opencode.ts read the live catalog; opencode setup fetches it when a key is available (env or saved auth) - models.json: closedModelOrgs blocklist + metadata/aliases for the new open models (GLM-5.3, Qwen 3.8 family, Grok 4.6, Hy4, Muse Spark, Fugu, Laguna, free MiniMax variants, ...) Co-Authored-By: Claude --- src/config.ts | 3 +- src/models.json | 99 ++++++++++++++++++++-- src/server.ts | 35 ++++---- src/setup/opencode.ts | 40 ++++++--- src/translate/catalog.ts | 156 +++++++++++++++++++++++++++++++++++ src/translate/models.ts | 29 +++---- tests/catalog.test.ts | 144 ++++++++++++++++++++++++++++++++ tests/setup-opencode.test.ts | 36 ++++++++ 8 files changed, 484 insertions(+), 58 deletions(-) create mode 100644 src/translate/catalog.ts create mode 100644 tests/catalog.test.ts 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..7c5ea5f --- /dev/null +++ b/src/translate/catalog.ts @@ -0,0 +1,156 @@ +// 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])), + }; +} + +export function getStaticCatalog(): ModelCatalog { + return buildCatalog( + STATIC_BUILTIN.map((id) => ({ + id, + displayName: STATIC_NAMES[id] ?? id.split("/").pop() ?? 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); + const open = apiModels.filter((m) => m.id && !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: api?.name ?? STATIC_NAMES[id] ?? id.split("/").pop() ?? id, + contextWindow: api?.context_length ?? STATIC_CONTEXT[id] ?? 128_000, + source: api ? "api" : "static", + }); + seen.add(id); + } + for (const m of open) { + if (seen.has(m.id)) continue; + merged.push({ + id: m.id, + displayName: m.name ?? m.id.split("/").pop() ?? m.id, + 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..0a2d9e4 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, refreshCatalog } from "@/translate/catalog.js"; const BUILTIN_MODELS: string[] = modelsData.builtin; const SHORT_ALIASES: Record = modelsData.shortAliases; @@ -11,32 +12,22 @@ const REASONING_EFFORTS: Record = modelsData.reasoningEfforts 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. + * Fetch available open-source models from the CC provider API (closed models + * are filtered out) and merge them into the catalog. Returns the merged model + * list. Failures keep the current (static fallback) catalog. */ 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 []; - } + const catalog = await refreshCatalog(apiBase, apiKey); + return catalog.ids; } 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 +37,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/catalog.test.ts b/tests/catalog.test.ts new file mode 100644 index 0000000..2b94a54 --- /dev/null +++ b/tests/catalog.test.ts @@ -0,0 +1,144 @@ +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"; + +const API_BASE = "https://api.test-cc.example"; +const API_KEY = "test-key"; + +function mockFetch(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); + // Only intercept CC API calls; other fetches (e.g. hitting the test server + // itself) must go to the real network stack. + 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; + }); +} + +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); + }); +}); + +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/setup-opencode.test.ts b/tests/setup-opencode.test.ts index 3ab7f0c..2208f7f 100644 --- a/tests/setup-opencode.test.ts +++ b/tests/setup-opencode.test.ts @@ -1,12 +1,18 @@ import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; import { setupOpenCodeConfig } from "@/setup/opencode.js"; +import { __resetCatalogForTests } from "@/translate/catalog.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 +22,7 @@ describe("setupOpenCodeConfig", () => { }); afterEach(() => { + delete process.env.CC_API_BASE; vi.restoreAllMocks(); }); @@ -53,4 +60,33 @@ 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"; + const realFetch = globalThis.fetch.bind(globalThis); + 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); + return new Response( + JSON.stringify({ + object: "list", + data: [ + { id: "claude-opus-5", name: "Claude Opus 5", context_length: 1000000 }, + { id: "zai-org/GLM-5.3", name: "GLM-5.3", context_length: 1000000 }, + ], + }), + { status: 200 }, + ) as unknown as Response; + }); + + 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 }); + }); }); From a67a614407a0fbf226b5b33718ce7b820d4f9942 Mon Sep 17 00:00:00 2001 From: thaolaptrinh Date: Sun, 30 Aug 2026 19:04:16 +0700 Subject: [PATCH 2/4] =?UTF-8?q?refactor(catalog):=20review=20cleanup=20?= =?UTF-8?q?=E2=80=94=20drop=20dead=20fetchModelList,=20dedupe=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-review of PR #8: - remove fetchModelList (no remaining callers after server.ts migrated to refreshCatalog) - extract displayNameFor() to collapse the three display-name fallback chains in catalog.ts - share the URL-filtering CC models fetch mock between test files via tests/helpers.ts Co-Authored-By: Claude --- src/translate/catalog.ts | 11 ++++++++--- src/translate/models.ts | 12 +----------- tests/catalog.test.ts | 16 ++-------------- tests/helpers.ts | 20 ++++++++++++++++++++ tests/setup-opencode.test.ts | 20 +++++--------------- 5 files changed, 36 insertions(+), 43 deletions(-) create mode 100644 tests/helpers.ts diff --git a/src/translate/catalog.ts b/src/translate/catalog.ts index 7c5ea5f..c6463f7 100644 --- a/src/translate/catalog.ts +++ b/src/translate/catalog.ts @@ -45,11 +45,16 @@ function buildCatalog(models: CatalogModel[]): ModelCatalog { }; } +/** 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: STATIC_NAMES[id] ?? id.split("/").pop() ?? id, + displayName: displayNameFor(id), contextWindow: STATIC_CONTEXT[id] ?? 128_000, source: "static" as const, })), @@ -120,7 +125,7 @@ export async function refreshCatalog( const api = apiById.get(id); merged.push({ id, - displayName: api?.name ?? STATIC_NAMES[id] ?? id.split("/").pop() ?? id, + displayName: displayNameFor(id, api?.name), contextWindow: api?.context_length ?? STATIC_CONTEXT[id] ?? 128_000, source: api ? "api" : "static", }); @@ -130,7 +135,7 @@ export async function refreshCatalog( if (seen.has(m.id)) continue; merged.push({ id: m.id, - displayName: m.name ?? m.id.split("/").pop() ?? m.id, + displayName: displayNameFor(m.id, m.name), contextWindow: m.context_length ?? 128_000, source: "api", }); diff --git a/src/translate/models.ts b/src/translate/models.ts index 0a2d9e4..f9a1021 100644 --- a/src/translate/models.ts +++ b/src/translate/models.ts @@ -1,7 +1,7 @@ // Model resolution, aliasing, and discovery against the CC provider API. import modelsData from "@/models.json" with { type: "json" }; -import { getCatalog, refreshCatalog } from "@/translate/catalog.js"; +import { getCatalog } from "@/translate/catalog.js"; const BUILTIN_MODELS: string[] = modelsData.builtin; const SHORT_ALIASES: Record = modelsData.shortAliases; @@ -11,16 +11,6 @@ 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 open-source models from the CC provider API (closed models - * are filtered out) and merge them into the catalog. Returns the merged model - * list. Failures keep the current (static fallback) catalog. - */ -export async function fetchModelList(apiBase: string, apiKey: string): Promise { - const catalog = await refreshCatalog(apiBase, apiKey); - return catalog.ids; -} - export function getDefaultModels(): string[] { return getCatalog().ids; } diff --git a/tests/catalog.test.ts b/tests/catalog.test.ts index 2b94a54..eb99c9c 100644 --- a/tests/catalog.test.ts +++ b/tests/catalog.test.ts @@ -9,24 +9,12 @@ import { 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"; -function mockFetch(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); - // Only intercept CC API calls; other fetches (e.g. hitting the test server - // itself) must go to the real network stack. - 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; - }); -} +const mockFetch = mockCcModelsFetch; const API_MODELS = [ { id: "claude-opus-5", name: "Claude Opus 5", context_length: 1000000 }, 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 2208f7f..3884c29 100644 --- a/tests/setup-opencode.test.ts +++ b/tests/setup-opencode.test.ts @@ -1,6 +1,7 @@ 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. @@ -64,21 +65,10 @@ describe("setupOpenCodeConfig", () => { 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"; - const realFetch = globalThis.fetch.bind(globalThis); - 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); - return new Response( - JSON.stringify({ - object: "list", - data: [ - { id: "claude-opus-5", name: "Claude Opus 5", context_length: 1000000 }, - { id: "zai-org/GLM-5.3", name: "GLM-5.3", context_length: 1000000 }, - ], - }), - { status: 200 }, - ) as unknown as Response; - }); + 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); From 0e648a64be129eb3badfac171c49e5a09e23c184 Mon Sep 17 00:00:00 2001 From: thaolaptrinh Date: Sun, 30 Aug 2026 19:44:39 +0700 Subject: [PATCH 3/4] test(catalog): cover TTL expiry, retired models, malformed payloads Closes the four gaps flagged in the review of PR #8: - TTL expiry actually elapses and refetches (Date.now spy) - static entries survive when the API no longer lists them (retired upstream models keep static name/context, source: "static") - malformed payloads: data:null keeps the catalog; id-less and null items are skipped instead of discarding the whole refresh (also hardened the filter in catalog.ts for this); null context_length falls back to 128k - resolveAnthropicModel keeps the claude-* default pinned to the static first entry even when the API returns a reordered list 166 tests pass, typecheck clean. Co-Authored-By: Claude --- src/translate/catalog.ts | 7 ++++- tests/anthropic-models.test.ts | 18 +++++++++++- tests/catalog.test.ts | 51 ++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/translate/catalog.ts b/src/translate/catalog.ts index c6463f7..69fcf28 100644 --- a/src/translate/catalog.ts +++ b/src/translate/catalog.ts @@ -115,7 +115,12 @@ export async function refreshCatalog( inflight = (async () => { try { const apiModels = await fetchApiModels(apiBase, apiKey); - const open = apiModels.filter((m) => m.id && !isClosedModel(m.id)); + // 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[] = []; 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 index eb99c9c..5c790be 100644 --- a/tests/catalog.test.ts +++ b/tests/catalog.test.ts @@ -89,6 +89,57 @@ describe("refreshCatalog", () => { 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("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", () => { From c03c9acc3631984cacfe18b28c51725e3e8d851b Mon Sep 17 00:00:00 2001 From: thaolaptrinh Date: Sun, 30 Aug 2026 19:46:49 +0700 Subject: [PATCH 4/4] fix(catalog): absorb duplicate ids within the API model list Self-review pass: an API payload listing the same API-only id twice would append it twice to the catalog (the `seen` set only tracked static ids). Now the second loop adds to `seen` as well; first occurrence wins. Regression test included. Co-Authored-By: Claude --- src/translate/catalog.ts | 2 ++ tests/catalog.test.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/translate/catalog.ts b/src/translate/catalog.ts index 69fcf28..0a203f5 100644 --- a/src/translate/catalog.ts +++ b/src/translate/catalog.ts @@ -137,7 +137,9 @@ export async function refreshCatalog( 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), diff --git a/tests/catalog.test.ts b/tests/catalog.test.ts index 5c790be..054d892 100644 --- a/tests/catalog.test.ts +++ b/tests/catalog.test.ts @@ -121,6 +121,19 @@ describe("refreshCatalog", () => { 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;