diff --git a/README.md b/README.md index 9321b3a..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,18 +88,22 @@ 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: +Any query will then use the Command Code API. You can list available models within pi: -```sh -pi -e index.ts --list-models +```txt +/models ``` -Or within pi: +## Model discovery + +On startup, the provider fetches: ```txt -/models +https://api.commandcode.ai/provider/v1/models ``` +For tests or local mocks, override it with `COMMANDCODE_MODELS_URL`. + ## Publish ```sh diff --git a/index.ts b/index.ts index c56fdef..917f268 100644 --- a/index.ts +++ b/index.ts @@ -9,152 +9,18 @@ * 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 fetched from Command Code's Provider API at startup. */ 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" const API_BASE = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE - -// --------------------------------------------------------------------------- -// Model definitions -// --------------------------------------------------------------------------- - -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, - }, -] +const MODELS_URL = process.env.COMMANDCODE_MODELS_URL ?? DEFAULT_MODELS_URL const streamCommandCode = createStreamCommandCode({ createStream: createAssistantMessageEventStream, @@ -166,7 +32,9 @@ const streamCommandCode = createStreamCommandCode({ // 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, @@ -184,11 +52,11 @@ 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"], + input: ["text"] as const, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: model.contextWindow, maxTokens: model.maxTokens, 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/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) +} 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)