From cfe49d445154118af9b9caabd54cce35221ffa99 Mon Sep 17 00:00:00 2001 From: Kieran Roper <13541920+KRoperUK@users.noreply.github.com> Date: Sun, 14 Jun 2026 20:42:44 +0100 Subject: [PATCH] Source model list from /provider/v1/models API instead of scraping CLI bundle The model list was extracted by parsing the minified command-code CLI bundle with fragile string anchors (e.g. 'SONNET_4_6:{id:"claude-sonnet-4-6"'), which drifted out of date: models.json had 21 models while Command Code now serves 30 (missing mimo, claude-opus-4-8, claude-fable-5, nemotron, and others). Use the public OpenAI-compatible endpoint https://api.commandcode.ai/provider/v1/models as the source of truth for which models exist, their names, and context windows. The CLI bundle is still downloaded, but now only to enrich entries with pricing (the endpoint does not expose cost). Fields the endpoint lacks (reasoning, tool_call) come from a small MODEL_META overrides map with sensible defaults. Models without bundle pricing are kept with zeroed cost and a warning rather than being silently dropped, so newly released models appear immediately. - Remove brittle catalog scraping (extractModelCatalog, extractSpecConstants, HARDCODED_EXTRAS, provider-based TIER_MAP); derive tier from id namespace. - Regenerate models.json (21 -> 30) and README table. - Add tests/unit/models-json.test.ts to guard the models.json shape. Co-Authored-By: Claude Opus 4.8 --- README.md | 17 +++- models.json | 147 +++++++++++++++++++++++++++- scripts/sync-models.ts | 170 ++++++++++++--------------------- tests/unit/models-json.test.ts | 36 +++++++ 4 files changed, 252 insertions(+), 118 deletions(-) create mode 100644 tests/unit/models-json.test.ts diff --git a/README.md b/README.md index cd60840..633c520 100644 --- a/README.md +++ b/README.md @@ -60,13 +60,15 @@ COMMANDCODE_API_KEY=your-key opencode | Model ID | Name | Tier | Reasoning | Context | |---|---|---|---|---| +| `claude-fable-5` | Claude Fable 5 | premium | yes | 1M | | `claude-haiku-4-5-20251001` | Claude Haiku 4.5 | premium | no | 200K | | `claude-opus-4-7` | Claude Opus 4.7 | premium | yes | 1M | +| `claude-opus-4-8` | Claude Opus 4.8 | premium | yes | 1M | | `claude-sonnet-4-6` | Claude Sonnet 4.6 | premium | yes | 1M | | `gpt-5.3-codex` | GPT-5.3 Codex | premium | yes | 400K | | `gpt-5.4` | GPT-5.4 | premium | yes | 400K | | `gpt-5.4-mini` | GPT-5.4 Mini | premium | yes | 400K | -| `gpt-5.5` | GPT-5.5 | premium | yes | 256K | +| `gpt-5.5` | GPT-5.5 | premium | yes | 200K | | `deepseek/deepseek-v4-flash` | DeepSeek V4 Flash | open-source | yes | 1M | | `deepseek/deepseek-v4-pro` | DeepSeek V4 Pro | open-source | yes | 1M | | `google/gemini-3.1-flash-lite` | Gemini 3.1 Flash Lite | open-source | yes | 1M | @@ -75,12 +77,19 @@ COMMANDCODE_API_KEY=your-key opencode | `zai-org/GLM-5.1` | GLM-5.1 | open-source | no | 200K | | `moonshotai/Kimi-K2.5` | Kimi K2.5 | open-source | no | 256K | | `moonshotai/Kimi-K2.6` | Kimi K2.6 | open-source | no | 256K | +| `moonshotai/Kimi-K2.7-Code` | Kimi K2.7 Code | open-source | yes | 256K | +| `xiaomi/mimo-v2.5` | MiMo V2.5 | open-source | yes | 1M | +| `xiaomi/mimo-v2.5-pro` | MiMo V2.5 Pro | open-source | yes | 1M | | `MiniMaxAI/MiniMax-M2.5` | MiniMax M2.5 | open-source | no | 200K | -| `MiniMaxAI/MiniMax-M2.7` | MiniMax M2.7 | open-source | no | 1M | -| `Qwen/Qwen3.6-Max-Preview` | Qwen 3.6 Max Preview | open-source | yes | 1M | -| `Qwen/Qwen3.6-Plus` | Qwen 3.6 Plus | open-source | yes | 1M | +| `MiniMaxAI/MiniMax-M2.7` | MiniMax M2.7 | open-source | no | 200K | +| `MiniMaxAI/MiniMax-M3` | MiniMax M3 | open-source | yes | 1M | +| `nvidia/nemotron-3-ultra-550b-a55b` | Nemotron 3 Ultra | open-source | yes | 1M | +| `Qwen/Qwen3.6-Max-Preview` | Qwen 3.6 Max Preview | open-source | yes | 200K | +| `Qwen/Qwen3.6-Plus` | Qwen 3.6 Plus | open-source | yes | 200K | | `Qwen/Qwen3.7-Max` | Qwen 3.7 Max | open-source | yes | 1M | +| `Qwen/Qwen3.7-Plus` | Qwen 3.7 Plus | open-source | yes | 1M | | `stepfun/Step-3.5-Flash` | Step 3.5 Flash | open-source | yes | 1M | +| `stepfun/Step-3.7-Flash` | Step 3.7 Flash | open-source | yes | 256K | Full model list is maintained in [`models.json`](./models.json). Run `bun run sync` to refresh from the latest Command Code CLI release on npm. diff --git a/models.json b/models.json index db50960..8a138bd 100644 --- a/models.json +++ b/models.json @@ -1,4 +1,21 @@ [ + { + "id": "claude-fable-5", + "name": "Claude Fable 5", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "cost": { + "input": 10, + "output": 50, + "cache_read": 1, + "cache_write": 12.5 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, { "id": "claude-haiku-4-5-20251001", "name": "Claude Haiku 4.5", @@ -33,6 +50,23 @@ "output": 32000 } }, + { + "id": "claude-opus-4-8", + "name": "Claude Opus 4.8", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "cost": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, { "id": "claude-sonnet-4-6", "name": "Claude Sonnet 4.6", @@ -110,7 +144,7 @@ "cache_read": 0.5 }, "limit": { - "context": 256000, + "context": 200000, "output": 128000 } }, @@ -240,6 +274,51 @@ "output": 131072 } }, + { + "id": "moonshotai/Kimi-K2.7-Code", + "name": "Kimi K2.7 Code", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "cost": { + "input": 0, + "output": 0 + }, + "limit": { + "context": 256000, + "output": 65536 + } + }, + { + "id": "xiaomi/mimo-v2.5", + "name": "MiMo V2.5", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "cost": { + "input": 0, + "output": 0 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, + { + "id": "xiaomi/mimo-v2.5-pro", + "name": "MiMo V2.5 Pro", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "cost": { + "input": 0, + "output": 0 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, { "id": "MiniMaxAI/MiniMax-M2.5", "name": "MiniMax M2.5", @@ -267,10 +346,40 @@ "cache_read": 0.06 }, "limit": { - "context": 1000000, + "context": 200000, "output": 131072 } }, + { + "id": "MiniMaxAI/MiniMax-M3", + "name": "MiniMax M3", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "cost": { + "input": 0, + "output": 0 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, + { + "id": "nvidia/nemotron-3-ultra-550b-a55b", + "name": "Nemotron 3 Ultra", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "cost": { + "input": 0, + "output": 0 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, { "id": "Qwen/Qwen3.6-Max-Preview", "name": "Qwen 3.6 Max Preview", @@ -284,7 +393,7 @@ "cache_write": 1.63 }, "limit": { - "context": 1000000, + "context": 200000, "output": 131072 } }, @@ -300,7 +409,7 @@ "cache_read": 0.1 }, "limit": { - "context": 1000000, + "context": 200000, "output": 131072 } }, @@ -321,6 +430,21 @@ "output": 131072 } }, + { + "id": "Qwen/Qwen3.7-Plus", + "name": "Qwen 3.7 Plus", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "cost": { + "input": 0, + "output": 0 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, { "id": "stepfun/Step-3.5-Flash", "name": "Step 3.5 Flash", @@ -336,5 +460,20 @@ "context": 1000000, "output": 131072 } + }, + { + "id": "stepfun/Step-3.7-Flash", + "name": "Step 3.7 Flash", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "cost": { + "input": 0, + "output": 0 + }, + "limit": { + "context": 256000, + "output": 65536 + } } ] diff --git a/scripts/sync-models.ts b/scripts/sync-models.ts index ca83166..16fa881 100644 --- a/scripts/sync-models.ts +++ b/scripts/sync-models.ts @@ -8,6 +8,10 @@ const MODELS_JSON = join(PROJECT_ROOT, "models.json") const GLOBAL_CONFIG = join(homedir(), ".config", "opencode", "opencode.jsonc") const NPM_PACKAGE = "command-code" const TMP_DIR = join("/tmp", "cc-model-sync") +// Authoritative, OpenAI-compatible model listing. Source of truth for which models exist, +// their display names, and context windows. Pricing is not exposed here, so cost data is +// enriched from the CLI bundle (see fetchLatestBundle / extractCostData). +const MODELS_ENDPOINT = "https://api.commandcode.ai/provider/v1/models" interface ModelEntry { id: string @@ -30,16 +34,30 @@ interface CostEntry { cacheHitCost: number } -interface SnEntry { +interface EndpointModel { id: string - provider: string - spec: string - label: string name: string - description: string + context_length: number +} + +// Per-model overrides for fields the /provider/v1/models endpoint does not expose +// (reasoning capability, tool-call support). Anything not listed falls back to sensible +// defaults in buildModelEntry (reasoning: true, tool_call: true). +interface ModelMeta { reasoning?: boolean - reasoningEfforts?: string[] - contextWindow?: number + tool_call?: boolean +} + +// Models that are NOT reasoning-capable. Everything else defaults to reasoning: true. +// (Output limits live in FALLBACK_LIMITS; tool_call defaults to true.) +const MODEL_META: Record = { + "claude-haiku-4-5-20251001": { reasoning: false }, + "zai-org/GLM-5": { reasoning: false }, + "zai-org/GLM-5.1": { reasoning: false }, + "moonshotai/Kimi-K2.5": { reasoning: false }, + "moonshotai/Kimi-K2.6": { reasoning: false }, + "MiniMaxAI/MiniMax-M2.5": { reasoning: false }, + "MiniMaxAI/MiniMax-M2.7": { reasoning: false }, } const FALLBACK_COSTS: Record = { @@ -80,25 +98,15 @@ const FALLBACK_LIMITS: Record = { "google/gemini-3.1-flash-lite": { context: 1000000, output: 65536 }, } -const HARDCODED_EXTRAS: SnEntry[] = [ - { - id: "Qwen/Qwen3.7-Max", - provider: "vercel-ai-gateway", - spec: "chatComplete", - label: "Qwen 3.7 Max", - name: "Qwen 3.7 Max", - description: "latest Qwen Max model", - reasoning: true, - }, -] - -const TIER_MAP: Record = { - "anthropic": "premium", - "openai": "premium", - "baseten": "open-source", - "vercel-ai-gateway": "open-source", - "openrouter": "open-source", - "cloudflare-ai-gateway": "open-source", +async function fetchModelList(): Promise { + console.log(`Fetching model list from ${MODELS_ENDPOINT}...`) + const resp = await fetch(MODELS_ENDPOINT) + if (!resp.ok) throw new Error(`models endpoint returned ${resp.status}`) + const json = (await resp.json()) as { data?: Array<{ id: string; name: string; context_length: number }> } + if (!Array.isArray(json.data) || json.data.length === 0) { + throw new Error("models endpoint returned no models") + } + return json.data.map((m) => ({ id: m.id, name: m.name, context_length: m.context_length })) } async function fetchLatestBundle(): Promise<{ source: string; version: string }> { @@ -169,40 +177,6 @@ function extractWt(source: string): Record { return evaluateWithContext(normalizeForEval(raw), {}) } -function extractSpecConstants(source: string): { chatComplete: string; responses: string; qt: string } { - const anchorIdx = source.indexOf('SONNET_4_6:{id:"claude-sonnet-4-6"') - if (anchorIdx < 0) throw new Error("Could not find model catalog anchor") - - const before = source.slice(Math.max(0, anchorIdx - 5000), anchorIdx) - - const chatMatch = before.match(/([A-Za-z_$]+)="chatComplete"/) - const respMatch = before.match(/([A-Za-z_$]+)="responses"/) - if (!chatMatch || !respMatch) throw new Error("Could not find spec constants") - - const qtMatch = before.match(/([A-Za-z_$]+)=Vt\[0\]/) - const qtVar = qtMatch ? qtMatch[1] : null - - return { - chatComplete: chatMatch[1], - responses: respMatch[1], - qt: qtVar || "", - } -} - -function extractModelCatalog( - source: string, - wt: Record, - wtName: string, - spec: ReturnType, -): Record { - const raw = findBalancedObject(source, 'SONNET_4_6:{id:"claude-sonnet-4-6"') - const ctx: Record = { [wtName]: wt } - ctx[spec.chatComplete] = "chatComplete" - ctx[spec.responses] = "responses" - if (spec.qt) ctx[spec.qt] = wt.VERCEL_AI_GATEWAY - return evaluateWithContext(normalizeForEval(raw), ctx) -} - function extractCostData(source: string, wt: Record, wtName: string): Record { const anchor = '{id:"anthropic:claude-sonnet-4-' const anchorIdx = source.indexOf(anchor) @@ -264,38 +238,37 @@ function buildCostMap(costs: Record): Map, -): ModelEntry | null { - const provider = entry.provider || "unknown" - const tier = TIER_MAP[provider] ?? "open-source" +function buildModelEntry(model: EndpointModel, costMap: Map): ModelEntry { + // Namespaced ids (e.g. "deepseek/...", "xiaomi/...") are open-source; bare ids + // (claude-*, gpt-*) are premium. Matches every model the endpoint currently serves. + const tier: "premium" | "open-source" = model.id.includes("/") ? "open-source" : "premium" - const costEntry = costMap.get(entry.id) + const costEntry = costMap.get(model.id) let cost: { input: number; output: number; cache_read?: number; cache_write?: number } if (costEntry) { - cost = { - input: costEntry.promptCost, - output: costEntry.completionCost, - } + cost = { input: costEntry.promptCost, output: costEntry.completionCost } if (costEntry.cacheHitCost > 0) cost.cache_read = costEntry.cacheHitCost if (costEntry.cacheWrite5mCost > 0) cost.cache_write = costEntry.cacheWrite5mCost + } else if (FALLBACK_COSTS[model.id]) { + cost = FALLBACK_COSTS[model.id]! } else { - const fallback = FALLBACK_COSTS[entry.id] - if (!fallback) return null - cost = fallback + // New/unpriced model: keep it in the list with zeroed cost rather than dropping it. + console.warn(` No cost data for ${model.id} — defaulting to 0 (add to FALLBACK_COSTS to fix)`) + cost = { input: 0, output: 0 } } - const limit = entry.contextWindow - ? { context: entry.contextWindow, output: FALLBACK_LIMITS[entry.id]?.output ?? 65536 } - : FALLBACK_LIMITS[entry.id] ?? { context: 200000, output: 65536 } + const meta = MODEL_META[model.id] + const limit = { + context: model.context_length ?? FALLBACK_LIMITS[model.id]?.context ?? 200000, + output: FALLBACK_LIMITS[model.id]?.output ?? 65536, + } return { - id: entry.id, - name: entry.name, + id: model.id, + name: model.name, tier, - reasoning: entry.reasoning || (entry.reasoningEfforts?.length ?? 0) > 0, - tool_call: true, + reasoning: meta?.reasoning ?? true, + tool_call: meta?.tool_call ?? true, cost, limit, } @@ -391,6 +364,11 @@ async function main() { const args = process.argv.slice(2) const shouldUpdateGlobal = args.includes("--update-global") + // Authoritative model list (id, name, context window). + const list = await fetchModelList() + console.log(` Found ${list.length} models`) + + // CLI bundle is used only to enrich the list with pricing. const { source, version } = await fetchLatestBundle() console.log(`Read CLI bundle v${version} (${(source.length / 1024).toFixed(0)} KB)`) @@ -399,40 +377,12 @@ async function main() { const wtName = getWtVarName(source) console.log(` Provider enum var: ${wtName}, keys: ${Object.keys(wt).join(", ")}`) - console.log("Extracting spec constants...") - const spec = extractSpecConstants(source) - console.log(` chatComplete=${spec.chatComplete}, responses=${spec.responses}, qt=${spec.qt || "(none)"}`) - - console.log("Extracting model catalog...") - const models = extractModelCatalog(source, wt, wtName, spec) - const modelCount = Object.keys(models).length - console.log(` Found ${modelCount} models`) - console.log("Extracting cost data...") const costs = extractCostData(source, wt, wtName) const costMap = buildCostMap(costs) console.log(` Found ${costMap.size} cost entries`) - const entries: ModelEntry[] = [] - - for (const [, model] of Object.entries(models)) { - const entry = buildModelEntry(model, costMap) - if (entry) { - entries.push(entry) - } else { - console.warn(` Skipping ${model.id}: no cost data`) - } - } - - for (const extra of HARDCODED_EXTRAS) { - if (!entries.some((e) => e.id === extra.id)) { - const entry = buildModelEntry(extra, costMap) - if (entry) { - console.log(` Adding hardcoded extra: ${extra.id}`) - entries.push(entry) - } - } - } + const entries: ModelEntry[] = list.map((model) => buildModelEntry(model, costMap)) entries.sort((a, b) => { if (a.tier !== b.tier) return a.tier === "premium" ? -1 : 1 diff --git a/tests/unit/models-json.test.ts b/tests/unit/models-json.test.ts new file mode 100644 index 0000000..a26d73c --- /dev/null +++ b/tests/unit/models-json.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "bun:test" +import { readFileSync } from "fs" +import { join } from "path" + +const models = JSON.parse( + readFileSync(join(import.meta.dir, "..", "..", "models.json"), "utf-8"), +) as Array> + +test("models.json is a non-empty array", () => { + expect(Array.isArray(models)).toBe(true) + expect(models.length).toBeGreaterThan(0) +}) + +test("every entry has the ModelEntry shape", () => { + for (const m of models) { + expect(typeof m.id).toBe("string") + expect(typeof m.name).toBe("string") + expect(["premium", "open-source"]).toContain(m.tier) + expect(typeof m.reasoning).toBe("boolean") + expect(typeof m.tool_call).toBe("boolean") + + const cost = m.cost as Record + expect(typeof cost.input).toBe("number") + expect(typeof cost.output).toBe("number") + + const limit = m.limit as Record + expect(typeof limit.context).toBe("number") + expect(typeof limit.output).toBe("number") + expect(limit.context as number).toBeGreaterThan(0) + } +}) + +test("model ids are unique", () => { + const ids = models.map((m) => m.id as string) + expect(new Set(ids).size).toBe(ids.length) +})