diff --git a/README.md b/README.md index cd60840..c815033 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 @@ -56,17 +56,34 @@ 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 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`: + +```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 | |---|---|---|---|---| | `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 +93,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 +131,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..bf7947f 100644 --- a/plugin.ts +++ b/plugin.ts @@ -1,10 +1,21 @@ -import { readFileSync } 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)) -interface ModelEntry { +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 tier: "premium" | "open-source" @@ -14,9 +25,84 @@ interface ModelEntry { limit: { context: number; output: number } } +interface ApiModel { + id: string + name?: string + context_length?: number +} + function loadModels(): ModelEntry[] { const modelsPath = join(__dirname, "models.json") - return JSON.parse(readFileSync(modelsPath, "utf-8")) + try { + return JSON.parse(readFileSync(modelsPath, "utf-8")) + } 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 + + // 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 + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 5000) + try { + const resp = await fetch("https://api.commandcode.ai/provider/v1/models", { + headers: apiKey ? { 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) + } +} + +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, + // 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, + // 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 }, + }) + } + + return merged } function toConfigKey(id: string): string { @@ -40,7 +126,11 @@ 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) + } 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..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` @@ -31,7 +36,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/helpers/mocks.ts b/tests/helpers/mocks.ts index 5fd4e46..b90138b 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,84 @@ 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 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 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 { + readFileSpy.mockRestore() + } +} + +export async function withCorruptModels(fn: () => Promise): Promise { + 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 { + readFileSpy.mockRestore() + } +} diff --git a/tests/unit/plugin.test.ts b/tests/unit/plugin.test.ts index d753d52..60c31bc 100644 --- a/tests/unit/plugin.test.ts +++ b/tests/unit/plugin.test.ts @@ -1,4 +1,6 @@ -import { expect, test, beforeAll } from "bun:test" +import { expect, test, beforeAll, beforeEach, afterEach } from "bun:test" +import { mockFetch, mockFetchTrack, withFakeConfig, withMissingModels, withCorruptModels, withLocalModels } from "../helpers/mocks.ts" +import bundledModels from "../../models.json" with { type: "json" } type PluginResult = { config: (config: Record) => Promise @@ -139,3 +141,260 @@ test("config hook creates provider block if missing", async () => { expect(cc).toBeDefined() expect(cc.npm).toBe("commandcode-go-opencode-provider") }) + +// --- mergeModels integration tests (via config hook) --- + +const sampleModel = (overrides: Record = {}) => ({ + 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, +}) + +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("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("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("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.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( + [], + [{ id: "new/model" }], + ) + expect(models["model"].limit.context).toBe(131072) + expect(models["model"].limit.output).toBe(131072) +}) + +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 --- + +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 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 }] }), + }) + + try { + const plugin = await pluginFn() + const config: Record = { provider: { commandcode: {} } } + await plugin.config(config) + + // 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 () => { + process.env.COMMANDCODE_API_KEY = "sk-test" + const fetchSpy = mockFetch({ json: () => Promise.reject(new Error("network error")) }) + + 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 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 () => { + 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() + } +}) + +// --- 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") + }) +})