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) +})