diff --git a/README.md b/README.md index cd60840..1ed3127 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,35 @@ 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-fable-5` | Claude Fable 5 | premium | yes | 1M | | `claude-haiku-4-5-20251001` | Claude Haiku 4.5 | premium | no | 200K | | `claude-opus-4-7` | Claude Opus 4.7 | premium | yes | 1M | +| `claude-opus-4-8` | Claude Opus 4.8 | premium | yes | 1M | | `claude-sonnet-4-6` | Claude Sonnet 4.6 | premium | yes | 1M | | `gpt-5.3-codex` | GPT-5.3 Codex | premium | yes | 400K | | `gpt-5.4` | GPT-5.4 | premium | yes | 400K | | `gpt-5.4-mini` | GPT-5.4 Mini | premium | yes | 400K | -| `gpt-5.5` | GPT-5.5 | premium | yes | 256K | +| `gpt-5.5` | GPT-5.5 | premium | yes | 200K | | `deepseek/deepseek-v4-flash` | DeepSeek V4 Flash | open-source | yes | 1M | | `deepseek/deepseek-v4-pro` | DeepSeek V4 Pro | open-source | yes | 1M | | `google/gemini-3.1-flash-lite` | Gemini 3.1 Flash Lite | open-source | yes | 1M | @@ -75,14 +93,21 @@ COMMANDCODE_API_KEY=your-key opencode | `zai-org/GLM-5.1` | GLM-5.1 | open-source | no | 200K | | `moonshotai/Kimi-K2.5` | Kimi K2.5 | open-source | no | 256K | | `moonshotai/Kimi-K2.6` | Kimi K2.6 | open-source | no | 256K | +| `moonshotai/Kimi-K2.7-Code` | Kimi K2.7 Code | open-source | yes | 256K | +| `xiaomi/mimo-v2.5` | MiMo V2.5 | open-source | yes | 1M | +| `xiaomi/mimo-v2.5-pro` | MiMo V2.5 Pro | open-source | yes | 1M | | `MiniMaxAI/MiniMax-M2.5` | MiniMax M2.5 | open-source | no | 200K | -| `MiniMaxAI/MiniMax-M2.7` | MiniMax M2.7 | open-source | no | 1M | -| `Qwen/Qwen3.6-Max-Preview` | Qwen 3.6 Max Preview | open-source | yes | 1M | -| `Qwen/Qwen3.6-Plus` | Qwen 3.6 Plus | open-source | yes | 1M | +| `MiniMaxAI/MiniMax-M2.7` | MiniMax M2.7 | open-source | no | 200K | +| `MiniMaxAI/MiniMax-M3` | MiniMax M3 | open-source | yes | 1M | +| `nvidia/nemotron-3-ultra-550b-a55b` | Nemotron 3 Ultra | open-source | yes | 1M | +| `Qwen/Qwen3.6-Max-Preview` | Qwen 3.6 Max Preview | open-source | yes | 200K | +| `Qwen/Qwen3.6-Plus` | Qwen 3.6 Plus | open-source | yes | 200K | | `Qwen/Qwen3.7-Max` | Qwen 3.7 Max | open-source | yes | 1M | +| `Qwen/Qwen3.7-Plus` | Qwen 3.7 Plus | open-source | yes | 1M | | `stepfun/Step-3.5-Flash` | Step 3.5 Flash | open-source | yes | 1M | +| `stepfun/Step-3.7-Flash` | Step 3.7 Flash | open-source | yes | 256K | -Full model list is maintained in [`models.json`](./models.json). Run `bun run sync` to refresh from the latest Command Code CLI release on npm. +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 +134,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..8a138bd 100644 --- a/models.json +++ b/models.json @@ -1,4 +1,21 @@ [ + { + "id": "claude-fable-5", + "name": "Claude Fable 5", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "cost": { + "input": 10, + "output": 50, + "cache_read": 1, + "cache_write": 12.5 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, { "id": "claude-haiku-4-5-20251001", "name": "Claude Haiku 4.5", @@ -33,6 +50,23 @@ "output": 32000 } }, + { + "id": "claude-opus-4-8", + "name": "Claude Opus 4.8", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "cost": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, { "id": "claude-sonnet-4-6", "name": "Claude Sonnet 4.6", @@ -110,7 +144,7 @@ "cache_read": 0.5 }, "limit": { - "context": 256000, + "context": 200000, "output": 128000 } }, @@ -240,6 +274,51 @@ "output": 131072 } }, + { + "id": "moonshotai/Kimi-K2.7-Code", + "name": "Kimi K2.7 Code", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "cost": { + "input": 0, + "output": 0 + }, + "limit": { + "context": 256000, + "output": 65536 + } + }, + { + "id": "xiaomi/mimo-v2.5", + "name": "MiMo V2.5", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "cost": { + "input": 0, + "output": 0 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, + { + "id": "xiaomi/mimo-v2.5-pro", + "name": "MiMo V2.5 Pro", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "cost": { + "input": 0, + "output": 0 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, { "id": "MiniMaxAI/MiniMax-M2.5", "name": "MiniMax M2.5", @@ -267,10 +346,40 @@ "cache_read": 0.06 }, "limit": { - "context": 1000000, + "context": 200000, "output": 131072 } }, + { + "id": "MiniMaxAI/MiniMax-M3", + "name": "MiniMax M3", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "cost": { + "input": 0, + "output": 0 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, + { + "id": "nvidia/nemotron-3-ultra-550b-a55b", + "name": "Nemotron 3 Ultra", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "cost": { + "input": 0, + "output": 0 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, { "id": "Qwen/Qwen3.6-Max-Preview", "name": "Qwen 3.6 Max Preview", @@ -284,7 +393,7 @@ "cache_write": 1.63 }, "limit": { - "context": 1000000, + "context": 200000, "output": 131072 } }, @@ -300,7 +409,7 @@ "cache_read": 0.1 }, "limit": { - "context": 1000000, + "context": 200000, "output": 131072 } }, @@ -321,6 +430,21 @@ "output": 131072 } }, + { + "id": "Qwen/Qwen3.7-Plus", + "name": "Qwen 3.7 Plus", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "cost": { + "input": 0, + "output": 0 + }, + "limit": { + "context": 1000000, + "output": 65536 + } + }, { "id": "stepfun/Step-3.5-Flash", "name": "Step 3.5 Flash", @@ -336,5 +460,20 @@ "context": 1000000, "output": 131072 } + }, + { + "id": "stepfun/Step-3.7-Flash", + "name": "Step 3.7 Flash", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "cost": { + "input": 0, + "output": 0 + }, + "limit": { + "context": 256000, + "output": 65536 + } } ] diff --git a/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/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/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/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) +}) 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") + }) +})