From 960d0d1f2388d039c8e8cd8f610723c2425ca92b Mon Sep 17 00:00:00 2001 From: Tao Yang Date: Mon, 25 May 2026 16:37:08 +0800 Subject: [PATCH 1/3] feat: extract models from command-code dist, load via models.json - Add scripts/extract-models.ts to parse command-code npm dist file - Generate models.json (21 models, 15 pricing entries) with contextWindow and maxOutputTokens pre-filled; no nulls or hardcoded fallbacks in index.ts - Rewrite index.ts to load model list and costs from models.json - Cap gateway model maxOutputTokens at 65536 (API limit for Baseten/Vercel) - Add 'Update models' section to README documenting the generation flow - Add npm run extract-models script --- README.md | 23 ++ index.ts | 226 ++++++---------- models.json | 545 ++++++++++++++++++++++++++++++++++++++ package.json | 4 +- scripts/extract-models.ts | 252 ++++++++++++++++++ 5 files changed, 907 insertions(+), 143 deletions(-) create mode 100644 models.json create mode 100644 scripts/extract-models.ts diff --git a/README.md b/README.md index 9321b3a..442bd0b 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,29 @@ Or within pi: /models ``` +## Update models + +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: + +```sh +npm run extract-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. + ## Publish ```sh diff --git a/index.ts b/index.ts index c56fdef..24a6b03 100644 --- a/index.ts +++ b/index.ts @@ -9,158 +9,100 @@ * 3. Place API key in `~/.commandcode/auth.json` or `~/.pi/agent/auth.json` * as {"apiKey": "user_..."} or {"commandcode": "user_..."} * - * Models: deepseek-v4-pro, deepseek-v4-flash, claude-sonnet-4-6, claude-opus-4-7, etc. + * 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. */ -import { calculateCost, createAssistantMessageEventStream } from "@mariozechner/pi-ai" -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent" +import { readFileSync } from "node:fs"; -import { createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts" -import { getApiKey, login, refreshToken } from "./src/oauth.ts" +import { calculateCost, createAssistantMessageEventStream } from "@mariozechner/pi-ai"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -const API_BASE = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE +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; // --------------------------------------------------------------------------- -// Model definitions +// Load model definitions from models.json // --------------------------------------------------------------------------- -const MODELS = [ - // Premium (Anthropic) - { - id: "claude-opus-4-7", - name: "Claude Opus 4.7 (CC)", - reasoning: true, - contextWindow: 200_000, - maxTokens: 32_000, - }, - { - id: "claude-opus-4-6", - name: "Claude Opus 4.6 (CC)", - reasoning: true, - contextWindow: 200_000, - maxTokens: 32_000, - }, - { - id: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6 (CC)", - reasoning: true, - contextWindow: 200_000, - maxTokens: 16_384, - }, - { - id: "claude-haiku-4-5-20251001", - name: "Claude Haiku 4.5 (CC)", - reasoning: true, - contextWindow: 200_000, - maxTokens: 8_192, - }, - // Premium (OpenAI) - { - id: "gpt-5.5", - name: "GPT-5.5 (CC)", - reasoning: true, - contextWindow: 256_000, - maxTokens: 128_000, - }, - { - id: "gpt-5.4", - name: "GPT-5.4 (CC)", - reasoning: true, - contextWindow: 256_000, - maxTokens: 128_000, - }, - { - id: "gpt-5.3-codex", - name: "GPT-5.3 Codex (CC)", - reasoning: true, - contextWindow: 256_000, - maxTokens: 128_000, - }, - { - id: "gpt-5.4-mini", - name: "GPT-5.4 Mini (CC)", - reasoning: false, - contextWindow: 256_000, - maxTokens: 128_000, - }, - // Open-source - { - id: "deepseek/deepseek-v4-pro", - name: "DeepSeek V4 Pro (CC)", - reasoning: true, - contextWindow: 1_000_000, - maxTokens: 384_000, - }, - { - id: "deepseek/deepseek-v4-flash", - name: "DeepSeek V4 Flash (CC)", - reasoning: true, - contextWindow: 1_000_000, - maxTokens: 384_000, - }, - { - id: "moonshotai/Kimi-K2.6", - name: "Kimi K2.6 (CC)", - reasoning: true, - contextWindow: 262_144, - maxTokens: 131_072, - }, - { - id: "moonshotai/Kimi-K2.5", - name: "Kimi K2.5 (CC)", - reasoning: true, - contextWindow: 262_144, - maxTokens: 131_072, - }, - { - id: "zai-org/GLM-5.1", - name: "GLM-5.1 (CC)", - reasoning: true, - contextWindow: 200_000, - maxTokens: 131_072, - }, - { - id: "zai-org/GLM-5", - name: "GLM-5 (CC)", - reasoning: true, - contextWindow: 200_000, - maxTokens: 131_072, - }, - { - id: "MiniMaxAI/MiniMax-M2.7", - name: "MiniMax M2.7 (CC)", - reasoning: true, - contextWindow: 1_048_576, - maxTokens: 131_072, - }, - { - id: "MiniMaxAI/MiniMax-M2.5", - name: "MiniMax M2.5 (CC)", - reasoning: true, - contextWindow: 1_048_576, - maxTokens: 131_072, - }, - { - id: "Qwen/Qwen3.6-Max-Preview", - name: "Qwen 3.6 Max (CC)", - reasoning: true, - contextWindow: 1_000_000, - maxTokens: 131_072, - }, - { - id: "Qwen/Qwen3.6-Plus", - name: "Qwen 3.6 Plus (CC)", - reasoning: true, - contextWindow: 1_000_000, - maxTokens: 131_072, - }, -] +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 streamCommandCode = createStreamCommandCode({ createStream: createAssistantMessageEventStream, calculateCost, apiBase: API_BASE, -}) +}); // --------------------------------------------------------------------------- // Extension entry point @@ -188,10 +130,10 @@ export default function (pi: ExtensionAPI) { id: model.id, name: model.name, reasoning: model.reasoning, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + input: ["text"] as const, + cost: model.cost, contextWindow: model.contextWindow, maxTokens: model.maxTokens, })), - }) + }); } diff --git a/models.json b/models.json new file mode 100644 index 0000000..21bab17 --- /dev/null +++ b/models.json @@ -0,0 +1,545 @@ +{ + "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 2757d5b..2fc4fee 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "files": [ "index.ts", "src/", + "models.json", "README.md", "LICENSE" ], @@ -34,7 +35,8 @@ "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" + "test:smoke": "node tests/test-smoke.mjs", + "extract-models": "tsx scripts/extract-models.ts" }, "pi": { "extensions": [ diff --git a/scripts/extract-models.ts b/scripts/extract-models.ts new file mode 100644 index 0000000..d9dca06 --- /dev/null +++ b/scripts/extract-models.ts @@ -0,0 +1,252 @@ +#!/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}`, +); From 418f4c925bb02bccaa919c8dacd720df6ff1a38a Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Tue, 26 May 2026 21:51:41 +0200 Subject: [PATCH 2/3] Fetch Command Code models at startup --- README.md | 45 +--- index.ts | 104 ++------ models.json | 545 -------------------------------------- package.json | 4 +- scripts/extract-models.ts | 252 ------------------ src/models.ts | 85 ++++++ 6 files changed, 114 insertions(+), 921 deletions(-) delete mode 100644 models.json delete mode 100644 scripts/extract-models.ts create mode 100644 src/models.ts 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) +} From 5714e3863ac10068a250645072ccbdda189f7445 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Tue, 26 May 2026 21:51:54 +0200 Subject: [PATCH 3/3] Test dynamic Command Code model discovery --- package.json | 3 ++- tests/test-models.ts | 36 +++++++++++++++++++++++++ tests/test-pi-local.mjs | 60 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 tests/test-models.ts diff --git a/package.json b/package.json index 2757d5b..971b815 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,12 @@ "LICENSE" ], "scripts": { - "test": "npm run typecheck && tsx tests/test-pure-functions.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-stream.ts && node tests/test-pi-local.mjs", + "test": "npm run typecheck && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-stream.ts && node tests/test-pi-local.mjs", "typecheck": "tsc --noEmit", "format:check": "prettier --check '**/*.{ts,mjs,json,md}'", "format": "prettier --write '**/*.{ts,mjs,json,md}'", "test:unit": "tsx tests/test-pure-functions.ts", + "test:models": "tsx tests/test-models.ts", "test:oauth": "tsx tests/test-oauth.ts", "test:abort": "tsx tests/test-abort.ts", "test:stream": "tsx tests/test-stream.ts", diff --git a/tests/test-models.ts b/tests/test-models.ts new file mode 100644 index 0000000..15ee3fd --- /dev/null +++ b/tests/test-models.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { commandCodeModelsFromApiResponse } from "../src/models.ts" + +describe("commandCodeModelsFromApiResponse()", () => { + it("converts the Provider API model list to pi models", () => { + const models = commandCodeModelsFromApiResponse({ + object: "list", + data: [ + { + id: "Qwen/Qwen3.7-Max", + object: "model", + created: 1779824324, + owned_by: "command-code", + name: "Qwen 3.7 Max", + context_length: 1_000_000, + }, + ], + }) + + assert.deepEqual(models, [ + { + id: "Qwen/Qwen3.7-Max", + name: "Qwen 3.7 Max (CC)", + reasoning: true, + contextWindow: 1_000_000, + maxTokens: 65_536, + }, + ]) + }) + + it("rejects unexpected API shapes", () => { + assert.throws(() => commandCodeModelsFromApiResponse({ object: "list", data: [{}] })) + }) +}) diff --git a/tests/test-pi-local.mjs b/tests/test-pi-local.mjs index 9f97fd7..8b37560 100644 --- a/tests/test-pi-local.mjs +++ b/tests/test-pi-local.mjs @@ -56,10 +56,40 @@ if (piCheck.error) { } let requestCount = 0 +let modelListRequestCount = 0 let lastRequestBody let lastRequestHeaders = {} const server = createServer((req, res) => { + if (req.method === "GET" && req.url === "/provider/v1/models") { + modelListRequestCount += 1 + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }) + res.end( + JSON.stringify({ + object: "list", + data: [ + { + id: TEST_MODEL, + object: "model", + created: 1779824324, + owned_by: "command-code", + name: "DeepSeek V4 Flash", + context_length: 1_000_000, + }, + { + id: "Qwen/Qwen3.7-Max", + object: "model", + created: 1779824324, + owned_by: "command-code", + name: "Qwen 3.7 Max", + context_length: 1_000_000, + }, + ], + }), + ) + return + } + if (req.method !== "POST" || req.url !== "/alpha/generate") { res.writeHead(404) res.end("Not found") @@ -114,6 +144,7 @@ let tempHome const env = { ...process.env, COMMANDCODE_API_BASE: apiBase, + COMMANDCODE_MODELS_URL: `${apiBase}/provider/v1/models`, } if (hasLivePiAuth()) { @@ -161,7 +192,17 @@ function runPi(args, timeoutMs = 30_000) { async function runRpcQuery(timeoutMs = 30_000) { const child = spawn( PI_BIN, - ["--mode", "rpc", "-e", EXT_PATH, "--provider", "commandcode", "--model", TEST_MODEL], + [ + "--no-extensions", + "--mode", + "rpc", + "-e", + EXT_PATH, + "--provider", + "commandcode", + "--model", + TEST_MODEL, + ], { cwd: PROJECT_DIR, env, @@ -250,15 +291,28 @@ async function runRpcQuery(timeoutMs = 30_000) { try { console.log("[pi-local] list models through real extension") - const list = await runPi(["-e", EXT_PATH, "--list-models"], 20_000) + modelListRequestCount = 0 + const list = await runPi(["--no-extensions", "-e", EXT_PATH, "--list-models"], 20_000) assert.equal(list.code, 0, list.stderr) assert.match(list.stdout, /commandcode/) assert.match(list.stdout, /deepseek\/deepseek-v4-flash/) + assert.match(list.stdout, /Qwen\/Qwen3\.7-Max/) + assert.equal(modelListRequestCount, 1) console.log("[pi-local] print mode through real extension and mock API") requestCount = 0 const print = await runPi( - ["-e", EXT_PATH, "-p", "say mock token", "--provider", "commandcode", "--model", TEST_MODEL], + [ + "--no-extensions", + "-e", + EXT_PATH, + "-p", + "say mock token", + "--provider", + "commandcode", + "--model", + TEST_MODEL, + ], 30_000, ) assert.equal(print.code, 0, print.stderr)