From 095ed3127d2d0c2e157e0a58d6b1d07af284d3c1 Mon Sep 17 00:00:00 2001 From: Lucas Alcantara Date: Thu, 4 Jun 2026 11:53:25 -0300 Subject: [PATCH 1/5] feat: auto-sync models from CommandCode API at startup - Fetch model list from CommandCode API on plugin config hook - Merge with local models.json (API provides context_length, local provides curated cost/tier/reasoning) - Persist merged result back to models.json for offline fallback - Treat empty API response as failure to avoid wiping local models - Add idempotency to saveModels (only write when content changed) - Add new models and fix context sizes in models.json - Add unit tests for mergeModels (pure) and integration tests using existing mockFetch helpers --- README.md | 20 ++- models.json | 312 ++++++++++++++++++++++++------------- plugin.ts | 93 ++++++++++- scripts/generate-readme.ts | 2 +- tests/unit/plugin.test.ts | 136 +++++++++++++++- 5 files changed, 440 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index cd60840..9a34403 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ If you prefer to configure manually, add this to your `opencode.json`: } ``` -The plugin auto-registers models from [`models.json`](./models.json) at startup. You only need the `provider.commandcode` block — no need to list individual models. +The plugin auto-registers models from [`models.json`](./models.json) at startup, with specs automatically synced from the CommandCode API. You only need the `provider.commandcode` block — no need to list individual models. ### Environment Variable @@ -62,11 +62,12 @@ COMMANDCODE_API_KEY=your-key opencode |---|---|---|---|---| | `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 | @@ -76,13 +77,18 @@ COMMANDCODE_API_KEY=your-key opencode | `moonshotai/Kimi-K2.5` | Kimi K2.5 | open-source | no | 256K | | `moonshotai/Kimi-K2.6` | Kimi K2.6 | open-source | no | 256K | | `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 | no | 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 | +| `xiaomi/mimo-v2.5` | Xiaomi MiMo V2.5 | open-source | no | 1M | +| `xiaomi/mimo-v2.5-pro` | Xiaomi MiMo V2.5 Pro | open-source | no | 1M | -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. +Models are automatically synced from the CommandCode API at startup. Context sizes and new models are updated on each restart. The local [`models.json`](./models.json) serves as an offline fallback. ## Development @@ -109,7 +115,7 @@ For local testing, create `opencode.local.json` (gitignored) with `file://` path Run `opencode --config opencode.local.json` to test with your local build. -### Sync Models +### Sync Models (Manual) ```bash bun run sync # update models.json from Command Code diff --git a/models.json b/models.json index db50960..d23ada5 100644 --- a/models.json +++ b/models.json @@ -1,117 +1,182 @@ [ { - "id": "claude-haiku-4-5-20251001", - "name": "Claude Haiku 4.5", - "tier": "premium", + "id": "MiniMaxAI/MiniMax-M2.5", + "name": "MiniMax M2.5", + "tier": "open-source", "reasoning": false, "tool_call": true, "cost": { - "input": 1, - "output": 5, - "cache_read": 0.1, - "cache_write": 1.25 + "input": 0.5, + "output": 2 }, "limit": { "context": 200000, - "output": 8192 + "output": 131072 } }, { - "id": "claude-opus-4-7", - "name": "Claude Opus 4.7", - "tier": "premium", + "id": "MiniMaxAI/MiniMax-M2.7", + "name": "MiniMax M2.7", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "cost": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06 + }, + "limit": { + "context": 200000, + "output": 131072 + } + }, + { + "id": "MiniMaxAI/MiniMax-M3", + "name": "MiniMax M3", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "cost": { + "input": 0.5, + "output": 2 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "Qwen/Qwen3.6-Max-Preview", + "name": "Qwen 3.6 Max Preview", + "tier": "open-source", "reasoning": true, "tool_call": true, "cost": { - "input": 5, - "output": 25, - "cache_read": 0.5, - "cache_write": 6.25 + "input": 1.3, + "output": 7.8, + "cache_read": 0.26, + "cache_write": 1.63 + }, + "limit": { + "context": 200000, + "output": 131072 + } + }, + { + "id": "Qwen/Qwen3.6-Plus", + "name": "Qwen 3.6 Plus", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "cost": { + "input": 0.5, + "output": 3, + "cache_read": 0.1 + }, + "limit": { + "context": 200000, + "output": 131072 + } + }, + { + "id": "Qwen/Qwen3.7-Max", + "name": "Qwen 3.7 Max", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "cost": { + "input": 1.25, + "output": 3.75, + "cache_read": 0.25, + "cache_write": 1.56 }, "limit": { "context": 1000000, - "output": 32000 + "output": 131072 } }, { - "id": "claude-sonnet-4-6", - "name": "Claude Sonnet 4.6", - "tier": "premium", + "id": "Qwen/Qwen3.7-Plus", + "name": "Qwen 3.7 Plus", + "tier": "open-source", "reasoning": true, "tool_call": true, "cost": { - "input": 3, - "output": 15, - "cache_read": 0.3, - "cache_write": 3.75 + "input": 0.5, + "output": 3, + "cache_read": 0.1 }, "limit": { "context": 1000000, - "output": 16000 + "output": 131072 } }, { - "id": "gpt-5.3-codex", - "name": "GPT-5.3 Codex", + "id": "claude-haiku-4-5-20251001", + "name": "Claude Haiku 4.5", "tier": "premium", - "reasoning": true, + "reasoning": false, "tool_call": true, "cost": { - "input": 2, - "output": 8, - "cache_read": 0.5 + "input": 1, + "output": 5, + "cache_read": 0.1, + "cache_write": 1.25 }, "limit": { - "context": 400000, - "output": 128000 + "context": 200000, + "output": 8192 } }, { - "id": "gpt-5.4", - "name": "GPT-5.4", + "id": "claude-opus-4-7", + "name": "Claude Opus 4.7", "tier": "premium", "reasoning": true, "tool_call": true, "cost": { - "input": 2.5, - "output": 15, - "cache_read": 0.25 + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25 }, "limit": { - "context": 400000, - "output": 128000 + "context": 1000000, + "output": 32000 } }, { - "id": "gpt-5.4-mini", - "name": "GPT-5.4 Mini", + "id": "claude-opus-4-8", + "name": "Claude Opus 4.8", "tier": "premium", "reasoning": true, "tool_call": true, "cost": { - "input": 0.75, - "output": 4.5, - "cache_read": 0.075 + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25 }, "limit": { - "context": 400000, - "output": 128000 + "context": 1000000, + "output": 131072 } }, { - "id": "gpt-5.5", - "name": "GPT-5.5", + "id": "claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", "tier": "premium", "reasoning": true, "tool_call": true, "cost": { - "input": 5, - "output": 30, - "cache_read": 0.5 + "input": 3, + "output": 15, + "cache_read": 0.3, + "cache_write": 3.75 }, "limit": { - "context": 256000, - "output": 128000 + "context": 1000000, + "output": 16000 } }, { @@ -179,34 +244,67 @@ } }, { - "id": "zai-org/GLM-5", - "name": "GLM-5", - "tier": "open-source", - "reasoning": false, + "id": "gpt-5.3-codex", + "name": "GPT-5.3 Codex", + "tier": "premium", + "reasoning": true, "tool_call": true, "cost": { - "input": 0.95, - "output": 3.15 + "input": 2, + "output": 8, + "cache_read": 0.5 }, "limit": { - "context": 200000, - "output": 131072 + "context": 400000, + "output": 128000 } }, { - "id": "zai-org/GLM-5.1", - "name": "GLM-5.1", - "tier": "open-source", - "reasoning": false, + "id": "gpt-5.4", + "name": "GPT-5.4", + "tier": "premium", + "reasoning": true, "tool_call": true, "cost": { - "input": 1.4, - "output": 4.4, - "cache_read": 0.26 + "input": 2.5, + "output": 15, + "cache_read": 0.25 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "gpt-5.4-mini", + "name": "GPT-5.4 Mini", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "cost": { + "input": 0.75, + "output": 4.5, + "cache_read": 0.075 + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "gpt-5.5", + "name": "GPT-5.5", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "cost": { + "input": 5, + "output": 30, + "cache_read": 0.5 }, "limit": { "context": 200000, - "output": 131072 + "output": 128000 } }, { @@ -241,47 +339,46 @@ } }, { - "id": "MiniMaxAI/MiniMax-M2.5", - "name": "MiniMax M2.5", + "id": "stepfun/Step-3.5-Flash", + "name": "Step 3.5 Flash", "tier": "open-source", - "reasoning": false, + "reasoning": true, "tool_call": true, "cost": { - "input": 0.5, - "output": 2 + "input": 0.1, + "output": 0.3, + "cache_read": 0.02 }, "limit": { - "context": 200000, + "context": 1000000, "output": 131072 } }, { - "id": "MiniMaxAI/MiniMax-M2.7", - "name": "MiniMax M2.7", + "id": "stepfun/Step-3.7-Flash", + "name": "Step 3.7 Flash", "tier": "open-source", - "reasoning": false, + "reasoning": true, "tool_call": true, "cost": { - "input": 0.3, - "output": 1.2, - "cache_read": 0.06 + "input": 0.1, + "output": 0.3, + "cache_read": 0.02 }, "limit": { - "context": 1000000, + "context": 256000, "output": 131072 } }, { - "id": "Qwen/Qwen3.6-Max-Preview", - "name": "Qwen 3.6 Max Preview", + "id": "xiaomi/mimo-v2.5", + "name": "Xiaomi MiMo V2.5", "tier": "open-source", - "reasoning": true, + "reasoning": false, "tool_call": true, "cost": { - "input": 1.3, - "output": 7.8, - "cache_read": 0.26, - "cache_write": 1.63 + "input": 0.25, + "output": 1 }, "limit": { "context": 1000000, @@ -289,15 +386,14 @@ } }, { - "id": "Qwen/Qwen3.6-Plus", - "name": "Qwen 3.6 Plus", + "id": "xiaomi/mimo-v2.5-pro", + "name": "Xiaomi MiMo V2.5 Pro", "tier": "open-source", - "reasoning": true, + "reasoning": false, "tool_call": true, "cost": { "input": 0.5, - "output": 3, - "cache_read": 0.1 + "output": 2 }, "limit": { "context": 1000000, @@ -305,35 +401,33 @@ } }, { - "id": "Qwen/Qwen3.7-Max", - "name": "Qwen 3.7 Max", + "id": "zai-org/GLM-5", + "name": "GLM-5", "tier": "open-source", - "reasoning": true, + "reasoning": false, "tool_call": true, "cost": { - "input": 1.25, - "output": 3.75, - "cache_read": 0.25, - "cache_write": 1.56 + "input": 0.95, + "output": 3.15 }, "limit": { - "context": 1000000, + "context": 200000, "output": 131072 } }, { - "id": "stepfun/Step-3.5-Flash", - "name": "Step 3.5 Flash", + "id": "zai-org/GLM-5.1", + "name": "GLM-5.1", "tier": "open-source", - "reasoning": true, + "reasoning": false, "tool_call": true, "cost": { - "input": 0.1, - "output": 0.3, - "cache_read": 0.02 + "input": 1.4, + "output": 4.4, + "cache_read": 0.26 }, "limit": { - "context": 1000000, + "context": 200000, "output": 131072 } } diff --git a/plugin.ts b/plugin.ts index 655e127..f84db51 100644 --- a/plugin.ts +++ b/plugin.ts @@ -1,10 +1,10 @@ -import { readFileSync } from "fs" +import { readFileSync, writeFileSync } from "fs" import { join, dirname } from "path" import { fileURLToPath } from "url" const __dirname = dirname(fileURLToPath(import.meta.url)) -interface ModelEntry { +export interface ModelEntry { id: string name: string tier: "premium" | "open-source" @@ -14,9 +14,87 @@ interface ModelEntry { limit: { context: number; output: number } } +interface ApiModel { + id: string + context_length?: number +} + function loadModels(): ModelEntry[] { - const modelsPath = join(__dirname, "models.json") - return JSON.parse(readFileSync(modelsPath, "utf-8")) + try { + const modelsPath = join(__dirname, "models.json") + return JSON.parse(readFileSync(modelsPath, "utf-8")) + } catch { + return [] + } +} + +function saveModels(models: ModelEntry[]): void { + try { + const modelsPath = join(__dirname, "models.json") + const serialized = JSON.stringify(models, null, 2) + "\n" + const existing = (() => { + try { + return readFileSync(modelsPath, "utf-8") + } catch { + return null + } + })() + if (existing === serialized) return + writeFileSync(modelsPath, serialized, "utf-8") + } catch { + // silent — non-fatal + } +} + +async function fetchModelsFromApi(): Promise { + const apiKey = process.env.COMMANDCODE_API_KEY + if (!apiKey) return null + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 5000) + try { + const resp = await fetch("https://api.commandcode.ai/provider/v1/models", { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: controller.signal, + }) + + if (!resp.ok) return null + const data = (await resp.json()) as { data?: ApiModel[] } + return data.data ?? null + } catch { + return null + } finally { + clearTimeout(timeout) + } +} + +export function mergeModels(local: ModelEntry[], api: ApiModel[]): ModelEntry[] { + const apiMap = new Map(api.map((m) => [m.id, m])) + const merged: ModelEntry[] = [] + + for (const entry of local) { + const apiModel = apiMap.get(entry.id) + if (apiModel?.context_length) { + merged.push({ ...entry, limit: { ...entry.limit, context: apiModel.context_length } }) + } else { + merged.push(entry) + } + apiMap.delete(entry.id) + } + + for (const [id, apiModel] of apiMap) { + merged.push({ + id, + name: id.split("/").pop() ?? id, + tier: "open-source", + reasoning: false, + tool_call: true, + cost: { input: 0.5, output: 2 }, + limit: { context: apiModel.context_length ?? 131072, output: 131072 }, + }) + } + + return merged } function toConfigKey(id: string): string { @@ -40,7 +118,12 @@ export default async function commandcodePlugin() { if (!cc.env) cc.env = ["COMMANDCODE_API_KEY"] if (!cc.models) { - const models = loadModels() + let models = loadModels() + const apiModels = await fetchModelsFromApi() + if (apiModels && apiModels.length > 0) { + models = mergeModels(models, apiModels) + saveModels(models) + } const modelsObj: Record = {} for (const entry of models) { const key = toConfigKey(entry.id) diff --git a/scripts/generate-readme.ts b/scripts/generate-readme.ts index eb7fd3d..8be72a4 100644 --- a/scripts/generate-readme.ts +++ b/scripts/generate-readme.ts @@ -31,7 +31,7 @@ const table = [tableHeader, separator, ...rows].join("\n") const readme = readFileSync(README, "utf-8") const startMarker = "## Available Models" -const endMarker = "Full model list is maintained in" +const endMarker = "Models are automatically synced" const startIdx = readme.indexOf(startMarker) const endIdx = readme.indexOf(endMarker, startIdx) diff --git a/tests/unit/plugin.test.ts b/tests/unit/plugin.test.ts index d753d52..a3ba78e 100644 --- a/tests/unit/plugin.test.ts +++ b/tests/unit/plugin.test.ts @@ -1,4 +1,8 @@ -import { expect, test, beforeAll } from "bun:test" +import { expect, test, beforeAll, beforeEach, afterEach, spyOn } from "bun:test" +import * as fs from "fs" +import { mergeModels } from "../../plugin.ts" +import type { ModelEntry } from "../../plugin.ts" +import { mockFetch, mockFetchTrack } from "../helpers/mocks.ts" type PluginResult = { config: (config: Record) => Promise @@ -139,3 +143,133 @@ test("config hook creates provider block if missing", async () => { expect(cc).toBeDefined() expect(cc.npm).toBe("commandcode-go-opencode-provider") }) + +// --- mergeModels unit tests (pure, no I/O) --- + +const sampleModel = (overrides: Partial = {}): ModelEntry => ({ + id: "test/model-a", + name: "Model A", + tier: "premium", + reasoning: false, + tool_call: true, + cost: { input: 1, output: 2 }, + limit: { context: 100000, output: 8192 }, + ...overrides, +}) + +test("mergeModels preserves local entry when API has no match", () => { + const local = [sampleModel()] + const result = mergeModels(local, []) + expect(result).toHaveLength(1) + expect(result[0].id).toBe("test/model-a") +}) + +test("mergeModels updates context from API for known models", () => { + const local = [sampleModel({ id: "test/model-a" })] + const result = mergeModels(local, [{ id: "test/model-a", context_length: 200000 }]) + expect(result[0].limit.context).toBe(200000) + expect(result[0].limit.output).toBe(8192) +}) + +test("mergeModels keeps local context when API has no context_length", () => { + const local = [sampleModel({ id: "test/model-a", limit: { context: 150000, output: 4096 } })] + const result = mergeModels(local, [{ id: "test/model-a" }]) + expect(result[0].limit.context).toBe(150000) +}) + +test("mergeModels adds new API-only models with defaults", () => { + const local = [sampleModel({ id: "test/model-a" })] + const result = mergeModels(local, [{ id: "new-provider/new-model", context_length: 500000 }]) + expect(result).toHaveLength(2) + const newModel = result.find((m) => m.id === "new-provider/new-model")! + expect(newModel.name).toBe("new-model") + expect(newModel.tier).toBe("open-source") + expect(newModel.reasoning).toBe(false) + expect(newModel.limit.context).toBe(500000) + expect(newModel.limit.output).toBe(131072) +}) + +test("mergeModels uses default context when API model has none", () => { + const result = mergeModels([], [{ id: "new/model" }]) + expect(result[0].limit.context).toBe(131072) + expect(result[0].limit.output).toBe(131072) +}) + +test("mergeModels preserves curated fields for local entries", () => { + const local = [sampleModel({ + id: "test/model-a", + name: "Custom Name", + tier: "premium", + reasoning: true, + cost: { input: 5, output: 25 }, + })] + const result = mergeModels(local, [{ id: "test/model-a", context_length: 999 }]) + expect(result[0].name).toBe("Custom Name") + expect(result[0].tier).toBe("premium") + expect(result[0].reasoning).toBe(true) + expect(result[0].cost.input).toBe(5) +}) + +// --- Config hook integration tests (with writeFileSync mocked) --- + +let originalApiKey: string | undefined + +beforeEach(() => { + originalApiKey = process.env.COMMANDCODE_API_KEY +}) + +afterEach(() => { + if (originalApiKey === undefined) delete process.env.COMMANDCODE_API_KEY + else process.env.COMMANDCODE_API_KEY = originalApiKey +}) + +test("config hook falls back to local when API key is missing", async () => { + const spy = spyOn(fs, "writeFileSync").mockImplementation(() => undefined) + delete process.env.COMMANDCODE_API_KEY + + try { + const plugin = await pluginFn() + const config: Record = { provider: { commandcode: {} } } + await plugin.config(config) + + const cc = (config.provider as Record>).commandcode + const models = cc.models as Record> + expect(Object.keys(models).length).toBeGreaterThan(0) + } finally { + spy.mockRestore() + } +}) + +test("config hook does not write to disk when API response unparseable", async () => { + const spy = spyOn(fs, "writeFileSync").mockImplementation(() => undefined) + process.env.COMMANDCODE_API_KEY = "sk-test" + const fetchSpy = mockFetch({ json: () => Promise.reject(new Error("network error")) }) + + try { + const plugin = await pluginFn() + const config: Record = { provider: { commandcode: {} } } + await plugin.config(config) + expect(spy).not.toHaveBeenCalled() + } finally { + spy.mockRestore() + fetchSpy.restore() + } +}) + +test("config hook sends auth header to API", async () => { + process.env.COMMANDCODE_API_KEY = "sk-my-key" + const tracker = mockFetchTrack() + + try { + const plugin = await pluginFn() + const config: Record = { provider: { commandcode: {} } } + await plugin.config(config) + + expect(tracker.calls).toHaveLength(1) + expect(tracker.calls[0].url).toBe("https://api.commandcode.ai/provider/v1/models") + const headers = tracker.calls[0].options.headers as Record + expect(headers["Authorization"]).toBe("Bearer sk-my-key") + } finally { + tracker.restore() + } +}) From 33a3703a7d5d3cbf47b71b0a91ba08d80589b54e Mon Sep 17 00:00:00 2001 From: Lucas Alcantara Date: Thu, 4 Jun 2026 21:26:17 -0300 Subject: [PATCH 2/5] feat: harden auto-sync (in-memory, opt-out, fail-loud load) - Switch from disk write to in-memory merge; sync updates no longer persist between startups - Add config-file opt-out at ~/.config/opencode/commandcode-go-opencode-provider.json with `disableModelSync: true` to skip the API fetch entirely - loadModels: throw actionable error on missing/corrupt bundled models.json ("please reinstall commandcode-go-opencode-provider", original error chained via Error.cause) - Tests: add withFakeConfig / withMissingModels / withCorruptModels helpers and structural assertions for bundled, opt-out, and load-error paths - README: document the opt-out workflow and bundled-list refresh via `bun run sync` --- .gitignore | 2 + README.md | 16 ++++++ plugin.ts | 44 +++++++------- scripts/generate-readme.ts | 7 ++- tests/helpers/mocks.ts | 50 ++++++++++++++++ tests/unit/plugin.test.ts | 115 +++++++++++++++++++++++++++++-------- 6 files changed, 185 insertions(+), 49 deletions(-) diff --git a/.gitignore b/.gitignore index 84186db..d21fd3f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules/ dist/ *.js.map +*.bak +package-lock.json .DS_Store opencode.local.json diff --git a/README.md b/README.md index 9a34403..a4e903f 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,22 @@ Set `COMMANDCODE_API_KEY` instead of using `/connect`: COMMANDCODE_API_KEY=your-key opencode ``` +### Disabling Auto-Sync + +By default, the plugin syncs model specs from the CommandCode API on every opencode startup. The sync is best-effort and silent — if the API is unreachable, returns invalid JSON, or the `COMMANDCODE_API_KEY` is invalid, the plugin falls back to the bundled [`models.json`](./models.json). + +To disable the sync, create `~/.config/opencode/commandcode-go-opencode-provider.json`: + +```json +{ + "disableModelSync": true +} +``` + +When this file exists with `disableModelSync: true`, the plugin skips the API fetch entirely and uses the bundled `models.json` as-is. + +**Note:** Bundled `models.json` is updated by maintainer releases, not at runtime. Run `bun run sync` to refresh from the latest Command Code API. + ## Available Models | Model ID | Name | Tier | Reasoning | Context | diff --git a/plugin.ts b/plugin.ts index f84db51..8f902ae 100644 --- a/plugin.ts +++ b/plugin.ts @@ -1,9 +1,20 @@ -import { readFileSync, writeFileSync } from "fs" +import { readFileSync, existsSync } from "fs" +import { homedir } from "os" import { join, dirname } from "path" import { fileURLToPath } from "url" const __dirname = dirname(fileURLToPath(import.meta.url)) +function loadPluginConfig(): { disableModelSync?: boolean } { + const configPath = join(homedir(), ".config", "opencode", "commandcode-go-opencode-provider.json") + if (!existsSync(configPath)) return {} + try { + return JSON.parse(readFileSync(configPath, "utf-8")) + } catch { + return {} + } +} + export interface ModelEntry { id: string name: string @@ -20,33 +31,21 @@ interface ApiModel { } function loadModels(): ModelEntry[] { + const modelsPath = join(__dirname, "models.json") try { - const modelsPath = join(__dirname, "models.json") return JSON.parse(readFileSync(modelsPath, "utf-8")) - } catch { - return [] - } -} - -function saveModels(models: ModelEntry[]): void { - try { - const modelsPath = join(__dirname, "models.json") - const serialized = JSON.stringify(models, null, 2) + "\n" - const existing = (() => { - try { - return readFileSync(modelsPath, "utf-8") - } catch { - return null - } - })() - if (existing === serialized) return - writeFileSync(modelsPath, serialized, "utf-8") - } catch { - // silent — non-fatal + } catch (err) { + throw new Error( + `Bundled models.json missing or corrupt at ${modelsPath}; ` + + `please reinstall commandcode-go-opencode-provider.`, + { cause: err }, + ) } } async function fetchModelsFromApi(): Promise { + if (loadPluginConfig().disableModelSync) return null + const apiKey = process.env.COMMANDCODE_API_KEY if (!apiKey) return null @@ -122,7 +121,6 @@ export default async function commandcodePlugin() { const apiModels = await fetchModelsFromApi() if (apiModels && apiModels.length > 0) { models = mergeModels(models, apiModels) - saveModels(models) } const modelsObj: Record = {} for (const entry of models) { diff --git a/scripts/generate-readme.ts b/scripts/generate-readme.ts index 8be72a4..9e78573 100644 --- a/scripts/generate-readme.ts +++ b/scripts/generate-readme.ts @@ -17,7 +17,12 @@ interface ModelEntry { const models: ModelEntry[] = JSON.parse(readFileSync(MODELS_JSON, "utf-8")) -const rows = models.map((m) => { +const sortedModels = [...models].sort((a, b) => { + if (a.tier !== b.tier) return a.tier === "premium" ? -1 : 1 + return a.name.localeCompare(b.name) +}) + +const rows = sortedModels.map((m) => { const id = `\`${m.id}\`` const tier = m.tier === "premium" ? "premium" : "open-source" const ctx = m.limit.context >= 1_000_000 ? `${(m.limit.context / 1_000_000).toFixed(0)}M` : `${(m.limit.context / 1000).toFixed(0)}K` diff --git a/tests/helpers/mocks.ts b/tests/helpers/mocks.ts index 5fd4e46..e7e91ab 100644 --- a/tests/helpers/mocks.ts +++ b/tests/helpers/mocks.ts @@ -3,6 +3,10 @@ import type { LanguageModelV3CallOptions, LanguageModelV3Message, } from "@ai-sdk/provider" +import { spyOn } from "bun:test" +import * as fs from "fs" +import * as os from "os" +import { join } from "path" export type MockFetchCall = { url: string @@ -196,3 +200,49 @@ export function makeCallOptions(overrides: Partial = ...overrides, } } + +export async function withFakeConfig( + fileContent: string, + fn: (tracker: ReturnType) => Promise, +): Promise { + const tmpDir = fs.mkdtempSync(join(os.tmpdir(), "cc-test-")) + const opencodeDir = join(tmpDir, ".config", "opencode") + const homedirSpy = spyOn(os, "homedir").mockReturnValue(tmpDir) + + try { + fs.mkdirSync(opencodeDir, { recursive: true }) + fs.writeFileSync(join(opencodeDir, "commandcode-go-opencode-provider.json"), fileContent, "utf-8") + + const tracker = mockFetchTrack() + try { + return await fn(tracker) + } finally { + tracker.restore() + } + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }) + homedirSpy.mockRestore() + } +} + +export async function withMissingModels(fn: () => Promise): Promise { + const readFileSpy = spyOn(fs, "readFileSync").mockImplementation(() => { + throw new Error("ENOENT: no such file or directory, open 'models.json'") + }) + try { + return await fn() + } finally { + readFileSpy.mockRestore() + } +} + +export async function withCorruptModels(fn: () => Promise): Promise { + const readFileSpy = spyOn(fs, "readFileSync").mockImplementation(() => { + return "{ invalid json" + }) + try { + return await fn() + } finally { + readFileSpy.mockRestore() + } +} diff --git a/tests/unit/plugin.test.ts b/tests/unit/plugin.test.ts index a3ba78e..3eababe 100644 --- a/tests/unit/plugin.test.ts +++ b/tests/unit/plugin.test.ts @@ -1,8 +1,8 @@ -import { expect, test, beforeAll, beforeEach, afterEach, spyOn } from "bun:test" -import * as fs from "fs" +import { expect, test, beforeAll, beforeEach, afterEach } from "bun:test" import { mergeModels } from "../../plugin.ts" import type { ModelEntry } from "../../plugin.ts" -import { mockFetch, mockFetchTrack } from "../helpers/mocks.ts" +import { mockFetch, mockFetchTrack, withFakeConfig, withMissingModels, withCorruptModels } from "../helpers/mocks.ts" +import bundledModels from "../../models.json" with { type: "json" } type PluginResult = { config: (config: Record) => Promise @@ -181,7 +181,8 @@ test("mergeModels adds new API-only models with defaults", () => { const local = [sampleModel({ id: "test/model-a" })] const result = mergeModels(local, [{ id: "new-provider/new-model", context_length: 500000 }]) expect(result).toHaveLength(2) - const newModel = result.find((m) => m.id === "new-provider/new-model")! + const newModel = result.find((m) => m.id === "new-provider/new-model") + if (!newModel) throw new Error("expected new model to be added") expect(newModel.name).toBe("new-model") expect(newModel.tier).toBe("open-source") expect(newModel.reasoning).toBe(false) @@ -210,7 +211,7 @@ test("mergeModels preserves curated fields for local entries", () => { expect(result[0].cost.input).toBe(5) }) -// --- Config hook integration tests (with writeFileSync mocked) --- +// --- Config hook integration tests --- let originalApiKey: string | undefined @@ -224,36 +225,65 @@ afterEach(() => { }) test("config hook falls back to local when API key is missing", async () => { - const spy = spyOn(fs, "writeFileSync").mockImplementation(() => undefined) delete process.env.COMMANDCODE_API_KEY - try { - const plugin = await pluginFn() - const config: Record = { provider: { commandcode: {} } } - await plugin.config(config) + const plugin = await pluginFn() + const config: Record = { provider: { commandcode: {} } } + await plugin.config(config) - const cc = (config.provider as Record>).commandcode - const models = cc.models as Record> - expect(Object.keys(models).length).toBeGreaterThan(0) - } finally { - spy.mockRestore() - } + const cc = (config.provider as Record>).commandcode + const models = cc.models as Record> + expect(Object.keys(models).length).toBeGreaterThan(0) }) -test("config hook does not write to disk when API response unparseable", async () => { - const spy = spyOn(fs, "writeFileSync").mockImplementation(() => undefined) +test("config hook uses bundled models on API error without merging", async () => { process.env.COMMANDCODE_API_KEY = "sk-test" const fetchSpy = mockFetch({ json: () => Promise.reject(new Error("network error")) }) - try { + const plugin = await pluginFn() + const config: Record = { provider: { commandcode: {} } } + await plugin.config(config) + fetchSpy.restore() + + const cc = (config.provider as Record>).commandcode + const models = cc.models as Record> + // Bundled list should pass through unchanged when the API fails — + // no API merge should add, drop, or overwrite entries. + expect(Object.keys(models).length).toBe(bundledModels.length) + for (const entry of bundledModels) { + const slashIdx = entry.id.indexOf("/") + const key = (slashIdx >= 0 ? entry.id.slice(slashIdx + 1) : entry.id).toLowerCase() + const result = models[key] as Record | undefined + expect(result).toBeDefined() + expect(result?.name).toBe(entry.name) + expect(result?.reasoning).toBe(entry.reasoning) + expect(result?.tool_call).toBe(entry.tool_call) + expect(result?.limit).toEqual(entry.limit) + } +}) + +test("config hook throws actionable error when bundled models.json is missing", async () => { + delete process.env.COMMANDCODE_API_KEY + + await withMissingModels(async () => { const plugin = await pluginFn() const config: Record = { provider: { commandcode: {} } } - await plugin.config(config) - expect(spy).not.toHaveBeenCalled() - } finally { - spy.mockRestore() - fetchSpy.restore() - } + await expect(plugin.config(config)).rejects.toThrow( + /Bundled models.json missing or corrupt.*please reinstall commandcode-go-opencode-provider/, + ) + }) +}) + +test("config hook throws actionable error when bundled models.json is corrupt", async () => { + delete process.env.COMMANDCODE_API_KEY + + await withCorruptModels(async () => { + const plugin = await pluginFn() + const config: Record = { provider: { commandcode: {} } } + await expect(plugin.config(config)).rejects.toThrow( + /Bundled models.json missing or corrupt.*please reinstall commandcode-go-opencode-provider/, + ) + }) }) test("config hook sends auth header to API", async () => { @@ -273,3 +303,38 @@ test("config hook sends auth header to API", async () => { tracker.restore() } }) + +// --- Opt-out via config file --- + +test("config hook skips API fetch when disableModelSync is true in config file", async () => { + process.env.COMMANDCODE_API_KEY = "sk-test" + + await withFakeConfig(JSON.stringify({ disableModelSync: true }), async (tracker) => { + const plugin = await pluginFn() + const config: Record = { provider: { commandcode: {} } } + await plugin.config(config) + + expect(tracker.calls).toHaveLength(0) + + const cc = (config.provider as Record>).commandcode + const models = cc.models as Record> + expect(Object.keys(models).length).toBeGreaterThan(0) + }) +}) + +test("config hook falls through to API on corrupt config file", async () => { + process.env.COMMANDCODE_API_KEY = "sk-test" + + await withFakeConfig("{ invalid json", async (tracker) => { + const plugin = await pluginFn() + const config: Record = { provider: { commandcode: {} } } + await plugin.config(config) + + // Fetch is called with the correct auth header — proves the request + // was actually issued, not just that fetch() was attempted. + expect(tracker.calls).toHaveLength(1) + expect(tracker.calls[0].url).toBe("https://api.commandcode.ai/provider/v1/models") + const headers = tracker.calls[0].options.headers as Record + expect(headers["Authorization"]).toBe("Bearer sk-test") + }) +}) From cf25a8bc86d50c5f2062396fa357d962bced3f7d Mon Sep 17 00:00:00 2001 From: Lucas Alcantara Date: Sat, 6 Jun 2026 20:39:24 -0300 Subject: [PATCH 3/5] fix: path-aware mocks, unexport mergeModels, scoped readFileSync --- .gitignore | 2 - plugin.ts | 2 +- tests/helpers/mocks.ts | 47 +++++++++++++-- tests/unit/plugin.test.ts | 118 ++++++++++++++++++++++++-------------- 4 files changed, 117 insertions(+), 52 deletions(-) diff --git a/.gitignore b/.gitignore index d21fd3f..84186db 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,5 @@ node_modules/ dist/ *.js.map -*.bak -package-lock.json .DS_Store opencode.local.json diff --git a/plugin.ts b/plugin.ts index 8f902ae..5f22ccd 100644 --- a/plugin.ts +++ b/plugin.ts @@ -67,7 +67,7 @@ async function fetchModelsFromApi(): Promise { } } -export function mergeModels(local: ModelEntry[], api: ApiModel[]): ModelEntry[] { +function mergeModels(local: ModelEntry[], api: ApiModel[]): ModelEntry[] { const apiMap = new Map(api.map((m) => [m.id, m])) const merged: ModelEntry[] = [] diff --git a/tests/helpers/mocks.ts b/tests/helpers/mocks.ts index e7e91ab..b90138b 100644 --- a/tests/helpers/mocks.ts +++ b/tests/helpers/mocks.ts @@ -225,10 +225,38 @@ export async function withFakeConfig( } } +export async function withLocalModels( + models: Array>, + fn: () => Promise, +): Promise { + const original = fs.readFileSync.bind(fs) + const spy = spyOn(fs, "readFileSync").mockImplementation( + ((...args: Parameters) => { + const [path] = args + if (typeof path === "string" && path.endsWith("models.json")) { + return JSON.stringify(models) + } + return original(...args) + }) as typeof fs.readFileSync, + ) + try { + return await fn() + } finally { + spy.mockRestore() + } +} + export async function withMissingModels(fn: () => Promise): Promise { - const readFileSpy = spyOn(fs, "readFileSync").mockImplementation(() => { - throw new Error("ENOENT: no such file or directory, open 'models.json'") - }) + const original = fs.readFileSync.bind(fs) + const readFileSpy = spyOn(fs, "readFileSync").mockImplementation( + ((...args: Parameters) => { + const [path] = args + if (typeof path === "string" && path.endsWith("models.json")) { + throw new Error("ENOENT: no such file or directory, open 'models.json'") + } + return original(...args) + }) as typeof fs.readFileSync, + ) try { return await fn() } finally { @@ -237,9 +265,16 @@ export async function withMissingModels(fn: () => Promise): Promise { } export async function withCorruptModels(fn: () => Promise): Promise { - const readFileSpy = spyOn(fs, "readFileSync").mockImplementation(() => { - return "{ invalid json" - }) + const original = fs.readFileSync.bind(fs) + const readFileSpy = spyOn(fs, "readFileSync").mockImplementation( + ((...args: Parameters) => { + const [path] = args + if (typeof path === "string" && path.endsWith("models.json")) { + return "{ invalid json" + } + return original(...args) + }) as typeof fs.readFileSync, + ) try { return await fn() } finally { diff --git a/tests/unit/plugin.test.ts b/tests/unit/plugin.test.ts index 3eababe..4862fb1 100644 --- a/tests/unit/plugin.test.ts +++ b/tests/unit/plugin.test.ts @@ -1,7 +1,5 @@ import { expect, test, beforeAll, beforeEach, afterEach } from "bun:test" -import { mergeModels } from "../../plugin.ts" -import type { ModelEntry } from "../../plugin.ts" -import { mockFetch, mockFetchTrack, withFakeConfig, withMissingModels, withCorruptModels } from "../helpers/mocks.ts" +import { mockFetch, mockFetchTrack, withFakeConfig, withMissingModels, withCorruptModels, withLocalModels } from "../helpers/mocks.ts" import bundledModels from "../../models.json" with { type: "json" } type PluginResult = { @@ -144,9 +142,9 @@ test("config hook creates provider block if missing", async () => { expect(cc.npm).toBe("commandcode-go-opencode-provider") }) -// --- mergeModels unit tests (pure, no I/O) --- +// --- mergeModels integration tests (via config hook) --- -const sampleModel = (overrides: Partial = {}): ModelEntry => ({ +const sampleModel = (overrides: Record = {}) => ({ id: "test/model-a", name: "Model A", tier: "premium", @@ -157,58 +155,92 @@ const sampleModel = (overrides: Partial = {}): ModelEntry => ({ ...overrides, }) -test("mergeModels preserves local entry when API has no match", () => { - const local = [sampleModel()] - const result = mergeModels(local, []) - expect(result).toHaveLength(1) - expect(result[0].id).toBe("test/model-a") +async function getMergedModels( + localModels: Array>, + apiModels: Array>, +): Promise>> { + let result!: Record> + await withLocalModels(localModels, async () => { + const tracker = mockFetchTrack() + tracker.respondWith({ json: () => Promise.resolve({ data: apiModels }) }) + try { + const plugin = await pluginFn() + const config: Record = { provider: { commandcode: {} } } + await plugin.config(config) + const cc = (config.provider as Record>).commandcode + result = cc.models as Record> + } finally { + tracker.restore() + } + }) + return result +} + +test("config hook preserves local entry when API returns empty list", async () => { + process.env.COMMANDCODE_API_KEY = "sk-test" + const models = await getMergedModels([sampleModel()], []) + expect(Object.keys(models)).toHaveLength(1) + expect(models["model-a"]).toBeDefined() }) -test("mergeModels updates context from API for known models", () => { - const local = [sampleModel({ id: "test/model-a" })] - const result = mergeModels(local, [{ id: "test/model-a", context_length: 200000 }]) - expect(result[0].limit.context).toBe(200000) - expect(result[0].limit.output).toBe(8192) +test("config hook updates context from API for known models", async () => { + process.env.COMMANDCODE_API_KEY = "sk-test" + const models = await getMergedModels( + [sampleModel()], + [{ id: "test/model-a", context_length: 200000 }], + ) + expect(models["model-a"].limit.context).toBe(200000) + expect(models["model-a"].limit.output).toBe(8192) }) -test("mergeModels keeps local context when API has no context_length", () => { - const local = [sampleModel({ id: "test/model-a", limit: { context: 150000, output: 4096 } })] - const result = mergeModels(local, [{ id: "test/model-a" }]) - expect(result[0].limit.context).toBe(150000) +test("config hook keeps local context when API has no context_length", async () => { + process.env.COMMANDCODE_API_KEY = "sk-test" + const models = await getMergedModels( + [sampleModel({ limit: { context: 150000, output: 4096 } })], + [{ id: "test/model-a" }], + ) + expect(models["model-a"].limit.context).toBe(150000) }) -test("mergeModels adds new API-only models with defaults", () => { - const local = [sampleModel({ id: "test/model-a" })] - const result = mergeModels(local, [{ id: "new-provider/new-model", context_length: 500000 }]) - expect(result).toHaveLength(2) - const newModel = result.find((m) => m.id === "new-provider/new-model") - if (!newModel) throw new Error("expected new model to be added") +test("config hook adds new API-only models with defaults", async () => { + process.env.COMMANDCODE_API_KEY = "sk-test" + const models = await getMergedModels( + [sampleModel()], + [{ id: "new-provider/new-model", context_length: 500000 }], + ) + expect(Object.keys(models)).toHaveLength(2) + const newModel = models["new-model"] + expect(newModel).toBeDefined() expect(newModel.name).toBe("new-model") - expect(newModel.tier).toBe("open-source") expect(newModel.reasoning).toBe(false) expect(newModel.limit.context).toBe(500000) expect(newModel.limit.output).toBe(131072) }) -test("mergeModels uses default context when API model has none", () => { - const result = mergeModels([], [{ id: "new/model" }]) - expect(result[0].limit.context).toBe(131072) - expect(result[0].limit.output).toBe(131072) +test("config hook uses default context when API model has none", async () => { + process.env.COMMANDCODE_API_KEY = "sk-test" + const models = await getMergedModels( + [], + [{ id: "new/model" }], + ) + expect(models["model"].limit.context).toBe(131072) + expect(models["model"].limit.output).toBe(131072) }) -test("mergeModels preserves curated fields for local entries", () => { - const local = [sampleModel({ - id: "test/model-a", - name: "Custom Name", - tier: "premium", - reasoning: true, - cost: { input: 5, output: 25 }, - })] - const result = mergeModels(local, [{ id: "test/model-a", context_length: 999 }]) - expect(result[0].name).toBe("Custom Name") - expect(result[0].tier).toBe("premium") - expect(result[0].reasoning).toBe(true) - expect(result[0].cost.input).toBe(5) +test("config hook preserves curated fields for local entries", async () => { + process.env.COMMANDCODE_API_KEY = "sk-test" + const models = await getMergedModels( + [sampleModel({ + name: "Custom Name", + tier: "premium", + reasoning: true, + cost: { input: 5, output: 25 }, + })], + [{ id: "test/model-a", context_length: 999 }], + ) + expect(models["model-a"].name).toBe("Custom Name") + expect(models["model-a"].reasoning).toBe(true) + expect(models["model-a"].cost.input).toBe(5) }) // --- Config hook integration tests --- From 369297abdf9c57ae3a94940c4ba6dce17541b2d7 Mon Sep 17 00:00:00 2001 From: Kieran Roper <13541920+KRoperUK@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:12:10 +0100 Subject: [PATCH 4/5] Sync models without an API key (public endpoint) and use real API metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the auto-sync work. Three improvements to how the runtime sync discovers models: - Drop the COMMANDCODE_API_KEY requirement for the model fetch. The listing endpoint (/provider/v1/models) is public, so requiring the env var meant users who authenticate via /connect (the common case — it doesn't export the env var) got no auto-sync at all. The request now runs unauthenticated when no key is present, and still sends the key as a Bearer token when it is. - Use the display name the API returns for new API-only models, instead of deriving it from the id (e.g. "MiMo V2.5 Pro" rather than "mimo-v2.5-pro"). - Derive tier from the id namespace instead of hardcoding "open-source", and stop fabricating a 0.5/2 price for unpriced models (zeroed instead, so cost accounting isn't fed an invented rate). Tests: rework the "missing API key" case to assert the fetch still happens with no Authorization header and merges API-only models; add a test for the API-provided name. 87 pass, typecheck clean. Co-Authored-By: Claude Opus 4.8 --- README.md | 2 +- plugin.ts | 19 +++++++++++++----- tests/unit/plugin.test.ts | 42 ++++++++++++++++++++++++++++++++------- 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index a4e903f..c815033 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ COMMANDCODE_API_KEY=your-key opencode ### Disabling Auto-Sync -By default, the plugin syncs model specs from the CommandCode API on every opencode startup. The sync is best-effort and silent — if the API is unreachable, returns invalid JSON, or the `COMMANDCODE_API_KEY` is invalid, the plugin falls back to the bundled [`models.json`](./models.json). +By default, the plugin syncs model specs from the CommandCode API on every opencode startup. The model listing endpoint is public, so the sync runs even without credentials (if `COMMANDCODE_API_KEY` is set it is sent, otherwise the request is made unauthenticated). The sync is best-effort and silent — if the API is unreachable or returns invalid JSON, the plugin falls back to the bundled [`models.json`](./models.json). To disable the sync, create `~/.config/opencode/commandcode-go-opencode-provider.json`: diff --git a/plugin.ts b/plugin.ts index 5f22ccd..bf7947f 100644 --- a/plugin.ts +++ b/plugin.ts @@ -27,6 +27,7 @@ export interface ModelEntry { interface ApiModel { id: string + name?: string context_length?: number } @@ -46,14 +47,16 @@ function loadModels(): ModelEntry[] { async function fetchModelsFromApi(): Promise { if (loadPluginConfig().disableModelSync) return null + // The model listing endpoint is public, so we sync even without credentials. + // Most users authenticate via `/connect` (which doesn't export COMMANDCODE_API_KEY), + // so requiring the env var would disable auto-sync for them. Send the key only if present. const apiKey = process.env.COMMANDCODE_API_KEY - if (!apiKey) return null const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 5000) try { const resp = await fetch("https://api.commandcode.ai/provider/v1/models", { - headers: { Authorization: `Bearer ${apiKey}` }, + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, signal: controller.signal, }) @@ -84,11 +87,17 @@ function mergeModels(local: ModelEntry[], api: ApiModel[]): ModelEntry[] { for (const [id, apiModel] of apiMap) { merged.push({ id, - name: id.split("/").pop() ?? id, - tier: "open-source", + // Prefer the API-provided display name; fall back to the id's last segment. + name: apiModel.name ?? id.split("/").pop() ?? id, + // Namespaced ids (e.g. "xiaomi/...") are open-source; bare ids (claude-*, gpt-*) + // are premium. Tier maps to Command Code plan access, so guessing wrong here would + // misrepresent which plans can use the model. + tier: id.includes("/") ? "open-source" : "premium", reasoning: false, tool_call: true, - cost: { input: 0.5, output: 2 }, + // Pricing isn't exposed by the listing endpoint; leave it zeroed rather than + // inventing a rate that would feed incorrect cost accounting. + cost: { input: 0, output: 0 }, limit: { context: apiModel.context_length ?? 131072, output: 131072 }, }) } diff --git a/tests/unit/plugin.test.ts b/tests/unit/plugin.test.ts index 4862fb1..60c31bc 100644 --- a/tests/unit/plugin.test.ts +++ b/tests/unit/plugin.test.ts @@ -213,10 +213,22 @@ test("config hook adds new API-only models with defaults", async () => { expect(newModel).toBeDefined() expect(newModel.name).toBe("new-model") expect(newModel.reasoning).toBe(false) + // Pricing is unknown for API-only models, so it stays zeroed rather than fabricated. + expect((newModel.cost as Record).input).toBe(0) + expect((newModel.cost as Record).output).toBe(0) expect(newModel.limit.context).toBe(500000) expect(newModel.limit.output).toBe(131072) }) +test("config hook uses the API-provided name for new models", async () => { + process.env.COMMANDCODE_API_KEY = "sk-test" + const models = await getMergedModels( + [], + [{ id: "xiaomi/mimo-v2.5-pro", name: "MiMo V2.5 Pro", context_length: 1000000 }], + ) + expect(models["mimo-v2.5-pro"].name).toBe("MiMo V2.5 Pro") +}) + test("config hook uses default context when API model has none", async () => { process.env.COMMANDCODE_API_KEY = "sk-test" const models = await getMergedModels( @@ -256,16 +268,32 @@ afterEach(() => { else process.env.COMMANDCODE_API_KEY = originalApiKey }) -test("config hook falls back to local when API key is missing", async () => { +test("config hook syncs without an API key and sends no auth header", async () => { delete process.env.COMMANDCODE_API_KEY + const tracker = mockFetchTrack() + tracker.respondWith({ + json: () => Promise.resolve({ data: [{ id: "new/api-only", context_length: 321000 }] }), + }) - const plugin = await pluginFn() - const config: Record = { provider: { commandcode: {} } } - await plugin.config(config) + try { + const plugin = await pluginFn() + const config: Record = { provider: { commandcode: {} } } + await plugin.config(config) - const cc = (config.provider as Record>).commandcode - const models = cc.models as Record> - expect(Object.keys(models).length).toBeGreaterThan(0) + // The listing endpoint is public, so the fetch still happens — but with no token. + expect(tracker.calls).toHaveLength(1) + expect(tracker.calls[0].url).toBe("https://api.commandcode.ai/provider/v1/models") + const headers = (tracker.calls[0].options.headers ?? {}) as Record + expect(headers["Authorization"]).toBeUndefined() + + const cc = (config.provider as Record>).commandcode + const models = cc.models as Record> + expect(Object.keys(models).length).toBeGreaterThan(0) + // The API-only model was merged in despite having no credentials. + expect(models["api-only"]).toBeDefined() + } finally { + tracker.restore() + } }) test("config hook uses bundled models on API error without merging", async () => { From eeb84338683de46e6256946ccd495b29eba6801d 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 5/5] 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 | 9 +- models.json | 411 ++++++++++++++++++--------------- scripts/sync-models.ts | 170 +++++--------- tests/unit/models-json.test.ts | 36 +++ 4 files changed, 330 insertions(+), 296 deletions(-) create mode 100644 tests/unit/models-json.test.ts diff --git a/README.md b/README.md index c815033..1ed3127 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ When this file exists with `disableModelSync: true`, the plugin skips the API fe | 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 | @@ -92,17 +93,19 @@ When this file exists with `disableModelSync: true`, the plugin skips the API fe | `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 | 200K | -| `MiniMaxAI/MiniMax-M3` | MiniMax M3 | open-source | no | 1M | +| `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 | -| `xiaomi/mimo-v2.5` | Xiaomi MiMo V2.5 | open-source | no | 1M | -| `xiaomi/mimo-v2.5-pro` | Xiaomi MiMo V2.5 Pro | open-source | no | 1M | Models are automatically synced from the CommandCode API at startup. Context sizes and new models are updated on each restart. The local [`models.json`](./models.json) serves as an offline fallback. diff --git a/models.json b/models.json index d23ada5..8a138bd 100644 --- a/models.json +++ b/models.json @@ -1,182 +1,151 @@ [ { - "id": "MiniMaxAI/MiniMax-M2.5", - "name": "MiniMax M2.5", - "tier": "open-source", - "reasoning": false, - "tool_call": true, - "cost": { - "input": 0.5, - "output": 2 - }, - "limit": { - "context": 200000, - "output": 131072 - } - }, - { - "id": "MiniMaxAI/MiniMax-M2.7", - "name": "MiniMax M2.7", - "tier": "open-source", - "reasoning": false, - "tool_call": true, - "cost": { - "input": 0.3, - "output": 1.2, - "cache_read": 0.06 - }, - "limit": { - "context": 200000, - "output": 131072 - } - }, - { - "id": "MiniMaxAI/MiniMax-M3", - "name": "MiniMax M3", - "tier": "open-source", - "reasoning": false, + "id": "claude-fable-5", + "name": "Claude Fable 5", + "tier": "premium", + "reasoning": true, "tool_call": true, "cost": { - "input": 0.5, - "output": 2 + "input": 10, + "output": 50, + "cache_read": 1, + "cache_write": 12.5 }, "limit": { "context": 1000000, - "output": 131072 + "output": 65536 } }, { - "id": "Qwen/Qwen3.6-Max-Preview", - "name": "Qwen 3.6 Max Preview", - "tier": "open-source", - "reasoning": true, + "id": "claude-haiku-4-5-20251001", + "name": "Claude Haiku 4.5", + "tier": "premium", + "reasoning": false, "tool_call": true, "cost": { - "input": 1.3, - "output": 7.8, - "cache_read": 0.26, - "cache_write": 1.63 + "input": 1, + "output": 5, + "cache_read": 0.1, + "cache_write": 1.25 }, "limit": { "context": 200000, - "output": 131072 + "output": 8192 } }, { - "id": "Qwen/Qwen3.6-Plus", - "name": "Qwen 3.6 Plus", - "tier": "open-source", + "id": "claude-opus-4-7", + "name": "Claude Opus 4.7", + "tier": "premium", "reasoning": true, "tool_call": true, "cost": { - "input": 0.5, - "output": 3, - "cache_read": 0.1 + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25 }, "limit": { - "context": 200000, - "output": 131072 + "context": 1000000, + "output": 32000 } }, { - "id": "Qwen/Qwen3.7-Max", - "name": "Qwen 3.7 Max", - "tier": "open-source", + "id": "claude-opus-4-8", + "name": "Claude Opus 4.8", + "tier": "premium", "reasoning": true, "tool_call": true, "cost": { - "input": 1.25, - "output": 3.75, - "cache_read": 0.25, - "cache_write": 1.56 + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25 }, "limit": { "context": 1000000, - "output": 131072 + "output": 65536 } }, { - "id": "Qwen/Qwen3.7-Plus", - "name": "Qwen 3.7 Plus", - "tier": "open-source", + "id": "claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "tier": "premium", "reasoning": true, "tool_call": true, "cost": { - "input": 0.5, - "output": 3, - "cache_read": 0.1 + "input": 3, + "output": 15, + "cache_read": 0.3, + "cache_write": 3.75 }, "limit": { "context": 1000000, - "output": 131072 + "output": 16000 } }, { - "id": "claude-haiku-4-5-20251001", - "name": "Claude Haiku 4.5", + "id": "gpt-5.3-codex", + "name": "GPT-5.3 Codex", "tier": "premium", - "reasoning": false, + "reasoning": true, "tool_call": true, "cost": { - "input": 1, - "output": 5, - "cache_read": 0.1, - "cache_write": 1.25 + "input": 2, + "output": 8, + "cache_read": 0.5 }, "limit": { - "context": 200000, - "output": 8192 + "context": 400000, + "output": 128000 } }, { - "id": "claude-opus-4-7", - "name": "Claude Opus 4.7", + "id": "gpt-5.4", + "name": "GPT-5.4", "tier": "premium", "reasoning": true, "tool_call": true, "cost": { - "input": 5, - "output": 25, - "cache_read": 0.5, - "cache_write": 6.25 + "input": 2.5, + "output": 15, + "cache_read": 0.25 }, "limit": { - "context": 1000000, - "output": 32000 + "context": 400000, + "output": 128000 } }, { - "id": "claude-opus-4-8", - "name": "Claude Opus 4.8", + "id": "gpt-5.4-mini", + "name": "GPT-5.4 Mini", "tier": "premium", "reasoning": true, "tool_call": true, "cost": { - "input": 5, - "output": 25, - "cache_read": 0.5, - "cache_write": 6.25 + "input": 0.75, + "output": 4.5, + "cache_read": 0.075 }, "limit": { - "context": 1000000, - "output": 131072 + "context": 400000, + "output": 128000 } }, { - "id": "claude-sonnet-4-6", - "name": "Claude Sonnet 4.6", + "id": "gpt-5.5", + "name": "GPT-5.5", "tier": "premium", "reasoning": true, "tool_call": true, "cost": { - "input": 3, - "output": 15, - "cache_read": 0.3, - "cache_write": 3.75 + "input": 5, + "output": 30, + "cache_read": 0.5 }, "limit": { - "context": 1000000, - "output": 16000 + "context": 200000, + "output": 128000 } }, { @@ -244,141 +213,217 @@ } }, { - "id": "gpt-5.3-codex", - "name": "GPT-5.3 Codex", - "tier": "premium", - "reasoning": true, + "id": "zai-org/GLM-5", + "name": "GLM-5", + "tier": "open-source", + "reasoning": false, "tool_call": true, "cost": { - "input": 2, - "output": 8, - "cache_read": 0.5 + "input": 0.95, + "output": 3.15 }, "limit": { - "context": 400000, - "output": 128000 + "context": 200000, + "output": 131072 } }, { - "id": "gpt-5.4", - "name": "GPT-5.4", - "tier": "premium", + "id": "zai-org/GLM-5.1", + "name": "GLM-5.1", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "cost": { + "input": 1.4, + "output": 4.4, + "cache_read": 0.26 + }, + "limit": { + "context": 200000, + "output": 131072 + } + }, + { + "id": "moonshotai/Kimi-K2.5", + "name": "Kimi K2.5", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "cost": { + "input": 0.6, + "output": 3 + }, + "limit": { + "context": 256000, + "output": 131072 + } + }, + { + "id": "moonshotai/Kimi-K2.6", + "name": "Kimi K2.6", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "cost": { + "input": 0.95, + "output": 4, + "cache_read": 0.16 + }, + "limit": { + "context": 256000, + "output": 131072 + } + }, + { + "id": "moonshotai/Kimi-K2.7-Code", + "name": "Kimi K2.7 Code", + "tier": "open-source", "reasoning": true, "tool_call": true, "cost": { - "input": 2.5, - "output": 15, - "cache_read": 0.25 + "input": 0, + "output": 0 }, "limit": { - "context": 400000, - "output": 128000 + "context": 256000, + "output": 65536 } }, { - "id": "gpt-5.4-mini", - "name": "GPT-5.4 Mini", - "tier": "premium", + "id": "xiaomi/mimo-v2.5", + "name": "MiMo V2.5", + "tier": "open-source", "reasoning": true, "tool_call": true, "cost": { - "input": 0.75, - "output": 4.5, - "cache_read": 0.075 + "input": 0, + "output": 0 }, "limit": { - "context": 400000, - "output": 128000 + "context": 1000000, + "output": 65536 } }, { - "id": "gpt-5.5", - "name": "GPT-5.5", - "tier": "premium", + "id": "xiaomi/mimo-v2.5-pro", + "name": "MiMo V2.5 Pro", + "tier": "open-source", "reasoning": true, "tool_call": true, "cost": { - "input": 5, - "output": 30, - "cache_read": 0.5 + "input": 0, + "output": 0 }, "limit": { - "context": 200000, - "output": 128000 + "context": 1000000, + "output": 65536 } }, { - "id": "moonshotai/Kimi-K2.5", - "name": "Kimi K2.5", + "id": "MiniMaxAI/MiniMax-M2.5", + "name": "MiniMax M2.5", "tier": "open-source", "reasoning": false, "tool_call": true, "cost": { - "input": 0.6, - "output": 3 + "input": 0.5, + "output": 2 }, "limit": { - "context": 256000, + "context": 200000, "output": 131072 } }, { - "id": "moonshotai/Kimi-K2.6", - "name": "Kimi K2.6", + "id": "MiniMaxAI/MiniMax-M2.7", + "name": "MiniMax M2.7", "tier": "open-source", "reasoning": false, "tool_call": true, "cost": { - "input": 0.95, - "output": 4, - "cache_read": 0.16 + "input": 0.3, + "output": 1.2, + "cache_read": 0.06 }, "limit": { - "context": 256000, + "context": 200000, "output": 131072 } }, { - "id": "stepfun/Step-3.5-Flash", - "name": "Step 3.5 Flash", + "id": "MiniMaxAI/MiniMax-M3", + "name": "MiniMax M3", "tier": "open-source", "reasoning": true, "tool_call": true, "cost": { - "input": 0.1, - "output": 0.3, - "cache_read": 0.02 + "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", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "cost": { + "input": 1.3, + "output": 7.8, + "cache_read": 0.26, + "cache_write": 1.63 + }, + "limit": { + "context": 200000, "output": 131072 } }, { - "id": "stepfun/Step-3.7-Flash", - "name": "Step 3.7 Flash", + "id": "Qwen/Qwen3.6-Plus", + "name": "Qwen 3.6 Plus", "tier": "open-source", "reasoning": true, "tool_call": true, "cost": { - "input": 0.1, - "output": 0.3, - "cache_read": 0.02 + "input": 0.5, + "output": 3, + "cache_read": 0.1 }, "limit": { - "context": 256000, + "context": 200000, "output": 131072 } }, { - "id": "xiaomi/mimo-v2.5", - "name": "Xiaomi MiMo V2.5", + "id": "Qwen/Qwen3.7-Max", + "name": "Qwen 3.7 Max", "tier": "open-source", - "reasoning": false, + "reasoning": true, "tool_call": true, "cost": { - "input": 0.25, - "output": 1 + "input": 1.25, + "output": 3.75, + "cache_read": 0.25, + "cache_write": 1.56 }, "limit": { "context": 1000000, @@ -386,49 +431,49 @@ } }, { - "id": "xiaomi/mimo-v2.5-pro", - "name": "Xiaomi MiMo V2.5 Pro", + "id": "Qwen/Qwen3.7-Plus", + "name": "Qwen 3.7 Plus", "tier": "open-source", - "reasoning": false, + "reasoning": true, "tool_call": true, "cost": { - "input": 0.5, - "output": 2 + "input": 0, + "output": 0 }, "limit": { "context": 1000000, - "output": 131072 + "output": 65536 } }, { - "id": "zai-org/GLM-5", - "name": "GLM-5", + "id": "stepfun/Step-3.5-Flash", + "name": "Step 3.5 Flash", "tier": "open-source", - "reasoning": false, + "reasoning": true, "tool_call": true, "cost": { - "input": 0.95, - "output": 3.15 + "input": 0.1, + "output": 0.3, + "cache_read": 0.02 }, "limit": { - "context": 200000, + "context": 1000000, "output": 131072 } }, { - "id": "zai-org/GLM-5.1", - "name": "GLM-5.1", + "id": "stepfun/Step-3.7-Flash", + "name": "Step 3.7 Flash", "tier": "open-source", - "reasoning": false, + "reasoning": true, "tool_call": true, "cost": { - "input": 1.4, - "output": 4.4, - "cache_read": 0.26 + "input": 0, + "output": 0 }, "limit": { - "context": 200000, - "output": 131072 + "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) +})