diff --git a/README.md b/README.md index 442bd0b..cffca47 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,17 @@ A [pi](https://github.com/badlogic/pi-mono) custom provider that connects pi to > **Note:** This package only provides a model _provider_. It does **not** include an API key. You must bring your own Command Code API key or subscription. -> 💰 **Current offer:** Command Code offers [4× usage of DeepSeek V4](https://commandcode.ai/docs/resources/pricing-limits#deepseek-v4-pro-4x-usage) (Pro and Flash) at no extra cost. +> 💰 **Current offers:** Command Code offers [4× usage of DeepSeek V4 Pro](https://commandcode.ai/docs/resources/pricing-limits#deepseek-v4-pro-4x-usage) and [2× usage of Qwen 3.7 Max](https://commandcode.ai/docs/resources/pricing-limits#qwen-3.7-max-2x-usage). ## Models -18 models across premium and open-source providers: +Models are fetched live from Command Code's Provider API at startup, so new models like Qwen 3.7 Max show up without a package release. -| Category | Models | -| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| **Anthropic** | Claude Opus 4.7, Claude Opus 4.6, Claude Sonnet 4.6, Claude Haiku 4.5 | -| **OpenAI** | GPT-5.5, GPT-5.4, GPT-5.3 Codex, GPT-5.4 Mini | -| **Open-source** | DeepSeek V4, DeepSeek V4 Pro, DeepSeek V4 Flash, Kimi K2.6, Kimi K2.5, GLM-5.1, GLM-5, MiniMax M2.7, MiniMax M2.5, Qwen 3.6 Max, Qwen 3.6 Plus | +You can list the current Command Code models with: + +```sh +pi -e index.ts --list-models +``` ## Install @@ -88,40 +88,21 @@ After installing and setting your API key, select a Command Code model in pi: /model deepseek/deepseek-v4-flash ``` -Any query will then use the Command Code API. You can list available models: - -```sh -pi -e index.ts --list-models -``` - -Or within pi: +Any query will then use the Command Code API. You can list available models within pi: ```txt /models ``` -## Update models +## Model discovery -The model list (`models.json`) is extracted from the [command-code](https://www.npmjs.com/package/command-code) npm package's dist file. When Command Code releases a new version with updated models, regenerate it: +On startup, the provider fetches: -```sh -npm run extract-models +```txt +https://api.commandcode.ai/provider/v1/models ``` -This runs `scripts/extract-models.ts`, which: - -1. Downloads the latest `command-code` tarball from npm (`npm pack command-code`) -2. Parses the minified `dist/index.mjs` to extract provider definitions, model metadata, and pricing -3. Fills in `contextWindow` and `maxOutputTokens` with sensible defaults where the CLI omits them -4. Writes the result to `models.json` - -To use a specific version or local dist file: - -```sh -npx tsx scripts/extract-models.ts /path/to/command-code/dist/index.mjs -``` - -`models.json` is committed to the repo and included in the npm package. +For tests or local mocks, override it with `COMMANDCODE_MODELS_URL`. ## Publish diff --git a/index.ts b/index.ts index 24a6b03..917f268 100644 --- a/index.ts +++ b/index.ts @@ -9,106 +9,32 @@ * 3. Place API key in `~/.commandcode/auth.json` or `~/.pi/agent/auth.json` * as {"apiKey": "user_..."} or {"commandcode": "user_..."} * - * Models are sourced from models.json, which is extracted from the command-code - * npm package dist file. Run `npx tsx scripts/extract-models.ts` to refresh. + * Models are fetched from Command Code's Provider API at startup. */ -import { readFileSync } from "node:fs"; +import { calculateCost, createAssistantMessageEventStream } from "@mariozechner/pi-ai" +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent" -import { calculateCost, createAssistantMessageEventStream } from "@mariozechner/pi-ai"; -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts" +import { DEFAULT_MODELS_URL, fetchCommandCodeModels } from "./src/models.ts" +import { getApiKey, login, refreshToken } from "./src/oauth.ts" -import { createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts"; -import { getApiKey, login, refreshToken } from "./src/oauth.ts"; - -const API_BASE = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE; - -// --------------------------------------------------------------------------- -// Load model definitions from models.json -// --------------------------------------------------------------------------- - -interface ModelsJson { - providers: Record; - models: Array<{ - key: string; - id: string; - provider: string; - spec: string; - label: string; - name: string; - description: string; - reasoning: boolean; - reasoningEfforts: string[] | null; - contextWindow: number; - maxOutputTokens: number; - vendorLabel: string | null; - }>; - pricing: Array<{ - provider: string; - id: string; - category: string; - promptCost: number; - completionCost: number; - cacheWrite5mCost: number; - cacheWrite1hCost: number; - cacheHitCost: number; - }>; -} - -const modelsJson: ModelsJson = JSON.parse( - readFileSync(new URL("./models.json", import.meta.url), "utf8"), -); - -// --------------------------------------------------------------------------- -// Build cost lookup (model id -> pricing) -// --------------------------------------------------------------------------- - -const costByModelId = new Map(); -for (const p of modelsJson.pricing) { - // Pricing id is like "anthropic:claude-sonnet-4-6" - const colonIdx = p.id.indexOf(":"); - if (colonIdx > 0) { - costByModelId.set(p.id.substring(colonIdx + 1), p); - } - costByModelId.set(p.id, p); -} - -// --------------------------------------------------------------------------- -// Build pi model list (all defaults come from models.json) -// --------------------------------------------------------------------------- - -const MODELS = modelsJson.models.map((m) => { - const cost = costByModelId.get(m.id); - return { - id: m.id, - name: `${m.name} (CC)`, - reasoning: m.reasoning, - contextWindow: m.contextWindow, - maxTokens: m.maxOutputTokens, - cost: { - input: cost?.promptCost ?? 0, - output: cost?.completionCost ?? 0, - cacheRead: cost?.cacheHitCost ?? 0, - cacheWrite: Math.max(cost?.cacheWrite5mCost ?? 0, cost?.cacheWrite1hCost ?? 0), - }, - }; -}); - -// --------------------------------------------------------------------------- -// Stream factory -// --------------------------------------------------------------------------- +const API_BASE = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE +const MODELS_URL = process.env.COMMANDCODE_MODELS_URL ?? DEFAULT_MODELS_URL const streamCommandCode = createStreamCommandCode({ createStream: createAssistantMessageEventStream, calculateCost, apiBase: API_BASE, -}); +}) // --------------------------------------------------------------------------- // Extension entry point // --------------------------------------------------------------------------- -export default function (pi: ExtensionAPI) { +export default async function (pi: ExtensionAPI) { + const models = await fetchCommandCodeModels({ url: MODELS_URL }) + pi.registerProvider("commandcode", { name: "Command Code", baseUrl: API_BASE, @@ -126,14 +52,14 @@ export default function (pi: ExtensionAPI) { refreshToken, getApiKey, }, - models: MODELS.map((model) => ({ + models: models.map((model) => ({ id: model.id, name: model.name, reasoning: model.reasoning, input: ["text"] as const, - cost: model.cost, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: model.contextWindow, maxTokens: model.maxTokens, })), - }); + }) } diff --git a/models.json b/models.json deleted file mode 100644 index 21bab17..0000000 --- a/models.json +++ /dev/null @@ -1,545 +0,0 @@ -{ - "providers": { - "ANTHROPIC": "anthropic", - "OPENAI": "openai", - "BASETEN": "baseten", - "VERCEL_AI_GATEWAY": "vercel-ai-gateway", - "CLOUDFLARE_AI_GATEWAY": "cloudflare-ai-gateway", - "OPENROUTER": "openrouter" - }, - "providerGroups": [ - { - "id": "command-code", - "label": "Command Code", - "shortLabel": "cmd", - "description": "recommended", - "providers": [ - "anthropic", - "openai", - "baseten", - "vercel-ai-gateway" - ] - }, - { - "id": "anthropic", - "label": "Anthropic", - "shortLabel": "anth", - "description": "Claude Pro/Max", - "providers": [ - "anthropic" - ] - }, - { - "id": "github-copilot", - "label": "GitHub Copilot", - "shortLabel": "copilot", - "description": "Copilot subscription", - "providers": [ - "anthropic", - "openai" - ] - }, - { - "id": "codex", - "label": "ChatGPT (Codex)", - "shortLabel": "codex", - "description": "ChatGPT Pro/Plus subscription", - "providers": [ - "openai" - ] - } - ], - "models": [ - { - "key": "SONNET_4_6", - "id": "claude-sonnet-4-6", - "provider": "anthropic", - "spec": "chatComplete", - "label": "Claude Sonnet 4.6", - "name": "Claude Sonnet 4.6", - "description": "best combo of speed & intelligence (recommended)", - "reasoning": true, - "reasoningEfforts": [ - "low", - "medium", - "high", - "xhigh", - "max" - ], - "contextWindow": 1000000, - "maxOutputTokens": 64000, - "vendorLabel": null - }, - { - "key": "OPUS_4_7", - "id": "claude-opus-4-7", - "provider": "anthropic", - "spec": "chatComplete", - "label": "Claude Opus 4.7", - "name": "Claude Opus 4.7", - "description": "most intelligent for agents and coding", - "reasoning": true, - "reasoningEfforts": [ - "low", - "medium", - "high", - "xhigh", - "max" - ], - "contextWindow": 1000000, - "maxOutputTokens": 64000, - "vendorLabel": null - }, - { - "key": "HAIKU_4_5", - "id": "claude-haiku-4-5-20251001", - "provider": "anthropic", - "spec": "chatComplete", - "label": "Claude Haiku 4.5", - "name": "Claude Haiku 4.5", - "description": "fastest & most compact, great for quick tasks", - "reasoning": false, - "reasoningEfforts": null, - "contextWindow": 200000, - "maxOutputTokens": 64000, - "vendorLabel": null - }, - { - "key": "GPT_5_5", - "id": "gpt-5.5", - "provider": "openai", - "spec": "responses", - "label": "GPT-5.5", - "name": "GPT-5.5", - "description": "latest frontier model for general complex work", - "reasoning": true, - "reasoningEfforts": [ - "low", - "medium", - "high", - "xhigh" - ], - "contextWindow": 256000, - "maxOutputTokens": 128000, - "vendorLabel": null - }, - { - "key": "GPT_5_4", - "id": "gpt-5.4", - "provider": "openai", - "spec": "responses", - "label": "GPT-5.4", - "name": "GPT-5.4", - "description": "frontier model for general complex work", - "reasoning": true, - "reasoningEfforts": [ - "low", - "medium", - "high", - "xhigh" - ], - "contextWindow": 400000, - "maxOutputTokens": 128000, - "vendorLabel": null - }, - { - "key": "GPT_5_3_CODEX", - "id": "gpt-5.3-codex", - "provider": "openai", - "spec": "responses", - "label": "GPT-5.3 Codex", - "name": "GPT-5.3 Codex", - "description": "frontier coding model", - "reasoning": true, - "reasoningEfforts": [ - "low", - "medium", - "high", - "xhigh" - ], - "contextWindow": 400000, - "maxOutputTokens": 128000, - "vendorLabel": null - }, - { - "key": "GPT_5_4_MINI", - "id": "gpt-5.4-mini", - "provider": "openai", - "spec": "responses", - "label": "GPT-5.4 Mini", - "name": "GPT-5.4 Mini", - "description": "fast, cost-effective model for everyday tasks", - "reasoning": true, - "reasoningEfforts": [ - "low", - "medium", - "high" - ], - "contextWindow": 400000, - "maxOutputTokens": 128000, - "vendorLabel": null - }, - { - "key": "KIMI_K2_6", - "id": "moonshotai/Kimi-K2.6", - "provider": "vercel-ai-gateway", - "spec": "chatComplete", - "label": "Kimi K2.6", - "name": "Kimi K2.6", - "description": "long-horizon coding with vision", - "reasoning": false, - "reasoningEfforts": null, - "contextWindow": 256000, - "maxOutputTokens": 65536, - "vendorLabel": null - }, - { - "key": "KIMI_K2_5", - "id": "moonshotai/Kimi-K2.5", - "provider": "vercel-ai-gateway", - "spec": "chatComplete", - "label": "Kimi K2.5", - "name": "Kimi K2.5", - "description": "multimodal frontend coding", - "reasoning": false, - "reasoningEfforts": null, - "contextWindow": 256000, - "maxOutputTokens": 65536, - "vendorLabel": null - }, - { - "key": "GLM_5_1", - "id": "zai-org/GLM-5.1", - "provider": "vercel-ai-gateway", - "spec": "chatComplete", - "label": "GLM-5.1", - "name": "GLM-5.1", - "description": "long-horizon autonomous coding agent", - "reasoning": false, - "reasoningEfforts": null, - "contextWindow": 200000, - "maxOutputTokens": 65536, - "vendorLabel": null - }, - { - "key": "GLM_5", - "id": "zai-org/GLM-5", - "provider": "vercel-ai-gateway", - "spec": "chatComplete", - "label": "GLM-5", - "name": "GLM-5", - "description": "multi-mode thinking & long-range planning", - "reasoning": false, - "reasoningEfforts": null, - "contextWindow": 200000, - "maxOutputTokens": 65536, - "vendorLabel": null - }, - { - "key": "MINIMAX_M2_7", - "id": "MiniMaxAI/MiniMax-M2.7", - "provider": "vercel-ai-gateway", - "spec": "chatComplete", - "label": "MiniMax M2.7", - "name": "MiniMax M2.7", - "description": "end-to-end software engineering agent", - "reasoning": false, - "reasoningEfforts": null, - "contextWindow": 1048576, - "maxOutputTokens": 65536, - "vendorLabel": null - }, - { - "key": "MINIMAX_M2_5", - "id": "MiniMaxAI/MiniMax-M2.5", - "provider": "vercel-ai-gateway", - "spec": "chatComplete", - "label": "MiniMax M2.5", - "name": "MiniMax M2.5", - "description": "cross-platform full-stack agentic dev", - "reasoning": false, - "reasoningEfforts": null, - "contextWindow": 200000, - "maxOutputTokens": 65536, - "vendorLabel": null - }, - { - "key": "DEEPSEEK_V4_PRO", - "id": "deepseek/deepseek-v4-pro", - "provider": "vercel-ai-gateway", - "spec": "chatComplete", - "label": "DeepSeek V4 Pro", - "name": "DeepSeek V4 Pro", - "description": "hybrid-attention long-context reasoning", - "reasoning": true, - "reasoningEfforts": [ - "high", - "max" - ], - "contextWindow": 1000000, - "maxOutputTokens": 384000, - "vendorLabel": null - }, - { - "key": "DEEPSEEK_V4_FLASH", - "id": "deepseek/deepseek-v4-flash", - "provider": "vercel-ai-gateway", - "spec": "chatComplete", - "label": "DeepSeek V4 Flash", - "name": "DeepSeek V4 Flash", - "description": "fast hybrid-attention reasoning", - "reasoning": true, - "reasoningEfforts": [ - "high", - "max" - ], - "contextWindow": 1000000, - "maxOutputTokens": 384000, - "vendorLabel": null - }, - { - "key": "QWEN_3_6_MAX_PREVIEW", - "id": "Qwen/Qwen3.6-Max-Preview", - "provider": "vercel-ai-gateway", - "spec": "chatComplete", - "label": "Qwen 3.6 Max Preview", - "name": "Qwen 3.6 Max Preview", - "description": "vibe coding & efficient agent execution", - "reasoning": true, - "reasoningEfforts": null, - "contextWindow": 1000000, - "maxOutputTokens": 65536, - "vendorLabel": null - }, - { - "key": "QWEN_3_6_PLUS", - "id": "Qwen/Qwen3.6-Plus", - "provider": "vercel-ai-gateway", - "spec": "chatComplete", - "label": "Qwen 3.6 Plus", - "name": "Qwen 3.6 Plus", - "description": "agentic coding & reasoning", - "reasoning": true, - "reasoningEfforts": null, - "contextWindow": 1000000, - "maxOutputTokens": 65536, - "vendorLabel": null - }, - { - "key": "QWEN_3_7_MAX", - "id": "Qwen/Qwen3.7-Max", - "provider": "vercel-ai-gateway", - "spec": "chatComplete", - "label": "Qwen 3.7 Max", - "name": "Qwen 3.7 Max", - "description": "frontier coding & long-horizon agent execution", - "reasoning": true, - "reasoningEfforts": null, - "contextWindow": 1000000, - "maxOutputTokens": 65536, - "vendorLabel": null - }, - { - "key": "STEP_3_5_FLASH", - "id": "stepfun/Step-3.5-Flash", - "provider": "vercel-ai-gateway", - "spec": "chatComplete", - "label": "Step 3.5 Flash", - "name": "Step 3.5 Flash", - "description": "fast sparse-MoE agentic reasoning", - "reasoning": true, - "reasoningEfforts": null, - "contextWindow": 1000000, - "maxOutputTokens": 65536, - "vendorLabel": null - }, - { - "key": "GEMINI_3_5_FLASH", - "id": "google/gemini-3.5-flash", - "provider": "vercel-ai-gateway", - "spec": "chatComplete", - "label": "Gemini 3.5 Flash", - "name": "Gemini 3.5 Flash", - "description": "Pro-level coding proficiency, parallel agentic execution", - "reasoning": true, - "reasoningEfforts": [ - "low", - "medium", - "high" - ], - "contextWindow": 1000000, - "maxOutputTokens": 65536, - "vendorLabel": "Google" - }, - { - "key": "GEMINI_3_1_FLASH_LITE", - "id": "google/gemini-3.1-flash-lite", - "provider": "vercel-ai-gateway", - "spec": "chatComplete", - "label": "Gemini 3.1 Flash Lite", - "name": "Gemini 3.1 Flash Lite", - "description": "high-volume workhorse model with implicit caching", - "reasoning": true, - "reasoningEfforts": [ - "low", - "medium", - "high" - ], - "contextWindow": 1000000, - "maxOutputTokens": 65536, - "vendorLabel": "Google" - } - ], - "pricing": [ - { - "provider": "Anthropic", - "id": "anthropic:claude-sonnet-4-20250514", - "category": "premium", - "promptCost": 3, - "completionCost": 15, - "cacheWrite5mCost": 3.75, - "cacheWrite1hCost": 6, - "cacheHitCost": 0.3 - }, - { - "provider": "Anthropic", - "id": "anthropic:claude-sonnet-4-5-20250929", - "category": "premium", - "promptCost": 3, - "completionCost": 15, - "cacheWrite5mCost": 3.75, - "cacheWrite1hCost": 6, - "cacheHitCost": 0.3 - }, - { - "provider": "Anthropic", - "id": "anthropic:claude-opus-4-5-20251101", - "category": "premium", - "promptCost": 5, - "completionCost": 25, - "cacheWrite5mCost": 6.25, - "cacheWrite1hCost": 10, - "cacheHitCost": 0.5 - }, - { - "provider": "Anthropic", - "id": "anthropic:claude-sonnet-4-6", - "category": "premium", - "promptCost": 3, - "completionCost": 15, - "cacheWrite5mCost": 3.75, - "cacheWrite1hCost": 6, - "cacheHitCost": 0.3 - }, - { - "provider": "Anthropic", - "id": "anthropic:claude-opus-4-7", - "category": "premium", - "promptCost": 5, - "completionCost": 25, - "cacheWrite5mCost": 6.25, - "cacheWrite1hCost": 10, - "cacheHitCost": 0.5 - }, - { - "provider": "Anthropic", - "id": "anthropic:claude-opus-4-6", - "category": "premium", - "promptCost": 5, - "completionCost": 25, - "cacheWrite5mCost": 6.25, - "cacheWrite1hCost": 10, - "cacheHitCost": 0.5 - }, - { - "provider": "Anthropic", - "id": "anthropic:claude-haiku-4-5-20251001", - "category": "premium", - "promptCost": 1, - "completionCost": 5, - "cacheWrite5mCost": 1.25, - "cacheWrite1hCost": 2, - "cacheHitCost": 0.1 - }, - { - "provider": "OpenAI", - "id": "openai:gpt-5.5", - "category": "premium", - "promptCost": 5, - "completionCost": 30, - "cacheWrite5mCost": 0, - "cacheWrite1hCost": 0, - "cacheHitCost": 0.5 - }, - { - "provider": "OpenAI", - "id": "openai:gpt-5.4", - "category": "premium", - "promptCost": 2.5, - "completionCost": 15, - "cacheWrite5mCost": 0, - "cacheWrite1hCost": 0, - "cacheHitCost": 0.25 - }, - { - "provider": "OpenAI", - "id": "openai:gpt-5.3-codex", - "category": "premium", - "promptCost": 2, - "completionCost": 8, - "cacheWrite5mCost": 0, - "cacheWrite1hCost": 0, - "cacheHitCost": 0.5 - }, - { - "provider": "OpenAI", - "id": "openai:gpt-5.4-mini", - "category": "premium", - "promptCost": 0.75, - "completionCost": 4.5, - "cacheWrite5mCost": 0, - "cacheWrite1hCost": 0, - "cacheHitCost": 0.075 - }, - { - "provider": "Baseten", - "id": "baseten:zai-org/GLM-5", - "category": "opensource", - "promptCost": 0.95, - "completionCost": 3.15, - "cacheWrite5mCost": 0, - "cacheWrite1hCost": 0, - "cacheHitCost": 0 - }, - { - "provider": "Baseten", - "id": "baseten:moonshotai/Kimi-K2.5", - "category": "opensource", - "promptCost": 0.6, - "completionCost": 3, - "cacheWrite5mCost": 0, - "cacheWrite1hCost": 0, - "cacheHitCost": 0 - }, - { - "provider": "Baseten", - "id": "baseten:moonshotai/Kimi-K2.6", - "category": "opensource", - "promptCost": 0.95, - "completionCost": 4, - "cacheWrite5mCost": 0, - "cacheWrite1hCost": 0, - "cacheHitCost": 0.16 - }, - { - "provider": "Baseten", - "id": "baseten:MiniMaxAI/MiniMax-M2.5", - "category": "opensource", - "promptCost": 0.5, - "completionCost": 2, - "cacheWrite5mCost": 0, - "cacheWrite1hCost": 0, - "cacheHitCost": 0 - } - ] -} \ No newline at end of file diff --git a/package.json b/package.json index 2fc4fee..2757d5b 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,6 @@ "files": [ "index.ts", "src/", - "models.json", "README.md", "LICENSE" ], @@ -35,8 +34,7 @@ "test:abort": "tsx tests/test-abort.ts", "test:stream": "tsx tests/test-stream.ts", "test:pi-local": "node tests/test-pi-local.mjs", - "test:smoke": "node tests/test-smoke.mjs", - "extract-models": "tsx scripts/extract-models.ts" + "test:smoke": "node tests/test-smoke.mjs" }, "pi": { "extensions": [ diff --git a/scripts/extract-models.ts b/scripts/extract-models.ts deleted file mode 100644 index d9dca06..0000000 --- a/scripts/extract-models.ts +++ /dev/null @@ -1,252 +0,0 @@ -#!/usr/bin/env -S npx tsx - -/** - * Extract model & provider definitions from the command-code npm package dist file. - * - * Usage: - * npx tsx scripts/extract-models.ts [path-to-dist/index.mjs] - * npx tsx scripts/extract-models.ts (downloads latest from npm) - * - * Output: models.json - * { - * providers: { ... }, // provider key -> value map - * providerGroups: { ... }, // provider-group key -> { id, label, providers[] } - * models: [ ... ], // flattened model definitions - * pricing: [ ... ] // pricing entries (per 1M tokens, USD) - * } - */ - -import { execSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Evaluate a JS object literal string safely via Function constructor. */ -function parseObjectLiteral(code: string): Record { - const fn = new Function(`return (${code})`); - return fn() as Record; -} - -// --------------------------------------------------------------------------- -// Step 1: get the dist file -// --------------------------------------------------------------------------- - -function ensureDist(srcPath?: string): string { - if (srcPath) { - if (!existsSync(srcPath)) throw new Error(`File not found: ${srcPath}`); - return srcPath; - } - - // Download latest from npm - const tmpDir = join(process.cwd(), ".extract-tmp"); - mkdirSync(tmpDir, { recursive: true }); - const tgz = execSync(`npm pack command-code --pack-destination "${tmpDir}"`, { - encoding: "utf8", - }).trim(); - const tgzPath = join(tmpDir, tgz); - execSync(`tar xzf "${tgzPath}" -C "${tmpDir}"`, { encoding: "utf8" }); - return join(tmpDir, "package", "dist", "index.mjs"); -} - -// --------------------------------------------------------------------------- -// Step 2: extract -// --------------------------------------------------------------------------- - -interface ModelDef { - key: string; - id: string; - provider: string; - spec: string; - label: string; - name: string; - description: string; - reasoning: boolean; - reasoningEfforts: string[] | null; - contextWindow: number; - maxOutputTokens: number; - vendorLabel: string | null; -} - -interface PricingEntry { - provider: string; - id: string; - category: string; - promptCost: number; - completionCost: number; - cacheWrite5mCost: number; - cacheWrite1hCost: number; - cacheHitCost: number; -} - -interface ProviderGroup { - id: string; - label: string; - shortLabel: string; - description: string; - providers: string[]; -} - -function extract(code: string) { - // --- Wt: provider constants --- - const wtMatch = code.match(/Wt=\{([^}]+)\}/); - if (!wtMatch) throw new Error("Cannot find Wt"); - const wtRaw = "{" + wtMatch[1] + "}"; - const wt: Record = {}; - for (const m of wtRaw.matchAll(/(\w+):"(\w[-\w]*)"/g)) { - wt[m[1]] = m[2]; - } - console.log("Providers:", wt); - - // --- an: model definitions --- - // an={...}).SONNET - const anIdx = code.indexOf("an={"); - if (anIdx < 0) throw new Error("Cannot find an"); - const anEndIdx = code.indexOf("}).SONNET", anIdx); - if (anEndIdx < 0) throw new Error("Cannot find end of an"); - let anCode = code.substring(anIdx + 1, anEndIdx + 2); // "an={...})" - anCode = anCode.replace(/^\(an=/, "").replace(/\)$/, ""); - - // Replace minified JS idioms - anCode = anCode.replace(/\bQt\b/g, JSON.stringify("vercel-ai-gateway")); - anCode = anCode.replace(/\bon\b/g, JSON.stringify("chatComplete")); - anCode = anCode.replace(/\bsn\b/g, JSON.stringify("responses")); - anCode = anCode.replace(/Wt\.([A-Z_]+)/g, (_, key: string) => - JSON.stringify(wt[key]), - ); - anCode = anCode.replace(/!0/g, "true"); - anCode = anCode.replace(/!1/g, "false"); - - const an = parseObjectLiteral(anCode); - - // --- Yt: pricing (provider -> model array) --- - const ytIdx = code.indexOf("Yt={["); - if (ytIdx < 0) throw new Error("Cannot find Yt"); - // Find the matching closing brace for Yt - let depth = 1; - let ytEndIdx = ytIdx + 4; - while (depth > 0 && ytEndIdx < code.length) { - if (code[ytEndIdx] === "{") depth++; - else if (code[ytEndIdx] === "}") depth--; - ytEndIdx++; - } - let ytCode = code.substring(ytIdx + 3, ytEndIdx); // "{ ... }" - ytCode = ytCode.replace(/Wt\.([A-Z_]+)/g, (_, key: string) => - JSON.stringify(wt[key]), - ); - ytCode = ytCode.replace(/!0/g, "true"); - ytCode = ytCode.replace(/!1/g, "false"); - const yt = parseObjectLiteral(ytCode); - - // --- pn: provider groups --- - const pnIdx = code.indexOf('pn={"command-code"'); - if (pnIdx < 0) throw new Error("Cannot find pn"); - const pnEndIdx = code.indexOf(",__name(buildModelGroups", pnIdx); - if (pnEndIdx < 0) throw new Error("Cannot find end of pn"); - let pnCode = code.substring(pnIdx + 3, pnEndIdx); - pnCode = pnCode.replace(/Wt\.([A-Z_]+)/g, (_, key: string) => - JSON.stringify(wt[key]), - ); - pnCode = pnCode.replace(/!0/g, "true"); - pnCode = pnCode.replace(/!1/g, "false"); - pnCode = pnCode.replace(/,\s*$/, ""); - const pn = parseObjectLiteral(pnCode); - - // --- Defaults for fields the CLI doesn't provide per-model --- - // maxOutputTokens: use the minimum across all providers for each model. - // Anthropic direct: 64k OpenAI direct: 128k - // DeepSeek (gateway): 384k (known to work) - // Other gateway models (Baseten/Vercel/Cloudflare/OpenRouter): 65536 - const CONTEXT_WINDOW_FALLBACKS: Record = { - "gpt-5.5": 256_000, - "zai-org/GLM-5.1": 200_000, - "MiniMaxAI/MiniMax-M2.7": 1_048_576, - "Qwen/Qwen3.6-Max-Preview": 1_000_000, - "Qwen/Qwen3.6-Plus": 1_000_000, - }; - const DEFAULT_CONTEXT_WINDOW = 200_000; - - function maxOutputTokensForModel(id: string, provider: string): number { - if (provider === "anthropic") return 64_000; - if (provider === "openai") return 128_000; - if (id.startsWith("deepseek/")) return 384_000; - // Gateway models — lowest common denominator across Baseten/Vercel/Cloudflare - return 65_536; - } - - // --- Build output --- - const models: ModelDef[] = []; - for (const [key, obj] of Object.entries(an)) { - const m = obj as Record; - const id = m.id as string; - const provider = m.provider as string; - models.push({ - key, - id, - provider, - spec: (m.spec as string) || "chatComplete", - label: m.label as string, - name: m.name as string, - description: m.description as string, - reasoning: !!( - m.reasoning ?? - ((m.reasoningEfforts as string[])?.length ?? 0) > 0 - ), - reasoningEfforts: (m.reasoningEfforts as string[]) || null, - contextWindow: - (typeof m.contextWindow === "number" ? m.contextWindow : null) ?? - CONTEXT_WINDOW_FALLBACKS[id] ?? - DEFAULT_CONTEXT_WINDOW, - maxOutputTokens: - maxOutputTokensForModel(id, provider), - vendorLabel: (m.vendorLabel as string) || null, - }); - } - - const pricing: PricingEntry[] = []; - for (const [_provider, entries] of Object.entries(yt)) { - for (const entry of entries as Array>) { - pricing.push({ - provider: entry.provider as string, - id: entry.id as string, - category: entry.category as string, - promptCost: entry.promptCost as number, - completionCost: entry.completionCost as number, - cacheWrite5mCost: (entry.cacheWrite5mCost as number) || 0, - cacheWrite1hCost: (entry.cacheWrite1hCost as number) || 0, - cacheHitCost: (entry.cacheHitCost as number) || 0, - }); - } - } - - const providerGroups: ProviderGroup[] = []; - for (const [key, obj] of Object.entries(pn)) { - const g = obj as Record; - providerGroups.push({ - id: g.id as string, - label: g.label as string, - shortLabel: (g.shortLabel as string) || "", - description: (g.description as string) || "", - providers: (g.supportedModelProviders as string[]) || [], - }); - } - - return { providers: wt, providerGroups, models, pricing }; -} - -// --------------------------------------------------------------------------- -// main -// --------------------------------------------------------------------------- - -const distPath = ensureDist(process.argv[2]); -console.log("Reading:", distPath); -const code = readFileSync(distPath, "utf8"); -const result = extract(code); - -const outPath = join(process.cwd(), "models.json"); -writeFileSync(outPath, JSON.stringify(result, null, 2), "utf8"); -console.log( - `Wrote ${result.models.length} models + ${result.pricing.length} pricing entries to ${outPath}`, -); diff --git a/src/models.ts b/src/models.ts new file mode 100644 index 0000000..c4be26f --- /dev/null +++ b/src/models.ts @@ -0,0 +1,85 @@ +export const DEFAULT_MODELS_URL = "https://api.commandcode.ai/provider/v1/models" + +const DEFAULT_MAX_OUTPUT_TOKENS = 65_536 + +interface ApiModel { + id: string + name: string + contextLength: number +} + +export interface CommandCodeModel { + id: string + name: string + reasoning: boolean + contextWindow: number + maxTokens: number +} + +interface FetchCommandCodeModelsOptions { + url?: string + fetchImpl?: typeof fetch +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function stringField(record: Record, key: string): string { + const value = record[key] + if (typeof value !== "string") throw new Error(`Expected ${key} to be a string`) + return value +} + +function numberField(record: Record, key: string): number { + const value = record[key] + if (typeof value !== "number") throw new Error(`Expected ${key} to be a number`) + return value +} + +function parseApiModel(value: unknown): ApiModel { + if (!isRecord(value)) throw new Error("Expected model entry to be an object") + + return { + id: stringField(value, "id"), + name: stringField(value, "name"), + contextLength: numberField(value, "context_length"), + } +} + +export function commandCodeModelsFromApiResponse(value: unknown): readonly CommandCodeModel[] { + if (!isRecord(value)) throw new Error("Expected models response to be an object") + if (value.object !== "list") throw new Error("Expected models response object to be 'list'") + + const data = value.data + if (!Array.isArray(data)) throw new Error("Expected models response data to be an array") + + return data.map(parseApiModel).map((model) => ({ + id: model.id, + name: `${model.name} (CC)`, + reasoning: true, + contextWindow: model.contextLength, + maxTokens: Math.min(model.contextLength, DEFAULT_MAX_OUTPUT_TOKENS), + })) +} + +export async function fetchCommandCodeModels( + options: FetchCommandCodeModelsOptions = {}, +): Promise { + const url = options.url ?? DEFAULT_MODELS_URL + const fetchImpl = options.fetchImpl ?? fetch + const response = await fetchImpl(url, { + headers: { + accept: "application/json", + }, + }) + + if (!response.ok) { + throw new Error( + `Failed to fetch Command Code models: ${response.status} ${response.statusText}`, + ) + } + + const body: unknown = await response.json() + return commandCodeModelsFromApiResponse(body) +}