From 16eb5a8ddaf80a23fde77373932be9a3956d507e Mon Sep 17 00:00:00 2001 From: warc0s Date: Mon, 17 Aug 2026 09:03:32 +0200 Subject: [PATCH 01/18] fix(core): preserve developer messages OMP converts custom and hook messages (advisor notes, todo reminders, retry nudges) to role "developer" before calling the provider. messagesToCC() only handled user, assistant, and toolResult, so those messages were dropped before params.messages was sent to /alpha/generate. Steering still interrupted pending tools, but the model never saw the message content. /alpha/generate has no developer role: the official command-code CLI (0.32.3) only emits user, assistant, and tool messages plus a separate params.system. Forward developer messages as user messages with identical content in the same chronological position. Hoisting them into params.system would turn a mid-conversation note into a global top-priority instruction. --- CHANGELOG.md | 2 + src/converters.ts | 7 ++- tests/test-pure-functions.ts | 83 ++++++++++++++++++++++++++++++++++++ tests/test-stream.ts | 50 ++++++++++++++++++++++ 4 files changed, 141 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cae0a0..01bf07a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Stop silently dropping `role: "developer"` messages (for example OMP advisor steering notes, reminders, and nudges). `/alpha/generate` only accepts `user`, `assistant`, and `tool` roles, so developer messages are now forwarded as `user` messages with identical content in the same chronological position instead of disappearing from the request. + ## 0.5.1 - 2026-08-11 - Add model-specific image input capabilities from the `command-code@1.15.1` catalog and forward user and tool-result images using the current Command Code wire format. diff --git a/src/converters.ts b/src/converters.ts index 4012fbb..6480263 100644 --- a/src/converters.ts +++ b/src/converters.ts @@ -190,7 +190,12 @@ export function messagesToCC( const pairedToolCallIds = completeToolCallIds(messages) for (const message of messages ?? []) { - if (message.role === "user") { + if (message.role === "user" || message.role === "developer") { + // Hosts such as OMP steer the agent by injecting developer-role messages + // (advisor notes, reminders, nudges) mid-conversation. /alpha/generate + // only accepts user, assistant, and tool roles, so degrade the role to + // user instead of dropping the message. Content and chronological + // position are preserved; system-prompt hoisting would change semantics. out.push({ role: "user", content: userContentToCommandCode(message.content, allowImages), diff --git a/tests/test-pure-functions.ts b/tests/test-pure-functions.ts index 136025f..c5d235a 100644 --- a/tests/test-pure-functions.ts +++ b/tests/test-pure-functions.ts @@ -626,6 +626,89 @@ describe("messagesToCC()", () => { it("handles empty conversations", () => { assert.deepEqual(messagesToCC([]), []) }) + + it("keeps developer messages instead of dropping them", () => { + const result = messagesToCC([ + { role: "user", content: "start" }, + { role: "developer", content: "mid-conversation steering note" }, + ]) + + assert.deepEqual(result, [ + { role: "user", content: "start" }, + { role: "user", content: "mid-conversation steering note" }, + ]) + }) + + it("converts developer text parts with the same shape as user content", () => { + const result = messagesToCC([ + { + role: "developer", + content: [{ type: "text", text: "reminder one" }], + }, + ]) + + assert.deepEqual(result, [ + { + role: "user", + content: [{ type: "text", text: "reminder one" }], + }, + ]) + }) + + it("preserves advisory XML verbatim in the serialized request messages", () => { + const advisory = + '\nStop and correct the benchmark.\n' + const serialized = JSON.stringify(messagesToCC([{ role: "developer", content: advisory }])) + + assert.deepEqual(JSON.parse(serialized), [{ role: "user", content: advisory }]) + }) + + it("keeps developer advisories in chronological position without hoisting", () => { + const advisory = + '\nStop and correct the benchmark.\n' + const result = messagesToCC([ + { role: "user", content: "run the benchmark" }, + { + role: "assistant", + content: [ + { type: "text", text: "running it" }, + { type: "toolCall", id: "c1", name: "bash", arguments: { command: "bench" } }, + ], + }, + { + role: "toolResult", + toolCallId: "c1", + toolName: "bash", + content: [{ type: "text", text: "benchmark output" }], + }, + { role: "developer", content: advisory }, + { role: "user", content: "continue" }, + ]) + + assert.deepEqual(result, [ + { role: "user", content: "run the benchmark" }, + { + role: "assistant", + content: [ + { type: "text", text: "running it" }, + { type: "tool-call", toolCallId: "c1", toolName: "bash", input: { command: "bench" } }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "c1", + toolName: "bash", + output: { type: "text", value: "benchmark output" }, + }, + ], + }, + { role: "user", content: advisory }, + { role: "user", content: "continue" }, + ]) + }) }) describe("parseStreamEventLine()", () => { diff --git a/tests/test-stream.ts b/tests/test-stream.ts index e30ac49..82ed325 100644 --- a/tests/test-stream.ts +++ b/tests/test-stream.ts @@ -495,6 +495,56 @@ describe("streamCommandCode — request serialization", () => { assert.equal(headers["x-session-id"], undefined) }) + it("sends developer advisories as user messages in position, without system hoisting", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + const advisory = + '\nStop and correct the benchmark.\n' + const context = makeContext({ + messages: [ + { role: "user", content: "run the benchmark" }, + { + role: "assistant", + content: [ + { type: "text", text: "running it" }, + { type: "toolCall", id: "c1", name: "bash", arguments: { command: "bench" } }, + ], + }, + { + role: "toolResult", + toolCallId: "c1", + toolName: "bash", + content: [{ type: "text", text: "benchmark output" }], + }, + { role: "developer", content: advisory }, + { role: "user", content: "continue" }, + ], + }) + + await collectEvents(streamCommandCode(makeModel(), context, { apiKey: "mock-key" })) + + const body = server.lastRequestBody() + assert.deepEqual( + objectAt(body, ["params", "messages", "3"]), + { role: "user", content: advisory }, + "developer advisory should arrive as an in-position user message with identical content", + ) + assert.deepEqual(objectAt(body, ["params", "messages", "4"]), { + role: "user", + content: "continue", + }) + assert.equal(objectAt(body, ["params", "messages", "5"]), undefined) + assert.equal( + objectAt(body, ["params", "system"]), + "You are a test assistant.", + "advisory must not be hoisted into the system prompt", + ) + assert.doesNotMatch(String(objectAt(body, ["params", "system"])), /advisory/) + }) + it("accepts the legacy OMP nested reasoning map", async () => { server.mockResponse({ type: "success", From 1f312f8d61514270066263446efeeaaa369b8261 Mon Sep 17 00:00:00 2001 From: warc0s Date: Mon, 17 Aug 2026 09:04:32 +0200 Subject: [PATCH 02/18] test(omp): cover developer advisory delivery Add a fixture extension that injects an advisor-style custom message on session start, and assert the advisory XML reaches params.messages verbatim, in chronological position, and is not hoisted into params.system. Port the models phase to `omp models --json`. `--list-models` no longer exists in current OMP (verified on 17.3.5), so the phase failed before reaching print mode. --- tests/fixtures/advisory-injector-extension.ts | 41 ++++++++ tests/test-omp-compat.mjs | 99 +++++++++++++++++-- 2 files changed, 130 insertions(+), 10 deletions(-) create mode 100644 tests/fixtures/advisory-injector-extension.ts diff --git a/tests/fixtures/advisory-injector-extension.ts b/tests/fixtures/advisory-injector-extension.ts new file mode 100644 index 0000000..f88d5ed --- /dev/null +++ b/tests/fixtures/advisory-injector-extension.ts @@ -0,0 +1,41 @@ +/** + * Minimal OMP extension used by tests/test-omp-compat.mjs. + * + * Appends a custom advisor message to the session on session start, mirroring + * how the OMP Advisor runtime injects steering notes. OMP converts custom + * messages to `role: "developer"` LLM messages before handing them to the + * provider. Types are declared inline so the fixture has no runtime or + * typecheck dependency on OMP packages. + */ + +interface AdvisorInjectorSendMessage { + ( + message: { + customType: string + content: string + display: boolean + attribution: string + }, + options?: { triggerTurn?: boolean }, + ): void +} + +interface AdvisorInjectorApi { + on: (event: "session_start", handler: () => void | Promise) => void + sendMessage: AdvisorInjectorSendMessage +} + +export default function advisoryInjectorExtension(pi: AdvisorInjectorApi): void { + pi.on("session_start", async () => { + pi.sendMessage( + { + customType: "advisor", + content: + '\nStop and correct the benchmark.\n', + display: true, + attribution: "agent", + }, + { triggerTurn: false }, + ) + }) +} diff --git a/tests/test-omp-compat.mjs b/tests/test-omp-compat.mjs index af7c2bd..04f8c78 100644 --- a/tests/test-omp-compat.mjs +++ b/tests/test-omp-compat.mjs @@ -19,7 +19,10 @@ import { fileURLToPath } from "node:url" const __dirname = dirname(fileURLToPath(import.meta.url)) const PROJECT_DIR = resolve(__dirname, "..") const EXT_PATH = resolve(PROJECT_DIR, "index.ts") +const ADVISORY_EXT_PATH = resolve(PROJECT_DIR, "tests/fixtures/advisory-injector-extension.ts") const TEST_MODEL = "deepseek/deepseek-v4-flash" +const ADVISORY_XML = + '\nStop and correct the benchmark.\n' function findOmpBinary() { if (process.env.OMP_BIN) return process.env.OMP_BIN @@ -45,6 +48,7 @@ const tempHome = mkdtempSync(join(tmpdir(), "omp-cc-home-")) let requestCount = 0 let modelListRequestCount = 0 let lastRequestBody +let requestBodies = [] let lastRequestHeaders = {} const server = createServer((req, res) => { @@ -98,6 +102,7 @@ const server = createServer((req, res) => { req.on("end", () => { try { lastRequestBody = JSON.parse(body) + requestBodies.push(lastRequestBody) } catch { lastRequestBody = undefined } @@ -160,19 +165,33 @@ function runOmp(args, timeoutMs = 30_000) { try { console.log("[omp-compat] list models through real extension") modelListRequestCount = 0 - const result = await runOmp(["-e", EXT_PATH, "--list-models"]) - assert.equal(result.code, 0, result.stderr) - const listOutput = result.stdout || result.stderr - assert.match(listOutput, /commandcode/) - assert.match(listOutput, /deepseek\/deepseek-v4-flash/) - assert.equal(modelListRequestCount, 1) - assert.doesNotThrow(() => - accessSync(join(tempHome, ".omp", "agent", "commandcode-models.json"), constants.R_OK), - ) - assert.doesNotMatch(result.stdout + result.stderr, /Failed to load extension/) + const list = await runOmp(["models", "--json", "-e", EXT_PATH, "--no-extensions"]) + if (list.code !== 0 && /unknown|unrecognized/i.test(list.stderr + list.stdout)) { + console.log("[omp-compat] SKIP models phase - omp models subcommand unavailable") + } else { + assert.equal(list.code, 0, list.stderr) + let listed = null + try { + listed = JSON.parse(list.stdout) + } catch { + listed = null + } + const models = Array.isArray(listed?.models) ? listed.models : [] + assert.ok( + models.some((model) => model.provider === "commandcode"), + "commandcode provider should be listed", + ) + assert.ok( + models.some((model) => model.id === TEST_MODEL), + "mock catalog model should be listed", + ) + assert.ok(modelListRequestCount >= 1) + assert.doesNotMatch(list.stdout + list.stderr, /Failed to load extension/) + } console.log("[omp-compat] print mode through real extension and mock API") requestCount = 0 + requestBodies = [] const print = await runOmp( ["-e", EXT_PATH, "-p", "say mock token", "--model", `commandcode/${TEST_MODEL}`], 30_000, @@ -187,6 +206,66 @@ try { ) assert.equal(lastRequestBody?.params?.model, TEST_MODEL) assert.equal(typeof lastRequestBody?.params?.system, "string") + assert.doesNotThrow(() => + accessSync(join(tempHome, ".omp", "agent", "commandcode-models.json"), constants.R_OK), + ) + + console.log("[omp-compat] developer advisory reaches the provider request body") + requestCount = 0 + requestBodies = [] + const advisoryRun = await runOmp( + [ + "-e", + EXT_PATH, + "-e", + ADVISORY_EXT_PATH, + "-p", + "say mock token", + "--model", + `commandcode/${TEST_MODEL}`, + "--no-tools", + "--no-title", + ], + 30_000, + ) + assert.equal(advisoryRun.code, 0, advisoryRun.stderr) + assert.match(advisoryRun.stdout, /mock-omp-ok/) + + const promptBodies = requestBodies.filter((body) => + JSON.stringify(body?.params?.messages ?? []).includes("say mock token"), + ) + assert.ok(promptBodies.length >= 1, "expected at least one generate request with the prompt") + + for (const body of promptBodies) { + const messages = body?.params?.messages ?? [] + const advisoryMessages = messages.filter((message) => + JSON.stringify(message).includes("Stop and correct the benchmark."), + ) + assert.equal(advisoryMessages.length, 1, "the advisory should survive conversion exactly once") + const advisoryMessage = advisoryMessages[0] + assert.equal(advisoryMessage.role, "user") + const advisoryText = + typeof advisoryMessage.content === "string" + ? advisoryMessage.content + : (advisoryMessage.content ?? []) + .map((part) => (part?.type === "text" ? part.text : "")) + .join("\n") + assert.equal(advisoryText, ADVISORY_XML, "advisory content must arrive verbatim") + + const advisoryIndex = messages.indexOf(advisoryMessage) + const promptIndex = messages.findIndex((message) => + JSON.stringify(message).includes("say mock token"), + ) + assert.ok( + advisoryIndex < promptIndex, + "advisory must keep its chronological position relative to the prompt", + ) + assert.doesNotMatch( + String(body?.params?.system ?? ""), + /Stop and correct the benchmark| Date: Fri, 28 Aug 2026 16:38:25 +0200 Subject: [PATCH 03/18] feat(models): add Qwen 3.8 Flash and GLM 5.3 Flash Synchronize the static model catalog with command-code@1.36.0, which adds Qwen/Qwen3.8-Flash (reasoning efforts low, medium, xhigh) and z-ai/glm-5.3-flash (low, high, max), the free minimax/minimax-m3-free model, and the glm-5.3-flash output limit, and drops the retired stealth/ox-alpha. --- README.md | 2 +- src/commandcode-catalog.ts | 17 +++++++++++------ tests/test-models.ts | 28 +++++++++++++++++++++++----- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7256c32..a39510c 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ The following environment variables are intended for tests, local mocks, and com ## Image input -The provider advertises image input only for models marked with the `image` input modality in the official Command Code CLI model catalog. The capability snapshot currently follows `command-code@1.32.2`; unknown models default to text-only until their upstream metadata is reviewed. A daily GitHub Actions job synchronizes the CLI version, image capabilities, reasoning flags, reasoning efforts, and model-specific output limits with the latest published CLI package and opens or updates a reviewable pull request when they change. Pricing remains manually reviewed because temporary promotions and long-context tiers require explicit review. +The provider advertises image input only for models marked with the `image` input modality in the official Command Code CLI model catalog. The capability snapshot currently follows `command-code@1.36.0`; unknown models default to text-only until their upstream metadata is reviewed. A daily GitHub Actions job synchronizes the CLI version, image capabilities, reasoning flags, reasoning efforts, and model-specific output limits with the latest published CLI package and opens or updates a reviewable pull request when they change. Pricing remains manually reviewed because temporary promotions and long-context tiers require explicit review. For vision-capable models, Pi's native provider adapters forward image blocks from user messages and tool results using the documented OpenAI or Anthropic message schema. Unknown and text-only models remain marked text-only in Pi. diff --git a/src/commandcode-catalog.ts b/src/commandcode-catalog.ts index 6840f5f..a136a8b 100644 --- a/src/commandcode-catalog.ts +++ b/src/commandcode-catalog.ts @@ -1,10 +1,10 @@ -export const COMMAND_CODE_CLI_VERSION = "1.32.2" +export const COMMAND_CODE_CLI_VERSION = "1.36.0" export type CommandCodeInputType = "text" | "image" export type CommandCodeReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh" | "max" /** - * Generated from command-code@1.32.2 by `npm run sync:commandcode-catalog`. + * Generated from command-code@1.36.0 by `npm run sync:commandcode-catalog`. * Do not edit manually. */ export const MODEL_INPUT_MODALITIES: Readonly> = { @@ -31,6 +31,7 @@ export const MODEL_INPUT_MODALITIES: Readonly> = { @@ -76,6 +78,7 @@ export const MODEL_REASONING: Readonly> = { "meta/muse-spark-1.1": true, "meta/muse-spark-1.2": true, "meta/muse-spark-1.2-contributor": true, + "minimax/minimax-m3-free": true, "MiniMaxAI/MiniMax-M3": true, "moonshotai/Kimi-K2.7-Code": true, "moonshotai/Kimi-K2.7-Code-Highspeed": true, @@ -88,9 +91,9 @@ export const MODEL_REASONING: Readonly> = { "Qwen/Qwen3.7-Max": true, "Qwen/Qwen3.7-Plus": true, "Qwen/Qwen3.8-27B": true, + "Qwen/Qwen3.8-Flash": true, "Qwen/Qwen3.8-Max": true, "sakana/fugu-ultra": true, - "stealth/ox-alpha": true, "stepfun/Step-3.5-Flash": true, "stepfun/Step-3.7-Flash": true, "tencent/hy3-paid": true, @@ -98,6 +101,7 @@ export const MODEL_REASONING: Readonly> = { "thinkingmachines/inkling-small": true, "xai/grok-4.5": true, "xai/grok-4.6": true, + "z-ai/glm-5.3-flash": true, "zai-org/GLM-5.2": true, "zai-org/GLM-5.3": true, } @@ -125,11 +129,12 @@ export const MODEL_EFFORTS: Readonly> = { "poolside/laguna-s-2.1-free": 32_768, "Qwen/Qwen3.8-27B": 32_768, - "stealth/ox-alpha": 131_072, + "z-ai/glm-5.3-flash": 131_072, } diff --git a/tests/test-models.ts b/tests/test-models.ts index c40d321..5773221 100644 --- a/tests/test-models.ts +++ b/tests/test-models.ts @@ -112,13 +112,15 @@ describe("commandCodeModelsFromApiResponse()", () => { ]) assert.deepEqual(inputModalitiesForModel("Qwen/Qwen3.8-27B"), ["text", "image"]) assert.deepEqual(inputModalitiesForModel("google/gemini-3.7-flash"), ["text", "image"]) - assert.deepEqual(inputModalitiesForModel("stealth/ox-alpha"), ["text", "image"]) + assert.deepEqual(inputModalitiesForModel("Qwen/Qwen3.8-Flash"), ["text", "image"]) + assert.deepEqual(inputModalitiesForModel("z-ai/glm-5.3-flash"), ["text", "image"]) + assert.deepEqual(inputModalitiesForModel("minimax/minimax-m3-free"), ["text", "image"]) assert.deepEqual(inputModalitiesForModel("deepseek/deepseek-v4-pro"), ["text"]) assert.deepEqual(inputModalitiesForModel("zai-org/GLM-5.3"), ["text"]) assert.deepEqual(inputModalitiesForModel("unknown-new-model"), ["text"]) assert.equal(modelSupportsImageInput("gpt-5.6-luna"), true) assert.equal(modelSupportsImageInput("deepseek/deepseek-v4-flash-vision-exp"), true) - assert.equal(modelSupportsImageInput("stealth/ox-alpha"), true) + assert.equal(modelSupportsImageInput("z-ai/glm-5.3-flash"), true) assert.equal(modelSupportsImageInput("deepseek/deepseek-v4-pro"), false) assert.ok(Object.keys(MODEL_INPUT_MODALITIES).length > 0) for (const modalities of Object.values(MODEL_INPUT_MODALITIES)) { @@ -149,7 +151,7 @@ describe("commandCodeModelsFromApiResponse()", () => { }, }) assert.equal(models[2]?.reasoning, false) - assert.equal(Object.keys(MODEL_REASONING).length, 48) + assert.equal(Object.keys(MODEL_REASONING).length, 50) }) it("uses model-specific output limits from the CLI catalog", () => { @@ -157,7 +159,7 @@ describe("commandCodeModelsFromApiResponse()", () => { object: "list", data: [ { ...API_RESPONSE.data[0], id: "Qwen/Qwen3.8-27B", context_length: 262_144 }, - { ...API_RESPONSE.data[0], id: "stealth/ox-alpha", context_length: 1_048_576 }, + { ...API_RESPONSE.data[0], id: "z-ai/glm-5.3-flash", context_length: 1_048_576 }, { ...API_RESPONSE.data[0], id: "poolside/laguna-s-2.1-free", @@ -170,7 +172,7 @@ describe("commandCodeModelsFromApiResponse()", () => { models.map(({ id, maxTokens }) => ({ id, maxTokens })), [ { id: "Qwen/Qwen3.8-27B", maxTokens: 32_768 }, - { id: "stealth/ox-alpha", maxTokens: 131_072 }, + { id: "z-ai/glm-5.3-flash", maxTokens: 131_072 }, { id: "poolside/laguna-s-2.1-free", maxTokens: 32_768 }, ], ) @@ -217,6 +219,22 @@ describe("commandCodeModelsFromApiResponse()", () => { xhigh: null, max: "max", }) + assert.deepEqual(thinkingLevelMapForEfforts(MODEL_EFFORTS["Qwen/Qwen3.8-Flash"]), { + minimal: null, + low: "low", + medium: "medium", + high: null, + xhigh: "xhigh", + max: null, + }) + assert.deepEqual(thinkingLevelMapForEfforts(MODEL_EFFORTS["z-ai/glm-5.3-flash"]), { + minimal: null, + low: "low", + medium: null, + high: "high", + xhigh: null, + max: "max", + }) assert.deepEqual(thinkingMetadataForModel("new-model-without-metadata"), undefined) }) From b267241e33b6b31431dce5ec7b2da4e83da96bad Mon Sep 17 00:00:00 2001 From: Thomas Byr Date: Fri, 28 Aug 2026 16:40:49 +0200 Subject: [PATCH 04/18] feat(pricing): add display pricing for Qwen 3.8 Flash and GLM 5.3 Flash Qwen/Qwen3.8-Flash lists $0.16 input, $0.47 output, and $0.016 cache read per million tokens, and z-ai/glm-5.3-flash lists $0.15, $0.50, and $0.03; the official pricing page documents no cache-write rate for either model. --- src/pricing.ts | 2 ++ tests/fixtures/commandcode-model-ids.json | 2 ++ tests/fixtures/commandcode-pricing.json | 2 ++ tests/test-pricing.ts | 12 ++++++++++++ 4 files changed, 18 insertions(+) diff --git a/src/pricing.ts b/src/pricing.ts index fddd2a3..71ba138 100644 --- a/src/pricing.ts +++ b/src/pricing.ts @@ -54,6 +54,7 @@ export const MODEL_COSTS: Readonly> = { }, "moonshotai/Kimi-K2.6": { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 }, "moonshotai/Kimi-K2.5": { input: 0.6, output: 3, cacheRead: 0.1, cacheWrite: 0 }, + "z-ai/glm-5.3-flash": { input: 0.15, output: 0.5, cacheRead: 0.03, cacheWrite: 0 }, "zai-org/GLM-5.3": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, "zai-org/GLM-5.2": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, "zai-org/GLM-5.2-Fast": { input: 3, output: 10.25, cacheRead: 0.5, cacheWrite: 0 }, @@ -84,6 +85,7 @@ export const MODEL_COSTS: Readonly> = { }, "Qwen/Qwen3.8-Max": { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 2.5 }, "Qwen/Qwen3.8-27B": { input: 0.4, output: 3, cacheRead: 0.04, cacheWrite: 0 }, + "Qwen/Qwen3.8-Flash": { input: 0.16, output: 0.47, cacheRead: 0.016, cacheWrite: 0 }, "Qwen/Qwen3.7-Max": { input: 2.5, output: 7.5, cacheRead: 0.5, cacheWrite: 3.13 }, "Qwen/Qwen3.7-Plus": { input: 0.4, diff --git a/tests/fixtures/commandcode-model-ids.json b/tests/fixtures/commandcode-model-ids.json index 2a69168..15c677c 100644 --- a/tests/fixtures/commandcode-model-ids.json +++ b/tests/fixtures/commandcode-model-ids.json @@ -24,6 +24,7 @@ "moonshotai/Kimi-K2.7-Code-Highspeed", "moonshotai/Kimi-K2.6", "moonshotai/Kimi-K2.5", + "z-ai/glm-5.3-flash", "zai-org/GLM-5.3", "zai-org/GLM-5.2", "zai-org/GLM-5.2-Fast", @@ -36,6 +37,7 @@ "xiaomi/mimo-v2.5", "Qwen/Qwen3.8-Max", "Qwen/Qwen3.8-27B", + "Qwen/Qwen3.8-Flash", "Qwen/Qwen3.7-Max", "Qwen/Qwen3.7-Plus", "Qwen/Qwen3.7-Flash", diff --git a/tests/fixtures/commandcode-pricing.json b/tests/fixtures/commandcode-pricing.json index c65ae0c..e8a45bb 100644 --- a/tests/fixtures/commandcode-pricing.json +++ b/tests/fixtures/commandcode-pricing.json @@ -19,6 +19,7 @@ "moonshotai/Kimi-K2.7-Code-Highspeed": [1.9, 8, 0.38, 0], "moonshotai/Kimi-K2.6": [0.95, 4, 0.16, 0], "moonshotai/Kimi-K2.5": [0.6, 3, 0.1, 0], + "z-ai/glm-5.3-flash": [0.15, 0.5, 0.03, 0], "zai-org/GLM-5.3": [1.4, 4.4, 0.26, 0], "zai-org/GLM-5.2": [1.4, 4.4, 0.26, 0], "zai-org/GLM-5.2-Fast": [3, 10.25, 0.5, 0], @@ -31,6 +32,7 @@ "xiaomi/mimo-v2.5": [0.14, 0.28, 0.0028, 0], "Qwen/Qwen3.8-Max": [2, 6, 0.25, 2.5], "Qwen/Qwen3.8-27B": [0.4, 3, 0.04, 0], + "Qwen/Qwen3.8-Flash": [0.16, 0.47, 0.016, 0], "Qwen/Qwen3.7-Max": [2.5, 7.5, 0.5, 3.13], "Qwen/Qwen3.7-Plus": [0.4, 1.6, 0.08, 0.5], "Qwen/Qwen3.7-Flash": [0.03, 0.13, 0.006, 0.038], diff --git a/tests/test-pricing.ts b/tests/test-pricing.ts index d7141a5..1cdc3c8 100644 --- a/tests/test-pricing.ts +++ b/tests/test-pricing.ts @@ -144,6 +144,18 @@ describe("MODEL_COSTS pricing overlay", () => { cacheRead: 0.04, cacheWrite: 0, }) + assertCost("Qwen/Qwen3.8-Flash", { + input: 0.16, + output: 0.47, + cacheRead: 0.016, + cacheWrite: 0, + }) + assertCost("z-ai/glm-5.3-flash", { + input: 0.15, + output: 0.5, + cacheRead: 0.03, + cacheWrite: 0, + }) assertCost("google/gemini-3.7-flash", { input: 0.75, output: 3.75, From 21a2c0518525b2c42ded0c3527e6f6aabc7d4429 Mon Sep 17 00:00:00 2001 From: Thomas Byr Date: Fri, 28 Aug 2026 16:41:15 +0200 Subject: [PATCH 05/18] feat(pricing): refresh catalog pricing snapshot Add the free minimax/minimax-m3-free and minimax/minimax-m2.7-free promotional variants (free through September 5, 2026) and tencent/hy4-preview, drop the retired stealth/ox-alpha, and refresh the model-id and pricing snapshots to the current 62-model catalog verified on 2026-08-28. --- src/pricing.ts | 11 +++++++++-- tests/fixtures/commandcode-model-ids.json | 6 ++++-- tests/fixtures/commandcode-pricing.json | 6 ++++-- tests/test-pricing.ts | 16 +++++++++++++--- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/pricing.ts b/src/pricing.ts index 71ba138..e62fbe3 100644 --- a/src/pricing.ts +++ b/src/pricing.ts @@ -20,7 +20,7 @@ export interface TemporaryPricing { } export const PRICING_SOURCE_URL = "https://commandcode.ai/docs/resources/pricing-limits" -export const PRICING_LAST_VERIFIED = "2026-08-25" +export const PRICING_LAST_VERIFIED = "2026-08-28" export const ZERO_MODEL_COST: CommandCodeModelCost = { input: 0, @@ -40,10 +40,12 @@ export const ZERO_MODEL_COST: CommandCodeModelCost = { export const MODEL_COSTS: Readonly> = { // Free models "poolside/laguna-s-2.1-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "stealth/ox-alpha": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + "minimax/minimax-m3-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + "minimax/minimax-m2.7-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, // Open and open-weight models "tencent/hy3-paid": { input: 0.14, output: 0.58, cacheRead: 0.035, cacheWrite: 0 }, + "tencent/hy4-preview": { input: 0.834, output: 2.501, cacheRead: 0.042, cacheWrite: 0 }, "moonshotai/Kimi-K3": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, "moonshotai/Kimi-K2.7-Code": { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 }, "moonshotai/Kimi-K2.7-Code-Highspeed": { @@ -233,4 +235,9 @@ export const TEMPORARY_PRICING: readonly TemporaryPricing[] = [ expiresOn: "2026-12-31", description: "50% promotional pricing", }, + { + models: ["minimax/minimax-m3-free", "minimax/minimax-m2.7-free"], + expiresOn: "2026-09-05", + description: "free promotional pricing", + }, ] diff --git a/tests/fixtures/commandcode-model-ids.json b/tests/fixtures/commandcode-model-ids.json index 15c677c..bced74b 100644 --- a/tests/fixtures/commandcode-model-ids.json +++ b/tests/fixtures/commandcode-model-ids.json @@ -1,5 +1,5 @@ { - "fetchedAt": "2026-08-25T13:32:11.631Z", + "fetchedAt": "2026-08-28T09:27:52.554Z", "source": "https://api.commandcode.ai/provider/v1/models", "modelIds": [ "claude-sonnet-5", @@ -32,6 +32,8 @@ "zai-org/GLM-5", "MiniMaxAI/MiniMax-M3", "MiniMaxAI/MiniMax-M2.7", + "minimax/minimax-m3-free", + "minimax/minimax-m2.7-free", "MiniMaxAI/MiniMax-M2.5", "xiaomi/mimo-v2.5-pro", "xiaomi/mimo-v2.5", @@ -46,6 +48,7 @@ "stepfun/Step-3.7-Flash", "stepfun/Step-3.5-Flash", "tencent/hy3-paid", + "tencent/hy4-preview", "google/gemini-3.7-flash", "google/gemini-3.6-flash", "google/gemini-3.5-flash", @@ -55,7 +58,6 @@ "nvidia/nemotron-3-ultra-550b-a55b", "thinkingmachines/inkling", "thinkingmachines/inkling-small", - "stealth/ox-alpha", "poolside/laguna-s-2.1-free", "meta/muse-spark-1.1", "meta/muse-spark-1.2", diff --git a/tests/fixtures/commandcode-pricing.json b/tests/fixtures/commandcode-pricing.json index e8a45bb..0d2d453 100644 --- a/tests/fixtures/commandcode-pricing.json +++ b/tests/fixtures/commandcode-pricing.json @@ -1,5 +1,5 @@ { - "verifiedAt": "2026-08-25", + "verifiedAt": "2026-08-28", "source": "https://commandcode.ai/docs/resources/pricing-limits", "tierPolicy": "Use request-wide input tiers; the highest threshold exceeded by input plus cache tokens applies to the full request.", "tiers": { @@ -40,12 +40,14 @@ "Qwen/Qwen3.6-Plus": [0.5, 3, 0.1, 0], "stepfun/Step-3.7-Flash": [0.2, 1.15, 0.04, 0], "stepfun/Step-3.5-Flash": [0.1, 0.3, 0.02, 0], + "minimax/minimax-m3-free": [0, 0, 0, 0], + "minimax/minimax-m2.7-free": [0, 0, 0, 0], "tencent/hy3-paid": [0.14, 0.58, 0.035, 0], + "tencent/hy4-preview": [0.834, 2.501, 0.042, 0], "nvidia/nemotron-3-ultra-550b-a55b": [0.6, 2.4, 0.12, 0], "thinkingmachines/inkling": [1, 4.05, 0.17, 0], "thinkingmachines/inkling-small": [0.5, 1.2, 0.1, 0], "poolside/laguna-s-2.1-free": [0, 0, 0, 0], - "stealth/ox-alpha": [0, 0, 0, 0], "claude-sonnet-5": [2, 10, 0.2, 2.5], "claude-sonnet-4-6": [3, 15, 0.3, 3.75], "claude-fable-5": [10, 50, 1, 12.5], diff --git a/tests/test-pricing.ts b/tests/test-pricing.ts index 1cdc3c8..0ec3341 100644 --- a/tests/test-pricing.ts +++ b/tests/test-pricing.ts @@ -27,7 +27,11 @@ const fixtureUrl = new URL("./fixtures/commandcode-model-ids.json", import.meta. const fixture = JSON.parse(await readFile(fixtureUrl, "utf-8")) as ModelCatalogSnapshot const pricingFixtureUrl = new URL("./fixtures/commandcode-pricing.json", import.meta.url) const pricingFixture = JSON.parse(await readFile(pricingFixtureUrl, "utf-8")) as PricingSnapshot -const freeModels = new Set(["poolside/laguna-s-2.1-free", "stealth/ox-alpha"]) +const freeModels = new Set([ + "poolside/laguna-s-2.1-free", + "minimax/minimax-m3-free", + "minimax/minimax-m2.7-free", +]) function assertCost( modelId: string, @@ -50,7 +54,7 @@ function assertCost( describe("MODEL_COSTS pricing overlay", () => { it("covers the current Command Code model catalog snapshot", () => { assert.equal(fixture.source, "https://api.commandcode.ai/provider/v1/models") - assert.match(fixture.fetchedAt, /^2026-08-25T/) + assert.match(fixture.fetchedAt, /^2026-08-28T/) const catalogIds = [...fixture.modelIds].sort() const pricedIds = Object.keys(MODEL_COSTS).sort() @@ -156,6 +160,12 @@ describe("MODEL_COSTS pricing overlay", () => { cacheRead: 0.03, cacheWrite: 0, }) + assertCost("tencent/hy4-preview", { + input: 0.834, + output: 2.501, + cacheRead: 0.042, + cacheWrite: 0, + }) assertCost("google/gemini-3.7-flash", { input: 0.75, output: 3.75, @@ -208,7 +218,7 @@ describe("MODEL_COSTS pricing overlay", () => { it("tracks pricing provenance", () => { assert.equal(PRICING_SOURCE_URL, "https://commandcode.ai/docs/resources/pricing-limits") - assert.equal(PRICING_LAST_VERIFIED, "2026-08-25") + assert.equal(PRICING_LAST_VERIFIED, "2026-08-28") }) it("fails once temporary pricing needs review", () => { From 9945a67bae40741f22b39829550e1025d7fe3e70 Mon Sep 17 00:00:00 2001 From: Thomas Byr Date: Fri, 28 Aug 2026 16:41:32 +0200 Subject: [PATCH 06/18] docs(changelog): document new models and pricing refresh --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index db107f2..1f0a357 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Add `Qwen/Qwen3.8-Flash` and `z-ai/glm-5.3-flash` with their verified reasoning efforts (`low, medium, xhigh` and `low, high, max`) and display pricing. +- Refresh static model capabilities from `command-code@1.36.0`, adding the free `minimax/minimax-m3-free` model and the `z-ai/glm-5.3-flash` output limit while dropping the retired `stealth/ox-alpha`. +- Refresh display pricing for the current 62-model catalog, adding the free `minimax/minimax-m3-free` and `minimax/minimax-m2.7-free` promotional variants (free through September 5, 2026) and `tencent/hy4-preview`, and removing the retired `stealth/ox-alpha`. + ## 0.6.0 - 2026-08-25 - Allow switching from a vision-capable model to a text-only model by omitting historical image tool results while preserving their text output; direct image prompts still fail clearly. From 425483e0e135b44f266e9e148c60e68a4399fb81 Mon Sep 17 00:00:00 2001 From: Thomas Byr Date: Fri, 28 Aug 2026 17:00:29 +0200 Subject: [PATCH 07/18] fix(models): spawn npm through the shell on Windows execFile cannot spawn npm's .cmd shim directly on Windows, so the catalog sync and drift check failed with spawn npm ENOENT. Route npm invocations through the shell with argument quoting on Windows and keep direct execFile calls elsewhere. --- .../check-commandcode-model-metadata.ts | 26 ++++++++++++++++--- CHANGELOG.md | 2 ++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/scripts/check-commandcode-model-metadata.ts b/.github/scripts/check-commandcode-model-metadata.ts index b83c386..543e8e0 100644 --- a/.github/scripts/check-commandcode-model-metadata.ts +++ b/.github/scripts/check-commandcode-model-metadata.ts @@ -18,6 +18,26 @@ const MODELS_REFERENCE_PATH = "dist/bundled/command-code-knowledge/reference/mod const CLI_BUNDLE_PATH = "dist/cli.mjs" const TEXT_ONLY_MARKER = ',__name(isKnownTextOnlyModel,"isKnownTextOnlyModel")' const VALID_EFFORTS = new Set(["minimal", "low", "medium", "high", "xhigh", "max"]) + +function quoteWindowsArgument(argument: string): string { + if (argument.length === 0) return '""' + if (!/[\s"]/.test(argument)) return argument + return `"${argument.replaceAll('"', '\\"')}"` +} + +/** + * Run npm on Windows and POSIX. npm is a `.cmd` shim on Windows, which + * execFile cannot spawn directly, so route it through the shell there with + * shell-safe quoting. + */ +function execNpmFileAsync( + args: readonly string[], + options: { cwd: string; encoding: "utf-8"; maxBuffer?: number }, +): Promise<{ stdout: string }> { + if (process.platform !== "win32") return execFileAsync("npm", args, options) + const command = ["npm", ...args.map(quoteWindowsArgument)].join(" ") + return execFileAsync(command, { ...options, shell: true }) +} const CATALOG_SOURCE_PATH = new URL("../../src/commandcode-catalog.ts", import.meta.url) const README_PATH = new URL("../../README.md", import.meta.url) @@ -417,8 +437,7 @@ async function resolvePackageSpec( ): Promise { if (packageSpec !== "command-code@latest") return packageSpec - const { stdout } = await execFileAsync( - "npm", + const { stdout } = await execNpmFileAsync( ["view", packageSpec, "version", "--json", "--prefer-online", "--cache", npmCacheDirectory], { cwd: directory, @@ -437,8 +456,7 @@ async function inspectPackedPackage(packageSpec: string): Promise<{ try { const resolvedPackageSpec = await resolvePackageSpec(packageSpec, directory, npmCacheDirectory) - const { stdout } = await execFileAsync( - "npm", + const { stdout } = await execNpmFileAsync( ["pack", resolvedPackageSpec, "--json", "--prefer-online", "--cache", npmCacheDirectory], { cwd: directory, diff --git a/CHANGELOG.md b/CHANGELOG.md index db107f2..53a1a73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Fix `npm run sync:commandcode-catalog` and `npm run check:commandcode-catalog` on Windows by spawning npm through the shell. + ## 0.6.0 - 2026-08-25 - Allow switching from a vision-capable model to a text-only model by omitting historical image tool results while preserving their text output; direct image prompts still fail clearly. From 8b5e4d78ba8f921d5195a1a51a84dbb0ee70d2fd Mon Sep 17 00:00:00 2001 From: Thomas Byr Date: Fri, 28 Aug 2026 17:00:39 +0200 Subject: [PATCH 08/18] feat(models): add refresh-model-catalog skill Add an agent skill that walks through a full model catalog refresh: drift detection, catalog sync, manually reviewed pricing updates, fixture refresh, and test updates. Its helper scripts run on Windows and Linux: one snapshots the live model-id list into the test fixture, the other regenerates the pricing fixture from MODEL_COSTS through prettier so format:check stays green. Typecheck now covers the skill scripts. --- .agents/skills/refresh-model-catalog/SKILL.md | 76 +++++++++++++++++++ .../scripts/refresh-model-ids.mjs | 40 ++++++++++ .../scripts/sync-pricing-fixture.ts | 43 +++++++++++ CHANGELOG.md | 1 + tsconfig.json | 2 +- 5 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/refresh-model-catalog/SKILL.md create mode 100644 .agents/skills/refresh-model-catalog/scripts/refresh-model-ids.mjs create mode 100644 .agents/skills/refresh-model-catalog/scripts/sync-pricing-fixture.ts diff --git a/.agents/skills/refresh-model-catalog/SKILL.md b/.agents/skills/refresh-model-catalog/SKILL.md new file mode 100644 index 0000000..8a598f5 --- /dev/null +++ b/.agents/skills/refresh-model-catalog/SKILL.md @@ -0,0 +1,76 @@ +--- +name: refresh-model-catalog +description: Use when adding or removing Command Code models, refreshing the model catalog snapshot (image, reasoning, effort, output-limit metadata), updating display pricing, or refreshing test fixtures in pi-commandcode-provider. +--- + +# Refresh Model Catalog + +Use this skill whenever the Command Code model catalog changes: new or retired models, changed reasoning efforts, output limits, or pricing. All commands run from the repository root and work on Windows and Linux. + +## Core rules + +- Do not commit, tag, push, or publish unless the user explicitly asks in the current conversation. +- Pricing is manually reviewed: temporary promotions and long-context tiers require explicit review of the official pricing page. Never copy prices blindly from the API. +- Keep the change focused: one refresh per PR, no unrelated refactors. +- Follow [CONTRIBUTING.md](../../../CONTRIBUTING.md) for commit message rules. + +## Workflow + +### 1. Detect drift + +```sh +npm run check:commandcode-catalog +``` + +This compares the repository snapshot against the latest published `command-code` npm package and reports added/removed models, changed efforts, and version drift. Use the report to scope the work. + +### 2. Sync static model metadata + +```sh +npm run sync:commandcode-catalog +``` + +Regenerates `src/commandcode-catalog.ts` and bumps the documented CLI version in `README.md`. Review the diff; the catalog also lists reasoning models without selectable efforts. + +### 3. Update display pricing (manual review) + +Fetch and compare against `src/pricing.ts`: + +- Add entries for new models and remove entries for retired models. Missing models silently display zero cost, so `MODEL_COSTS` must cover the full catalog. +- The pricing page's "Cache Read"/"Cache Write" columns map to `cacheRead`/`cacheWrite`; a "—" column means `0`. +- Update `PRICING_LAST_VERIFIED` to today's date. +- Add or update `TEMPORARY_PRICING` entries for promotions with an end date, so tests fail when they expire. + +### 4. Refresh the test fixtures + +```sh +node .agents/skills/refresh-model-catalog/scripts/refresh-model-ids.mjs +npx tsx .agents/skills/refresh-model-catalog/scripts/sync-pricing-fixture.ts +``` + +The first script snapshots the live model-id list into `tests/fixtures/commandcode-model-ids.json`; the second regenerates `tests/fixtures/commandcode-pricing.json` from `MODEL_COSTS`. The pricing test fails until `MODEL_COSTS` matches the catalog snapshot exactly. + +### 5. Update test expectations + +Adjust the model-specific assertions that the refresh invalidated, typically in: + +- `tests/test-pricing.ts`: fixture date assertions, the `freeModels` set, and per-model rate assertions. +- `tests/test-models.ts`: image/reasoning/effort/output-limit assertions and catalog entry counts. + +Do not weaken assertions to make them pass; update them to the verified upstream values. + +### 6. Validate + +```sh +npm run test:models +npm run test:pricing +npm run typecheck +npm run format:check +git diff --check +``` + +Run the full `npm test` before reporting the work as done when the environment allows it. + +### 7. Document + +Add entries to the `Unreleased` section of `CHANGELOG.md` covering new/retired models, effort changes, and pricing refreshes. diff --git a/.agents/skills/refresh-model-catalog/scripts/refresh-model-ids.mjs b/.agents/skills/refresh-model-catalog/scripts/refresh-model-ids.mjs new file mode 100644 index 0000000..f9be878 --- /dev/null +++ b/.agents/skills/refresh-model-catalog/scripts/refresh-model-ids.mjs @@ -0,0 +1,40 @@ +#!/usr/bin/env node +// Refreshes tests/fixtures/commandcode-model-ids.json from the live Command Code +// models API. Run from the repository root: +// node .agents/skills/refresh-model-catalog/scripts/refresh-model-ids.mjs +import { writeFile } from "node:fs/promises" + +import { format, resolveConfig } from "prettier" + +const MODELS_URL = "https://api.commandcode.ai/provider/v1/models" +const FIXTURE_PATH = new URL( + "../../../../tests/fixtures/commandcode-model-ids.json", + import.meta.url, +) + +const response = await fetch(MODELS_URL) +if (!response.ok) { + throw new Error(`Failed to fetch Command Code models: ${response.status} ${response.statusText}`) +} + +const body = await response.json() +if (body?.object !== "list" || !Array.isArray(body.data)) { + throw new Error("Expected a Command Code models list response") +} + +const modelIds = body.data.map((model) => { + if (typeof model?.id !== "string" || model.id.length === 0) { + throw new Error("Expected each model entry to have a non-empty id") + } + return model.id +}) +if (modelIds.length === 0) throw new Error("Command Code returned an empty model catalog") + +const fixture = { fetchedAt: new Date().toISOString(), source: MODELS_URL, modelIds } +const options = await resolveConfig(new URL("../../../../.prettierrc.json", import.meta.url)) +const contents = await format(JSON.stringify(fixture), { + ...options, + filepath: "commandcode-model-ids.json", +}) +await writeFile(FIXTURE_PATH, contents, "utf-8") +console.log(`Wrote ${modelIds.length} model ids to tests/fixtures/commandcode-model-ids.json`) diff --git a/.agents/skills/refresh-model-catalog/scripts/sync-pricing-fixture.ts b/.agents/skills/refresh-model-catalog/scripts/sync-pricing-fixture.ts new file mode 100644 index 0000000..21d8773 --- /dev/null +++ b/.agents/skills/refresh-model-catalog/scripts/sync-pricing-fixture.ts @@ -0,0 +1,43 @@ +// Regenerates tests/fixtures/commandcode-pricing.json from src/pricing.ts so the +// snapshot always matches MODEL_COSTS. Run from the repository root: +// npx tsx .agents/skills/refresh-model-catalog/scripts/sync-pricing-fixture.ts +import { writeFile } from "node:fs/promises" + +import { format, resolveConfig } from "prettier" + +import { MODEL_COSTS, PRICING_LAST_VERIFIED, PRICING_SOURCE_URL } from "../../../../src/pricing.ts" + +const FIXTURE_PATH = new URL("../../../../tests/fixtures/commandcode-pricing.json", import.meta.url) + +const costs: Record = {} +const tiers: Record = {} +for (const [modelId, cost] of Object.entries(MODEL_COSTS)) { + costs[modelId] = [cost.input, cost.output, cost.cacheRead, cost.cacheWrite] + if (cost.tiers) { + tiers[modelId] = cost.tiers.map((tier) => [ + tier.inputTokensAbove, + tier.input, + tier.output, + tier.cacheRead, + tier.cacheWrite, + ]) + } +} + +const fixture = { + verifiedAt: PRICING_LAST_VERIFIED, + source: PRICING_SOURCE_URL, + tierPolicy: + "Use request-wide input tiers; the highest threshold exceeded by input plus cache tokens applies to the full request.", + tiers, + costs, +} +const options = await resolveConfig(new URL("../../../../.prettierrc.json", import.meta.url)) +const contents = await format(JSON.stringify(fixture), { + ...options, + filepath: "commandcode-pricing.json", +}) +await writeFile(FIXTURE_PATH, contents, "utf-8") +console.log( + `Wrote ${Object.keys(costs).length} model prices to tests/fixtures/commandcode-pricing.json`, +) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53a1a73..0cc7cea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Fix `npm run sync:commandcode-catalog` and `npm run check:commandcode-catalog` on Windows by spawning npm through the shell. +- Add a `refresh-model-catalog` agent skill with cross-platform helper scripts that snapshot the live model catalog and regenerate the pricing fixture from `MODEL_COSTS`. ## 0.6.0 - 2026-08-25 diff --git a/tsconfig.json b/tsconfig.json index 2cf50c2..aac5ebe 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,5 +9,5 @@ "strict": true, "types": ["node"] }, - "include": [".github/scripts/**/*.ts", "src/**/*.ts", "tests/**/*.ts"] + "include": [".github/scripts/**/*.ts", ".agents/skills/**/*.ts", "src/**/*.ts", "tests/**/*.ts"] } From 37cfd9fef8d5cbd41a5d30cf86aad838cc238518 Mon Sep 17 00:00:00 2001 From: hjshin-ubob Date: Mon, 31 Aug 2026 11:19:52 +0900 Subject: [PATCH 09/18] feat(catalog): add reasoning efforts for meta/muse-spark-1.1/1.2/contributor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maps the three Muse Spark models to [minimal, low, medium, high, xhigh] so pi-ai can surface graded thinking levels instead of off-only. These ids are already MODEL_REASONING=true; without MODEL_EFFORTS, thinkingMetadataForModel() returns an empty map and the footer stays :off with no cycle via /thinking or Shift+Tab. Upstream command-code@1.32.2 lists these models as Efforts: — (none); this commit intentionally adds a pi-side policy mapping aligned with the opencode/opencode-go provider catalogs (which ship muse-spark@ minimal..xhigh). The catalog header notes it is normally generated by sync:commandcode-catalog — happy to move this to a separate override layer if maintainers prefer. --- src/commandcode-catalog.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/commandcode-catalog.ts b/src/commandcode-catalog.ts index 6840f5f..6b7c073 100644 --- a/src/commandcode-catalog.ts +++ b/src/commandcode-catalog.ts @@ -124,6 +124,9 @@ export const MODEL_EFFORTS: Readonly Date: Tue, 1 Sep 2026 23:00:12 +0200 Subject: [PATCH 10/18] test(models): assert catalog invariants instead of pinned model ids The model catalog tests hard-coded specific model ids, the reasoning model count, and output limits from command-code@1.32.2. Every upstream catalog sync broke them, which made the daily catalog sync workflow fail before it could open its PR. Assert structural invariants over the generated catalog instead: image models resolve to text+image, every effort entry has a reasoning flag, reasoning without efforts yields an empty level map, output limits are positive integers and clamp to the context length. Closes #67 (cherry picked from commit 458e3a57892bb625a78fb5ded092627ba3cf2867) --- tests/test-models.ts | 97 +++++++++++++++++++------------------------- 1 file changed, 42 insertions(+), 55 deletions(-) diff --git a/tests/test-models.ts b/tests/test-models.ts index 5773221..c5573dc 100644 --- a/tests/test-models.ts +++ b/tests/test-models.ts @@ -104,43 +104,48 @@ describe("commandCodeModelsFromApiResponse()", () => { }) it(`uses the command-code@${COMMAND_CODE_CLI_VERSION} image capability catalog`, () => { - assert.deepEqual(inputModalitiesForModel("gpt-5.6-luna"), ["text", "image"]) - assert.deepEqual(inputModalitiesForModel("meta/muse-spark-1.2"), ["text", "image"]) - assert.deepEqual(inputModalitiesForModel("deepseek/deepseek-v4-flash-vision-exp"), [ - "text", - "image", - ]) - assert.deepEqual(inputModalitiesForModel("Qwen/Qwen3.8-27B"), ["text", "image"]) - assert.deepEqual(inputModalitiesForModel("google/gemini-3.7-flash"), ["text", "image"]) - assert.deepEqual(inputModalitiesForModel("Qwen/Qwen3.8-Flash"), ["text", "image"]) - assert.deepEqual(inputModalitiesForModel("z-ai/glm-5.3-flash"), ["text", "image"]) - assert.deepEqual(inputModalitiesForModel("minimax/minimax-m3-free"), ["text", "image"]) - assert.deepEqual(inputModalitiesForModel("deepseek/deepseek-v4-pro"), ["text"]) - assert.deepEqual(inputModalitiesForModel("zai-org/GLM-5.3"), ["text"]) - assert.deepEqual(inputModalitiesForModel("unknown-new-model"), ["text"]) - assert.equal(modelSupportsImageInput("gpt-5.6-luna"), true) - assert.equal(modelSupportsImageInput("deepseek/deepseek-v4-flash-vision-exp"), true) - assert.equal(modelSupportsImageInput("z-ai/glm-5.3-flash"), true) - assert.equal(modelSupportsImageInput("deepseek/deepseek-v4-pro"), false) - assert.ok(Object.keys(MODEL_INPUT_MODALITIES).length > 0) - for (const modalities of Object.values(MODEL_INPUT_MODALITIES)) { - assert.deepEqual(modalities, ["text", "image"]) + const imageModels = Object.keys(MODEL_INPUT_MODALITIES) + assert.ok(imageModels.length > 0) + for (const modelId of imageModels) { + assert.deepEqual(MODEL_INPUT_MODALITIES[modelId], ["text", "image"], modelId) + assert.deepEqual(inputModalitiesForModel(modelId), ["text", "image"], modelId) + assert.equal(modelSupportsImageInput(modelId), true, modelId) } + + const textOnlyModel = Object.keys(MODEL_REASONING).find( + (modelId) => !(modelId in MODEL_INPUT_MODALITIES), + ) + assert.ok(textOnlyModel, "catalog should contain at least one text-only model") + assert.deepEqual(inputModalitiesForModel(textOnlyModel), ["text"]) + assert.equal(modelSupportsImageInput(textOnlyModel), false) + assert.deepEqual(inputModalitiesForModel("unknown-new-model"), ["text"]) + assert.equal(modelSupportsImageInput("unknown-new-model"), false) }) it("tracks reasoning independently from selectable effort levels", () => { + const reasoningModels = Object.keys(MODEL_REASONING) + const effortModels = Object.keys(MODEL_EFFORTS) + assert.ok(reasoningModels.length > 0) + assert.ok(effortModels.length > 0) + for (const modelId of effortModels) { + assert.equal(MODEL_REASONING[modelId], true, `${modelId} has efforts but no reasoning flag`) + } + + const reasoningWithoutEfforts = reasoningModels.find((modelId) => !(modelId in MODEL_EFFORTS)) + assert.ok(reasoningWithoutEfforts, "catalog should contain a reasoning model without efforts") + const models = commandCodeModelsFromApiResponse({ object: "list", data: [ - { ...API_RESPONSE.data[0], id: "deepseek/deepseek-v4-flash" }, - { ...API_RESPONSE.data[0], id: "moonshotai/Kimi-K3" }, + { ...API_RESPONSE.data[0], id: effortModels[0] }, + { ...API_RESPONSE.data[0], id: reasoningWithoutEfforts }, { ...API_RESPONSE.data[0], id: "new-model-without-metadata" }, ], }) assert.equal(models[0]?.reasoning, true) assert.equal(models[1]?.reasoning, true) - assert.deepEqual(thinkingMetadataForModel("moonshotai/Kimi-K3"), { + assert.deepEqual(thinkingMetadataForModel(reasoningWithoutEfforts), { thinkingLevelMap: { minimal: null, low: null, @@ -151,32 +156,30 @@ describe("commandCodeModelsFromApiResponse()", () => { }, }) assert.equal(models[2]?.reasoning, false) - assert.equal(Object.keys(MODEL_REASONING).length, 50) }) it("uses model-specific output limits from the CLI catalog", () => { + const limitedModels = Object.entries(MODEL_MAX_OUTPUT_TOKENS) + assert.ok(limitedModels.length > 0) + for (const [modelId, limit] of limitedModels) { + assert.ok(Number.isInteger(limit) && limit > 0, `${modelId} has an invalid output limit`) + } + + const [limitedId, limit] = limitedModels[0]! const models = commandCodeModelsFromApiResponse({ object: "list", data: [ - { ...API_RESPONSE.data[0], id: "Qwen/Qwen3.8-27B", context_length: 262_144 }, - { ...API_RESPONSE.data[0], id: "z-ai/glm-5.3-flash", context_length: 1_048_576 }, - { - ...API_RESPONSE.data[0], - id: "poolside/laguna-s-2.1-free", - context_length: 256_000, - }, + { ...API_RESPONSE.data[0], id: limitedId, context_length: limit * 4 }, + { ...API_RESPONSE.data[0], id: limitedId, context_length: Math.floor(limit / 2) }, + { ...API_RESPONSE.data[0], id: "unknown-new-model", context_length: 256_000 }, + { ...API_RESPONSE.data[0], id: "unknown-new-model", context_length: 8_192 }, ], }) assert.deepEqual( - models.map(({ id, maxTokens }) => ({ id, maxTokens })), - [ - { id: "Qwen/Qwen3.8-27B", maxTokens: 32_768 }, - { id: "z-ai/glm-5.3-flash", maxTokens: 131_072 }, - { id: "poolside/laguna-s-2.1-free", maxTokens: 32_768 }, - ], + models.map(({ maxTokens }) => maxTokens), + [limit, Math.floor(limit / 2), 65_536, 8_192], ) - assert.equal(Object.keys(MODEL_MAX_OUTPUT_TOKENS).length, 3) }) it(`uses the command-code@${COMMAND_CODE_CLI_VERSION} reasoning effort catalog`, () => { @@ -219,22 +222,6 @@ describe("commandCodeModelsFromApiResponse()", () => { xhigh: null, max: "max", }) - assert.deepEqual(thinkingLevelMapForEfforts(MODEL_EFFORTS["Qwen/Qwen3.8-Flash"]), { - minimal: null, - low: "low", - medium: "medium", - high: null, - xhigh: "xhigh", - max: null, - }) - assert.deepEqual(thinkingLevelMapForEfforts(MODEL_EFFORTS["z-ai/glm-5.3-flash"]), { - minimal: null, - low: "low", - medium: null, - high: "high", - xhigh: null, - max: "max", - }) assert.deepEqual(thinkingMetadataForModel("new-model-without-metadata"), undefined) }) From b6e0e07c01f3f0919501b6adde6f3be596a818e6 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Tue, 1 Sep 2026 23:29:16 +0200 Subject: [PATCH 11/18] feat(models): sync model catalog with command-code@1.40.1 Add claude-fable-5-1, deepseek/deepseek-v4-flash-fast, and tencent/hy4-preview with their reasoning efforts, add moonshotai/Kimi-K3 efforts, and drop minimax/minimax-m3-free, which the CLI no longer lists. --- README.md | 2 +- src/commandcode-catalog.ts | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a39510c..32a54d2 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ The following environment variables are intended for tests, local mocks, and com ## Image input -The provider advertises image input only for models marked with the `image` input modality in the official Command Code CLI model catalog. The capability snapshot currently follows `command-code@1.36.0`; unknown models default to text-only until their upstream metadata is reviewed. A daily GitHub Actions job synchronizes the CLI version, image capabilities, reasoning flags, reasoning efforts, and model-specific output limits with the latest published CLI package and opens or updates a reviewable pull request when they change. Pricing remains manually reviewed because temporary promotions and long-context tiers require explicit review. +The provider advertises image input only for models marked with the `image` input modality in the official Command Code CLI model catalog. The capability snapshot currently follows `command-code@1.40.1`; unknown models default to text-only until their upstream metadata is reviewed. A daily GitHub Actions job synchronizes the CLI version, image capabilities, reasoning flags, reasoning efforts, and model-specific output limits with the latest published CLI package and opens or updates a reviewable pull request when they change. Pricing remains manually reviewed because temporary promotions and long-context tiers require explicit review. For vision-capable models, Pi's native provider adapters forward image blocks from user messages and tool results using the documented OpenAI or Anthropic message schema. Unknown and text-only models remain marked text-only in Pi. diff --git a/src/commandcode-catalog.ts b/src/commandcode-catalog.ts index a136a8b..19db48f 100644 --- a/src/commandcode-catalog.ts +++ b/src/commandcode-catalog.ts @@ -1,14 +1,15 @@ -export const COMMAND_CODE_CLI_VERSION = "1.36.0" +export const COMMAND_CODE_CLI_VERSION = "1.40.1" export type CommandCodeInputType = "text" | "image" export type CommandCodeReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh" | "max" /** - * Generated from command-code@1.36.0 by `npm run sync:commandcode-catalog`. + * Generated from command-code@1.40.1 by `npm run sync:commandcode-catalog`. * Do not edit manually. */ export const MODEL_INPUT_MODALITIES: Readonly> = { "claude-fable-5": ["text", "image"], + "claude-fable-5-1": ["text", "image"], "claude-haiku-4-5-20251001": ["text", "image"], "claude-opus-4-7": ["text", "image"], "claude-opus-4-8": ["text", "image"], @@ -31,7 +32,6 @@ export const MODEL_INPUT_MODALITIES: Readonly> = { "claude-fable-5": true, + "claude-fable-5-1": true, "claude-opus-4-7": true, "claude-opus-4-8": true, "claude-opus-5": true, "claude-sonnet-4-6": true, "claude-sonnet-5": true, "deepseek/deepseek-v4-flash": true, + "deepseek/deepseek-v4-flash-fast": true, "deepseek/deepseek-v4-flash-vision-exp": true, "deepseek/deepseek-v4-pro": true, "google/gemini-3.1-flash-lite": true, @@ -78,7 +80,6 @@ export const MODEL_REASONING: Readonly> = { "meta/muse-spark-1.1": true, "meta/muse-spark-1.2": true, "meta/muse-spark-1.2-contributor": true, - "minimax/minimax-m3-free": true, "MiniMaxAI/MiniMax-M3": true, "moonshotai/Kimi-K2.7-Code": true, "moonshotai/Kimi-K2.7-Code-Highspeed": true, @@ -97,6 +98,7 @@ export const MODEL_REASONING: Readonly> = { "stepfun/Step-3.5-Flash": true, "stepfun/Step-3.7-Flash": true, "tencent/hy3-paid": true, + "tencent/hy4-preview": true, "thinkingmachines/inkling": true, "thinkingmachines/inkling-small": true, "xai/grok-4.5": true, @@ -108,12 +110,14 @@ export const MODEL_REASONING: Readonly> = { export const MODEL_EFFORTS: Readonly> = { "claude-fable-5": ["low", "medium", "high", "xhigh", "max"], + "claude-fable-5-1": ["low", "medium", "high", "xhigh", "max"], "claude-opus-4-7": ["low", "medium", "high", "xhigh", "max"], "claude-opus-4-8": ["low", "medium", "high", "xhigh", "max"], "claude-opus-5": ["low", "medium", "high", "xhigh", "max"], "claude-sonnet-4-6": ["low", "medium", "high", "xhigh", "max"], "claude-sonnet-5": ["low", "medium", "high", "xhigh", "max"], "deepseek/deepseek-v4-flash": ["high", "max"], + "deepseek/deepseek-v4-flash-fast": ["low", "high", "max"], "deepseek/deepseek-v4-flash-vision-exp": ["high", "max"], "deepseek/deepseek-v4-pro": ["high", "max"], "google/gemini-3.1-flash-lite": ["low", "medium", "high"], @@ -128,10 +132,12 @@ export const MODEL_EFFORTS: Readonly Date: Tue, 1 Sep 2026 23:29:16 +0200 Subject: [PATCH 12/18] fix(pricing): refresh catalog pricing snapshot Verify display pricing against the official pricing page and the live provider model list: - add claude-fable-5-1 and deepseek/deepseek-v4-flash-fast - remove minimax/minimax-m3-free and minimax/minimax-m2.7-free, which the provider no longer lists - end the claude-sonnet-5 introductory window; the listed rate is unchanged - google/gemini-3.7-flash returns to list price after the promotion ended --- CHANGELOG.md | 4 +-- src/pricing.ts | 38 ++++++++--------------- tests/fixtures/commandcode-model-ids.json | 6 ++-- tests/fixtures/commandcode-pricing.json | 32 +++++++++---------- tests/test-pricing.ts | 30 +++++++++++------- 5 files changed, 53 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c33339f..38b745e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ - Stop silently dropping `role: "developer"` messages (for example OMP advisor steering notes, reminders, and nudges). `/alpha/generate` only accepts `user`, `assistant`, and `tool` roles, so developer messages are now forwarded as `user` messages with identical content in the same chronological position instead of disappearing from the request. - Add `Qwen/Qwen3.8-Flash` and `z-ai/glm-5.3-flash` with their verified reasoning efforts (`low, medium, xhigh` and `low, high, max`) and display pricing. -- Refresh static model capabilities from `command-code@1.36.0`, adding the free `minimax/minimax-m3-free` model and the `z-ai/glm-5.3-flash` output limit while dropping the retired `stealth/ox-alpha`. -- Refresh display pricing for the current 62-model catalog, adding the free `minimax/minimax-m3-free` and `minimax/minimax-m2.7-free` promotional variants (free through September 5, 2026) and `tencent/hy4-preview`, and removing the retired `stealth/ox-alpha`. +- Refresh static model capabilities from `command-code@1.40.1`, adding `claude-fable-5-1`, `deepseek/deepseek-v4-flash-fast`, and `tencent/hy4-preview` with their reasoning efforts, adding `moonshotai/Kimi-K3` efforts and the `z-ai/glm-5.3-flash` output limit, and dropping the retired `stealth/ox-alpha` and `minimax/minimax-m3-free`. +- Refresh display pricing for the current 62-model catalog, adding `claude-fable-5-1`, `deepseek/deepseek-v4-flash-fast`, and `tencent/hy4-preview`, removing the retired `stealth/ox-alpha`, `minimax/minimax-m3-free`, and `minimax/minimax-m2.7-free`, and ending the expired Claude Sonnet 5 introductory and Gemini 3.7 Flash promotional windows. - Fix `npm run sync:commandcode-catalog` and `npm run check:commandcode-catalog` on Windows by spawning npm through the shell. - Add a `refresh-model-catalog` agent skill with cross-platform helper scripts that snapshot the live model catalog and regenerate the pricing fixture from `MODEL_COSTS`. diff --git a/src/pricing.ts b/src/pricing.ts index e62fbe3..583e294 100644 --- a/src/pricing.ts +++ b/src/pricing.ts @@ -20,7 +20,7 @@ export interface TemporaryPricing { } export const PRICING_SOURCE_URL = "https://commandcode.ai/docs/resources/pricing-limits" -export const PRICING_LAST_VERIFIED = "2026-08-28" +export const PRICING_LAST_VERIFIED = "2026-09-01" export const ZERO_MODEL_COST: CommandCodeModelCost = { input: 0, @@ -40,8 +40,6 @@ export const ZERO_MODEL_COST: CommandCodeModelCost = { export const MODEL_COSTS: Readonly> = { // Free models "poolside/laguna-s-2.1-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "minimax/minimax-m3-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "minimax/minimax-m2.7-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, // Open and open-weight models "tencent/hy3-paid": { input: 0.14, output: 0.58, cacheRead: 0.035, cacheWrite: 0 }, @@ -85,6 +83,12 @@ export const MODEL_COSTS: Readonly> = { cacheRead: 0.007, cacheWrite: 0, }, + "deepseek/deepseek-v4-flash-fast": { + input: 0.28, + output: 0.56, + cacheRead: 0.07, + cacheWrite: 0, + }, "Qwen/Qwen3.8-Max": { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 2.5 }, "Qwen/Qwen3.8-27B": { input: 0.4, output: 3, cacheRead: 0.04, cacheWrite: 0 }, "Qwen/Qwen3.8-Flash": { input: 0.16, output: 0.47, cacheRead: 0.016, cacheWrite: 0 }, @@ -162,9 +166,9 @@ export const MODEL_COSTS: Readonly> = { }, // Anthropic - // Introductory pricing through 2026-08-31. "claude-sonnet-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + "claude-fable-5-1": { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 }, "claude-fable-5": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, "claude-opus-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, "claude-opus-4-8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, @@ -187,10 +191,10 @@ export const MODEL_COSTS: Readonly> = { // Google and xAI "google/gemini-3.7-flash": { - input: 0.75, - output: 3.75, - cacheRead: 0.075, - cacheWrite: 0.04167, + input: 1.5, + output: 7.5, + cacheRead: 0.15, + cacheWrite: 0.08334, }, "google/gemini-3.6-flash": { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0 }, "google/gemini-3.5-flash": { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0 }, @@ -224,20 +228,4 @@ export const MODEL_COSTS: Readonly> = { }, } -export const TEMPORARY_PRICING: readonly TemporaryPricing[] = [ - { - models: ["claude-sonnet-5"], - expiresOn: "2026-08-31", - description: "introductory pricing", - }, - { - models: ["google/gemini-3.7-flash"], - expiresOn: "2026-12-31", - description: "50% promotional pricing", - }, - { - models: ["minimax/minimax-m3-free", "minimax/minimax-m2.7-free"], - expiresOn: "2026-09-05", - description: "free promotional pricing", - }, -] +export const TEMPORARY_PRICING: readonly TemporaryPricing[] = [] diff --git a/tests/fixtures/commandcode-model-ids.json b/tests/fixtures/commandcode-model-ids.json index bced74b..a6f9aa7 100644 --- a/tests/fixtures/commandcode-model-ids.json +++ b/tests/fixtures/commandcode-model-ids.json @@ -1,9 +1,10 @@ { - "fetchedAt": "2026-08-28T09:27:52.554Z", + "fetchedAt": "2026-09-01T21:28:23.974Z", "source": "https://api.commandcode.ai/provider/v1/models", "modelIds": [ "claude-sonnet-5", "claude-sonnet-4-6", + "claude-fable-5-1", "claude-fable-5", "claude-opus-5", "claude-opus-4-8", @@ -19,6 +20,7 @@ "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-flash-vision-exp", + "deepseek/deepseek-v4-flash-fast", "moonshotai/Kimi-K3", "moonshotai/Kimi-K2.7-Code", "moonshotai/Kimi-K2.7-Code-Highspeed", @@ -32,8 +34,6 @@ "zai-org/GLM-5", "MiniMaxAI/MiniMax-M3", "MiniMaxAI/MiniMax-M2.7", - "minimax/minimax-m3-free", - "minimax/minimax-m2.7-free", "MiniMaxAI/MiniMax-M2.5", "xiaomi/mimo-v2.5-pro", "xiaomi/mimo-v2.5", diff --git a/tests/fixtures/commandcode-pricing.json b/tests/fixtures/commandcode-pricing.json index 0d2d453..0fe4534 100644 --- a/tests/fixtures/commandcode-pricing.json +++ b/tests/fixtures/commandcode-pricing.json @@ -1,5 +1,5 @@ { - "verifiedAt": "2026-08-28", + "verifiedAt": "2026-09-01", "source": "https://commandcode.ai/docs/resources/pricing-limits", "tierPolicy": "Use request-wide input tiers; the highest threshold exceeded by input plus cache tokens applies to the full request.", "tiers": { @@ -11,9 +11,9 @@ "xai/grok-4.6": [[200000, 4, 12, 1, 0]] }, "costs": { - "deepseek/deepseek-v4-pro": [0.66, 1.98, 0.022, 0], - "deepseek/deepseek-v4-flash": [0.22, 0.66, 0.007, 0], - "deepseek/deepseek-v4-flash-vision-exp": [0.22, 0.66, 0.007, 0], + "poolside/laguna-s-2.1-free": [0, 0, 0, 0], + "tencent/hy3-paid": [0.14, 0.58, 0.035, 0], + "tencent/hy4-preview": [0.834, 2.501, 0.042, 0], "moonshotai/Kimi-K3": [3, 15, 0.3, 0], "moonshotai/Kimi-K2.7-Code": [0.95, 4, 0.19, 0], "moonshotai/Kimi-K2.7-Code-Highspeed": [1.9, 8, 0.38, 0], @@ -28,8 +28,10 @@ "MiniMaxAI/MiniMax-M3": [0.3, 1.2, 0.06, 0], "MiniMaxAI/MiniMax-M2.7": [0.3, 1.2, 0.06, 0], "MiniMaxAI/MiniMax-M2.5": [0.3, 1.2, 0.03, 0], - "xiaomi/mimo-v2.5-pro": [0.435, 0.87, 0.0036, 0], - "xiaomi/mimo-v2.5": [0.14, 0.28, 0.0028, 0], + "deepseek/deepseek-v4-pro": [0.66, 1.98, 0.022, 0], + "deepseek/deepseek-v4-flash": [0.22, 0.66, 0.007, 0], + "deepseek/deepseek-v4-flash-vision-exp": [0.22, 0.66, 0.007, 0], + "deepseek/deepseek-v4-flash-fast": [0.28, 0.56, 0.07, 0], "Qwen/Qwen3.8-Max": [2, 6, 0.25, 2.5], "Qwen/Qwen3.8-27B": [0.4, 3, 0.04, 0], "Qwen/Qwen3.8-Flash": [0.16, 0.47, 0.016, 0], @@ -40,16 +42,18 @@ "Qwen/Qwen3.6-Plus": [0.5, 3, 0.1, 0], "stepfun/Step-3.7-Flash": [0.2, 1.15, 0.04, 0], "stepfun/Step-3.5-Flash": [0.1, 0.3, 0.02, 0], - "minimax/minimax-m3-free": [0, 0, 0, 0], - "minimax/minimax-m2.7-free": [0, 0, 0, 0], - "tencent/hy3-paid": [0.14, 0.58, 0.035, 0], - "tencent/hy4-preview": [0.834, 2.501, 0.042, 0], + "xiaomi/mimo-v2.5-pro": [0.435, 0.87, 0.0036, 0], + "xiaomi/mimo-v2.5": [0.14, 0.28, 0.0028, 0], "nvidia/nemotron-3-ultra-550b-a55b": [0.6, 2.4, 0.12, 0], + "sakana/fugu-ultra": [5, 30, 0.5, 0], "thinkingmachines/inkling": [1, 4.05, 0.17, 0], "thinkingmachines/inkling-small": [0.5, 1.2, 0.1, 0], - "poolside/laguna-s-2.1-free": [0, 0, 0, 0], + "meta/muse-spark-1.1": [1.25, 4.25, 0.15, 0], + "meta/muse-spark-1.2": [1.25, 4.25, 0.15, 0], + "meta/muse-spark-1.2-contributor": [0.1, 0.2, 0.002, 0], "claude-sonnet-5": [2, 10, 0.2, 2.5], "claude-sonnet-4-6": [3, 15, 0.3, 3.75], + "claude-fable-5-1": [10, 50, 0.25, 12.5], "claude-fable-5": [10, 50, 1, 12.5], "claude-opus-5": [5, 25, 0.5, 6.25], "claude-opus-4-8": [5, 25, 0.5, 6.25], @@ -62,15 +66,11 @@ "gpt-5.4": [2.5, 15, 0.25, 0], "gpt-5.3-codex": [2, 8, 0.5, 0], "gpt-5.4-mini": [0.75, 4.5, 0.075, 0], - "google/gemini-3.7-flash": [0.75, 3.75, 0.075, 0.04167], + "google/gemini-3.7-flash": [1.5, 7.5, 0.15, 0.08334], "google/gemini-3.6-flash": [1.5, 7.5, 0.15, 0], "google/gemini-3.5-flash": [1.5, 9, 0.15, 0], "google/gemini-3.5-flash-lite": [0.3, 2.5, 0.03, 0], "google/gemini-3.1-flash-lite": [0.25, 1.5, 0.03, 0], - "sakana/fugu-ultra": [5, 30, 0.5, 0], - "meta/muse-spark-1.1": [1.25, 4.25, 0.15, 0], - "meta/muse-spark-1.2": [1.25, 4.25, 0.15, 0], - "meta/muse-spark-1.2-contributor": [0.1, 0.2, 0.002, 0], "xai/grok-4.5": [2, 6, 0.5, 0], "xai/grok-4.6": [2, 6, 0.5, 0] } diff --git a/tests/test-pricing.ts b/tests/test-pricing.ts index 0ec3341..73b8b75 100644 --- a/tests/test-pricing.ts +++ b/tests/test-pricing.ts @@ -27,11 +27,7 @@ const fixtureUrl = new URL("./fixtures/commandcode-model-ids.json", import.meta. const fixture = JSON.parse(await readFile(fixtureUrl, "utf-8")) as ModelCatalogSnapshot const pricingFixtureUrl = new URL("./fixtures/commandcode-pricing.json", import.meta.url) const pricingFixture = JSON.parse(await readFile(pricingFixtureUrl, "utf-8")) as PricingSnapshot -const freeModels = new Set([ - "poolside/laguna-s-2.1-free", - "minimax/minimax-m3-free", - "minimax/minimax-m2.7-free", -]) +const freeModels = new Set(["poolside/laguna-s-2.1-free"]) function assertCost( modelId: string, @@ -54,7 +50,7 @@ function assertCost( describe("MODEL_COSTS pricing overlay", () => { it("covers the current Command Code model catalog snapshot", () => { assert.equal(fixture.source, "https://api.commandcode.ai/provider/v1/models") - assert.match(fixture.fetchedAt, /^2026-08-28T/) + assert.match(fixture.fetchedAt, /^2026-09-01T/) const catalogIds = [...fixture.modelIds].sort() const pricedIds = Object.keys(MODEL_COSTS).sort() @@ -167,10 +163,22 @@ describe("MODEL_COSTS pricing overlay", () => { cacheWrite: 0, }) assertCost("google/gemini-3.7-flash", { - input: 0.75, - output: 3.75, - cacheRead: 0.075, - cacheWrite: 0.04167, + input: 1.5, + output: 7.5, + cacheRead: 0.15, + cacheWrite: 0.08334, + }) + assertCost("claude-fable-5-1", { + input: 10, + output: 50, + cacheRead: 0.25, + cacheWrite: 12.5, + }) + assertCost("deepseek/deepseek-v4-flash-fast", { + input: 0.28, + output: 0.56, + cacheRead: 0.07, + cacheWrite: 0, }) assertCost("meta/muse-spark-1.2-contributor", { input: 0.1, @@ -218,7 +226,7 @@ describe("MODEL_COSTS pricing overlay", () => { it("tracks pricing provenance", () => { assert.equal(PRICING_SOURCE_URL, "https://commandcode.ai/docs/resources/pricing-limits") - assert.equal(PRICING_LAST_VERIFIED, "2026-08-28") + assert.equal(PRICING_LAST_VERIFIED, "2026-09-01") }) it("fails once temporary pricing needs review", () => { From 0700d9b61d5a19093508840fd6bbfb0751bb7686 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Tue, 1 Sep 2026 23:10:00 +0200 Subject: [PATCH 13/18] fix(core): register the custom api in the pi-ai compat registry pi routes the main chat through the registered provider, but sibling extensions that call streamSimple from @earendil-works/pi-ai/compat with the active Command Code model resolve model.api through the compat api-registry, which only knows built-in APIs. On plain pi that failed with "No API provider registered for api: commandcode-custom". Register commandcode-custom there and delegate to the transport router. The registry resolves no credentials for extension providers, so fall back to the configured Command Code key when the caller passes none. Closes #68 (cherry picked from commit 7e9659e672771c6a9223b95938e50fb2a051a0a7) --- CHANGELOG.md | 3 + README.md | 4 ++ index.ts | 31 +++++++- tests/fixtures/compat-caller-extension.ts | 34 +++++++++ tests/test-pi-local.mjs | 87 +++++++++++++++++++++++ 5 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/compat-caller-extension.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 38b745e..ab77472 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Register the `commandcode-custom` API in the `@earendil-works/pi-ai/compat` registry so sibling extensions that stream with the active Command Code model no longer fail with `No API provider registered for api: commandcode-custom` on plain pi. +- Assert structural catalog invariants in the model tests so the daily catalog sync no longer fails on every upstream change. +- Display the monthly renewal date and remaining days in `/commandcode-quota`. - Stop silently dropping `role: "developer"` messages (for example OMP advisor steering notes, reminders, and nudges). `/alpha/generate` only accepts `user`, `assistant`, and `tool` roles, so developer messages are now forwarded as `user` messages with identical content in the same chronological position instead of disappearing from the request. - Add `Qwen/Qwen3.8-Flash` and `z-ai/glm-5.3-flash` with their verified reasoning efforts (`low, medium, xhigh` and `low, high, max`) and display pricing. - Refresh static model capabilities from `command-code@1.40.1`, adding `claude-fable-5-1`, `deepseek/deepseek-v4-flash-fast`, and `tencent/hy4-preview` with their reasoning efforts, adding `moonshotai/Kimi-K3` efforts and the `z-ai/glm-5.3-flash` output limit, and dropping the retired `stealth/ox-alpha` and `minimax/minimax-m3-free`. diff --git a/README.md b/README.md index 32a54d2..25be44c 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,10 @@ omp plugin install pi-commandcode-provider Restart OMP or run `/reload`, then use `/login` and select **Use a subscription** followed by **Command Code**. +## Other extensions + +Command Code models are registered under the custom `commandcode-custom` API. The provider also registers that API in the `@earendil-works/pi-ai/compat` registry, so sibling extensions that stream through `streamSimple` from that entrypoint with the active session model (background agents, memory workers, and similar) reach the same Command Code transport instead of failing with `No API provider registered for api: commandcode-custom`. When such a call passes no API key, the provider uses the configured Command Code credentials. + ## Authentication ### Login dialog diff --git a/index.ts b/index.ts index 09222bd..6dce140 100644 --- a/index.ts +++ b/index.ts @@ -6,7 +6,11 @@ */ import { AssistantMessageEventStream } from "@earendil-works/pi-ai" -import { streamSimple as streamNativeProvider } from "@earendil-works/pi-ai/compat" +import { + registerApiProvider, + streamSimple as streamNativeProvider, + type ApiStreamSimpleFunction, +} from "@earendil-works/pi-ai/compat" import { getAgentDir, type ExtensionAPI, @@ -37,6 +41,9 @@ import { registerCommandCodeQuota } from "./src/quota-command.ts" import { createCommandCodeRuntime } from "./src/runtime.ts" import { createCommandCodeTransportRouter } from "./src/transport.ts" +const COMMAND_CODE_API = "commandcode-custom" +const COMPAT_SOURCE_ID = "pi-commandcode-provider" + function commandCodeHeaders(): Record | undefined { if (process.env.CMD_ZDR === "1" || process.env.COMMANDCODE_ZDR === "1") { return { "x-cmd-zdr": "1" } @@ -54,7 +61,7 @@ function createProviderConfig( name: "Command Code", baseUrl: apiBase, apiKey: getConfiguredApiKey() ?? "$COMMAND_CODE_API_KEY", - api: "commandcode-custom", + api: COMMAND_CODE_API, streamSimple: streamCommandCode, headers, oauth: { @@ -66,7 +73,7 @@ function createProviderConfig( models: models.map((model) => ({ id: model.id, name: model.name, - api: "commandcode-custom", + api: COMMAND_CODE_API, baseUrl: baseUrlForModel(apiBase, model.api), reasoning: model.reasoning, ...(thinkingMetadataForModel(model.id) ?? {}), @@ -120,6 +127,24 @@ export default async function (pi: ExtensionAPI) { streamGenerate, }) + // pi dispatches the main chat through the registered provider, but sibling + // extensions that call `streamSimple` from `@earendil-works/pi-ai/compat` + // with a Command Code model resolve `model.api` through the compat + // api-registry, which knows nothing about extension providers. Register the + // custom api there so those calls reach the same transport. The registry + // resolves no credentials for extension providers, so fall back to the + // configured key when the caller passes none. + const compatStream: ApiStreamSimpleFunction = (model, context, options) => + transport.stream( + model, + context, + options?.apiKey ? options : { ...options, apiKey: getConfiguredApiKey() }, + ) as AssistantMessageEventStream + registerApiProvider( + { api: COMMAND_CODE_API, stream: compatStream, streamSimple: compatStream }, + COMPAT_SOURCE_ID, + ) + pi.on("message_end", async (event, ctx) => { if (event.message.role !== "assistant") return const normalized = normalizeCommandCodeMessage(event.message, ctx.model?.provider) diff --git a/tests/fixtures/compat-caller-extension.ts b/tests/fixtures/compat-caller-extension.ts new file mode 100644 index 0000000..214533f --- /dev/null +++ b/tests/fixtures/compat-caller-extension.ts @@ -0,0 +1,34 @@ +/** + * Test fixture: a sibling extension that streams through the pi-ai compat + * entrypoint with the active session model, the way background-agent + * extensions do. Registers `/compat-call` so the test can drive it over RPC. + */ + +import { streamSimple } from "@earendil-works/pi-ai/compat" +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" + +export default function (pi: ExtensionAPI) { + pi.registerCommand("compat-call", { + description: "Stream through @earendil-works/pi-ai/compat with the session model", + handler: async (_args, ctx) => { + const model = ctx.model + if (!model) { + ctx.ui.notify("compat-call: no active model", "error") + return + } + try { + const message = await streamSimple(model, { + messages: [{ role: "user", content: "say mock token", timestamp: Date.now() }], + }).result() + const text = message.content + .filter((part): part is { type: "text"; text: string } => part.type === "text") + .map((part) => part.text) + .join("") + ctx.ui.notify(`compat-call ok: ${text}`, "info") + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + ctx.ui.notify(`compat-call failed: ${detail}`, "error") + } + }, + }) +} diff --git a/tests/test-pi-local.mjs b/tests/test-pi-local.mjs index 3bce2c8..9908653 100644 --- a/tests/test-pi-local.mjs +++ b/tests/test-pi-local.mjs @@ -15,6 +15,12 @@ import { fileURLToPath } from "node:url" const __dirname = dirname(fileURLToPath(import.meta.url)) const PROJECT_DIR = resolve(__dirname, "..") const EXT_PATH = resolve(PROJECT_DIR, "index.ts") +const COMPAT_CALLER_EXT_PATH = resolve( + PROJECT_DIR, + "tests", + "fixtures", + "compat-caller-extension.ts", +) const TEST_MODEL = "gpt-5.4" const CLAUDE_TEST_MODEL = "claude-sonnet-4-6" @@ -486,6 +492,75 @@ async function runRpcExtensionCommands(timeoutMs = 30_000) { } } +async function runRpcCompatCall(timeoutMs = 30_000) { + const child = spawn( + PI_BIN, + [ + "--no-extensions", + "--mode", + "rpc", + "-e", + EXT_PATH, + "-e", + COMPAT_CALLER_EXT_PATH, + "--provider", + "commandcode", + "--model", + TEST_MODEL, + ], + { + cwd: PROJECT_DIR, + env, + stdio: ["pipe", "pipe", "pipe"], + }, + ) + + let buffer = "" + let stderr = "" + + const notification = new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`compat-call timeout. stderr: ${stderr.slice(-500)}`)), + timeoutMs, + ) + child.stdout.on("data", (chunk) => { + buffer += chunk.toString("utf-8") + const lines = buffer.split("\n") + buffer = lines.pop() ?? "" + for (const line of lines) { + if (!line.trim()) continue + let event + try { + event = JSON.parse(line) + } catch { + continue + } + if ( + event.type === "extension_ui_request" && + event.method === "notify" && + typeof event.message === "string" && + event.message.startsWith("compat-call") + ) { + clearTimeout(timer) + resolve(event.message) + } + } + }) + child.stderr.on("data", (chunk) => { + stderr += chunk.toString("utf-8") + }) + }) + + try { + child.stdin.write( + `${JSON.stringify({ id: "compat", type: "prompt", message: "/compat-call" })}\n`, + ) + return { message: await notification, stderr } + } finally { + child.kill() + } +} + async function runRpcOverflowRecovery(timeoutMs = 60_000) { const child = spawn( PI_BIN, @@ -797,6 +872,18 @@ try { JSON.stringify(imageContent), ) + console.log("[pi-local] sibling extension streams through the pi-ai compat registry") + requestCount = 0 + const compatCall = await runRpcCompatCall() + assert.equal(compatCall.message, "compat-call ok: mock-pi-ok", compatCall.stderr) + assert.equal(requestCount, 1) + assert.equal(lastRequestBody?.model, TEST_MODEL) + assert.ok( + typeof lastRequestHeaders.authorization === "string" && + lastRequestHeaders.authorization.startsWith("Bearer "), + "compat call should send a bearer Authorization header", + ) + console.log("[pi-local] verify overflow normalization and compaction recovery") overflowMode = true overflowRequestCount = 0 From 6e52b84036abbf41b8ca7ec9f894ab829c834d12 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Tue, 1 Sep 2026 23:11:10 +0200 Subject: [PATCH 14/18] test(pi-local): keep the compat caller fixture free of peer type deps Declare the fixture's pi types inline so npm run typecheck passes without the optional peer packages, matching the existing fixture convention. (cherry picked from commit 0ba5827c58a0f6ccf54387454ae38d93bbead3d9) --- tests/fixtures/compat-caller-extension.ts | 51 +++++++++++++++++++---- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/tests/fixtures/compat-caller-extension.ts b/tests/fixtures/compat-caller-extension.ts index 214533f..a028299 100644 --- a/tests/fixtures/compat-caller-extension.ts +++ b/tests/fixtures/compat-caller-extension.ts @@ -2,12 +2,45 @@ * Test fixture: a sibling extension that streams through the pi-ai compat * entrypoint with the active session model, the way background-agent * extensions do. Registers `/compat-call` so the test can drive it over RPC. + * + * Types are declared inline so the fixture has no typecheck dependency on the + * optional pi peer packages; the host's extension loader resolves the import. */ -import { streamSimple } from "@earendil-works/pi-ai/compat" -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" +// @ts-expect-error pi resolves this peer package at load time. +import * as compatModule from "@earendil-works/pi-ai/compat" -export default function (pi: ExtensionAPI) { +interface CompatTextPart { + type: string + text?: string +} + +interface CompatStreamResult { + result(): Promise<{ content: readonly CompatTextPart[] }> +} + +interface CompatModule { + streamSimple(model: unknown, context: unknown): CompatStreamResult +} + +interface CompatCallerContext { + model?: unknown + ui: { notify(message: string, level: "info" | "error"): void } +} + +interface CompatCallerExtensionApi { + registerCommand( + name: string, + command: { + description: string + handler: (args: string, ctx: CompatCallerContext) => Promise + }, + ): void +} + +const compat = compatModule as CompatModule + +export default function (pi: CompatCallerExtensionApi) { pi.registerCommand("compat-call", { description: "Stream through @earendil-works/pi-ai/compat with the session model", handler: async (_args, ctx) => { @@ -17,12 +50,14 @@ export default function (pi: ExtensionAPI) { return } try { - const message = await streamSimple(model, { - messages: [{ role: "user", content: "say mock token", timestamp: Date.now() }], - }).result() + const message = await compat + .streamSimple(model, { + messages: [{ role: "user", content: "say mock token", timestamp: Date.now() }], + }) + .result() const text = message.content - .filter((part): part is { type: "text"; text: string } => part.type === "text") - .map((part) => part.text) + .filter((part) => part.type === "text") + .map((part) => part.text ?? "") .join("") ctx.ui.notify(`compat-call ok: ${text}`, "info") } catch (error) { From 59322e4ffabcb3df516ca5720261240de6985b0c Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Tue, 1 Sep 2026 23:18:14 +0200 Subject: [PATCH 15/18] perf(models): start from the cached catalog and refresh in the background Every host start awaited a full catalog request before the provider registered, costing one HTTPS round-trip (up to the discovery timeout on a hanging connection) even when a cache written seconds earlier was on disk. Register the cached catalog immediately and run the live refresh in the background; the live result re-registers the provider when it arrives. A first start without a cache still awaits the live catalog. The background refresh is aborted on session_shutdown so print mode does not wait for it. Closes #63 (cherry picked from commit 142191f9420fe90460cdc3cbae70e26d0c000860) --- CHANGELOG.md | 1 + README.md | 2 +- index.ts | 9 ++- src/models.ts | 11 ++++ src/runtime.ts | 42 +++++++++++++- tests/test-pi-local.mjs | 31 ++++++++++ tests/test-runtime.ts | 124 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 215 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab77472..1e85f6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Start from the cached model catalog and refresh it in the background instead of blocking host startup on the catalog request; a first start without a cache still waits for the live catalog. - Register the `commandcode-custom` API in the `@earendil-works/pi-ai/compat` registry so sibling extensions that stream with the active Command Code model no longer fail with `No API provider registered for api: commandcode-custom` on plain pi. - Assert structural catalog invariants in the model tests so the daily catalog sync no longer fails on every upstream change. - Display the monthly renewal date and remaining days in `/commandcode-quota`. diff --git a/README.md b/README.md index 25be44c..26be812 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ https://api.commandcode.ai/provider/v1/models The last successful catalog is cached at `/commandcode-models.json`. For pi this is `~/.pi/agent/commandcode-models.json` by default. Compatible hosts such as OMP use their own agent directory. -If the endpoint is temporarily unavailable, the provider uses the cached catalog. On a first offline start without a cache, pi still loads, but Command Code models remain unavailable until the connection is restored and `/commandcode-refresh` succeeds. +When a valid cache exists, the provider registers the cached catalog immediately and refreshes it from the endpoint in the background, so startup does not wait for the network. The refreshed catalog replaces the cached one as soon as it arrives; `/commandcode-status` reports `source: cache` until then. If the endpoint is temporarily unavailable, the cached catalog stays active. On a first start without a cache, the provider waits for the live catalog; if that fails offline, pi still loads, but Command Code models remain unavailable until the connection is restored and `/commandcode-refresh` succeeds. While pi is running, use these provider commands without restarting: diff --git a/index.ts b/index.ts index 6dce140..12af76c 100644 --- a/index.ts +++ b/index.ts @@ -29,6 +29,7 @@ import { DEFAULT_PROVIDER_API_BASE, getModelsTimeoutMs, inputModalitiesForModel, + loadCachedCommandCodeModels, loadCommandCodeModels, MODEL_EFFORTS, thinkingMetadataForModel, @@ -159,15 +160,21 @@ export default async function (pi: ExtensionAPI) { const runtime = createCommandCodeRuntime(pi, { endpoint: modelsUrl, cachePath: modelsCachePath, - loadModels: () => + loadModels: (signal) => loadCommandCodeModels({ url: modelsUrl, cachePath: modelsCachePath, timeoutMs: modelsTimeoutMs, + signal, }), + loadCachedModels: () => loadCachedCommandCodeModels(modelsCachePath), createProviderConfig: (models) => createProviderConfig(models, apiBase, transport.stream), getTransport: transport.getTransport, }) + pi.on("session_shutdown", () => { + runtime.dispose() + }) + await runtime.initialize() } diff --git a/src/models.ts b/src/models.ts index be7c674..15cdadb 100644 --- a/src/models.ts +++ b/src/models.ts @@ -332,6 +332,17 @@ async function readCommandCodeModelsCache(cachePath: string): Promise { + try { + return await readCommandCodeModelsCache(cachePath) + } catch { + return [] + } +} + async function writeCommandCodeModelsCache( cachePath: string, models: readonly CommandCodeModel[], diff --git a/src/runtime.ts b/src/runtime.ts index e103f96..eb5e842 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -26,7 +26,9 @@ export interface CommandCodeRuntimeApi< export interface CommandCodeRuntimeOptions { endpoint: string cachePath: string - loadModels: () => Promise + loadModels: (signal: AbortSignal) => Promise + /** Cached catalog only; resolves to an empty list when no valid cache exists. */ + loadCachedModels: () => Promise createProviderConfig: (models: readonly CommandCodeModel[]) => TProviderConfig getTransport?: () => "unknown" | "provider" | "generate" now?: () => number @@ -108,6 +110,7 @@ export class CommandCodeRuntime | undefined + private readonly shutdown = new AbortController() constructor( private readonly pi: CommandCodeRuntimeApi, @@ -133,9 +136,34 @@ export class CommandCodeRuntime { this.registerCommands() - await this.refresh() + + const cached = await this.options.loadCachedModels() + if (cached.length === 0) { + await this.refresh() + return + } + + this.pi.registerProvider("commandcode", this.options.createProviderConfig(cached)) + this.providerRegistered = true + this.status = { + ...this.status, + source: "cache", + modelCount: cached.length, + lastSuccess: this.now(), + } + void this.refresh() + } + + /** Aborts any background refresh so a stopping host does not wait for the network. */ + dispose(): void { + this.shutdown.abort(new Error("Command Code provider shut down")) } refresh(): Promise { @@ -156,7 +184,7 @@ export class CommandCodeRuntime accessSync(modelsCachePath, constants.R_OK)) + modelsDelayMs = 5_000 + requestCount = 0 + const cachedStartedAt = Date.now() + const cachedPrint = await runPi( + [ + "--no-extensions", + "-e", + EXT_PATH, + "-p", + "say mock token", + "--provider", + "commandcode", + "--model", + TEST_MODEL, + ], + 30_000, + ) + const cachedElapsedMs = Date.now() - cachedStartedAt + assert.equal(cachedPrint.code, 0, cachedPrint.stderr) + assert.match(cachedPrint.stdout, /mock-pi-ok/) + assert.equal(requestCount, 1) + assert.ok(cachedElapsedMs < 5_000, `cached start took ${cachedElapsedMs}ms`) + modelsDelayMs = 0 + console.log("[pi-local] print mode with reasoning and tool schemas") requestCount = 0 const print = await runPi( diff --git a/tests/test-runtime.ts b/tests/test-runtime.ts index 2a89f12..28f9a42 100644 --- a/tests/test-runtime.ts +++ b/tests/test-runtime.ts @@ -97,6 +97,7 @@ describe("Command Code runtime", () => { endpoint: "https://api.commandcode.ai/provider/v1/models?token=user_secret_value", cachePath: "/tmp/commandcode-models.json", loadModels: () => firstLoad.promise, + loadCachedModels: async () => [], createProviderConfig: (models) => ({ models }), getTransport: () => "provider", now: () => now, @@ -105,6 +106,7 @@ describe("Command Code runtime", () => { const initialization = runtime.initialize() assert.deepEqual([...pi.commands.keys()], ["commandcode-refresh", "commandcode-status"]) + await Promise.resolve() assert.equal(runtime.getStatus().refreshing, true) assert.equal(runtime.getStatus().lastAttempt, now) @@ -142,6 +144,7 @@ describe("Command Code runtime", () => { if (!next) throw new Error("unexpected refresh") return next instanceof Promise ? next : next.promise }, + loadCachedModels: async () => [], createProviderConfig: (models) => ({ models }), logWarning: (warning) => warnings.push(warning), }) @@ -189,6 +192,7 @@ describe("Command Code runtime", () => { if (!result) throw new Error("unexpected refresh") return result }, + loadCachedModels: async () => [], createProviderConfig: (models) => ({ models }), logWarning: () => {}, }) @@ -204,6 +208,123 @@ describe("Command Code runtime", () => { assert.deepEqual(pi.providers.at(-1)?.models, [FIRST_MODEL, SECOND_MODEL]) }) + it("registers the cached catalog immediately and refreshes it in the background", async () => { + const pi = new ExtensionAPITestDouble() + const liveLoad = deferred() + let now = 1_700_000_000_000 + + const runtime = createCommandCodeRuntime(pi, { + endpoint: "https://api.commandcode.ai/provider/v1/models", + cachePath: "/tmp/commandcode-models.json", + loadModels: () => liveLoad.promise, + loadCachedModels: async () => [FIRST_MODEL], + createProviderConfig: (models) => ({ models }), + now: () => now, + logWarning: () => {}, + }) + + await runtime.initialize() + assert.equal(pi.providers.length, 1) + assert.deepEqual(pi.providers[0]?.models, [FIRST_MODEL]) + assert.equal(runtime.getStatus().source, "cache") + assert.equal(runtime.getStatus().modelCount, 1) + assert.equal(runtime.getStatus().refreshing, true) + + now += 1_000 + liveLoad.resolve(loaded([FIRST_MODEL, SECOND_MODEL])) + await runtime.refresh() + assert.equal(pi.providers.length, 2) + assert.deepEqual(pi.providers[1]?.models, [FIRST_MODEL, SECOND_MODEL]) + assert.equal(runtime.getStatus().source, "live") + assert.equal(runtime.getStatus().modelCount, 2) + assert.equal(runtime.getStatus().refreshing, false) + }) + + it("keeps the cached catalog when the background refresh fails", async () => { + const pi = new ExtensionAPITestDouble() + const warnings: string[] = [] + + const runtime = createCommandCodeRuntime(pi, { + endpoint: "https://api.commandcode.ai/provider/v1/models", + cachePath: "/tmp/commandcode-models.json", + loadModels: async () => { + throw new Error("offline") + }, + loadCachedModels: async () => [FIRST_MODEL], + createProviderConfig: (models) => ({ models }), + logWarning: (message) => warnings.push(message), + }) + + await runtime.initialize() + await runtime.refresh() + assert.equal(pi.providers.length, 1) + assert.deepEqual(pi.providers[0]?.models, [FIRST_MODEL]) + assert.equal(runtime.getStatus().source, "cache") + assert.equal(runtime.getStatus().modelCount, 1) + assert.match(runtime.getStatus().warning ?? "", /offline/) + assert.equal(warnings.length, 1) + }) + + it("aborts the background refresh on dispose without reporting a warning", async () => { + const pi = new ExtensionAPITestDouble() + const warnings: string[] = [] + let refreshSignal: AbortSignal | undefined + + const runtime = createCommandCodeRuntime(pi, { + endpoint: "https://api.commandcode.ai/provider/v1/models", + cachePath: "/tmp/commandcode-models.json", + loadModels: (signal) => + new Promise((_resolve, reject) => { + refreshSignal = signal + signal.addEventListener("abort", () => reject(signal.reason), { once: true }) + }), + loadCachedModels: async () => [FIRST_MODEL], + createProviderConfig: (models) => ({ models }), + logWarning: (message) => warnings.push(message), + }) + + await runtime.initialize() + const pending = runtime.refresh() + assert.equal(refreshSignal?.aborted, false) + + runtime.dispose() + const result = await pending + assert.equal(refreshSignal?.aborted, true) + assert.equal(result.refreshed, false) + assert.equal(runtime.getStatus().refreshing, false) + assert.equal(runtime.getStatus().warning, undefined) + assert.deepEqual(warnings, []) + assert.deepEqual(pi.providers[0]?.models, [FIRST_MODEL]) + }) + + it("awaits the live catalog when no cache exists", async () => { + const pi = new ExtensionAPITestDouble() + const liveLoad = deferred() + + const runtime = createCommandCodeRuntime(pi, { + endpoint: "https://api.commandcode.ai/provider/v1/models", + cachePath: "/tmp/commandcode-models.json", + loadModels: () => liveLoad.promise, + loadCachedModels: async () => [], + createProviderConfig: (models) => ({ models }), + logWarning: () => {}, + }) + + let initialized = false + const initialization = runtime.initialize().then(() => { + initialized = true + }) + await Promise.resolve() + assert.equal(pi.providers.length, 0) + assert.equal(initialized, false) + + liveLoad.resolve(loaded([FIRST_MODEL])) + await initialization + assert.equal(initialized, true) + assert.deepEqual(pi.providers[0]?.models, [FIRST_MODEL]) + assert.equal(runtime.getStatus().source, "live") + }) + it("installs a cached catalog after an initially empty start", async () => { const pi = new ExtensionAPITestDouble() const results = [ @@ -221,6 +342,7 @@ describe("Command Code runtime", () => { if (!result) throw new Error("unexpected refresh") return result }, + loadCachedModels: async () => [], createProviderConfig: (models) => ({ models }), logWarning: () => {}, }) @@ -254,6 +376,7 @@ describe("Command Code runtime", () => { if (!result) throw new Error("unexpected refresh") return result }, + loadCachedModels: async () => [], createProviderConfig: (models) => ({ models }), logWarning: () => {}, }) @@ -280,6 +403,7 @@ describe("Command Code runtime", () => { loadModels: async () => { throw new Error("offline; api_key=user_initial_secret") }, + loadCachedModels: async () => [], createProviderConfig: (models) => ({ models }), logWarning: () => {}, }) From be110c60466d65b5b4050fb934165391f882a440 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Tue, 1 Sep 2026 23:22:08 +0200 Subject: [PATCH 16/18] docs(readme): explain reasoning models without selectable efforts The official CLI marks meta/muse-spark-* as reasoning models but ships no effort levels for them and sends no effort parameter, so the thinking level stays off in pi. Document that so the locked footer is not mistaken for a provider bug. Refs #69 (cherry picked from commit 263310635a89e35cf0b3da527474555c9687903e) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 26be812..427822a 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ Open `/model` and select one of the models provided by Command Code. Model avail ### Reasoning support -Reasoning capability and selectable effort levels follow the official CLI catalog independently. Models can therefore be marked as reasoning-capable even when Command Code chooses their depth automatically. Models with explicit effort support also register a model-specific `thinkingLevelMap`, so pi and OMP expose only valid levels. Pi's native OpenAI- and Anthropic-compatible providers translate the selected level for Provider API accounts; the existing Command Code generate transport sends the matching `reasoning_effort` for Go accounts. +Reasoning capability and selectable effort levels follow the official CLI catalog independently. Models can therefore be marked as reasoning-capable even when Command Code chooses their depth automatically; for those models (for example `meta/muse-spark-1.1`, `meta/muse-spark-1.2`, and `meta/muse-spark-1.2-contributor`) the thinking level stays `off` because the CLI itself sends no effort parameter and Command Code has not published selectable levels. Models with explicit effort support register a model-specific `thinkingLevelMap`, so pi and OMP expose only valid levels. Pi's native OpenAI- and Anthropic-compatible providers translate the selected level for Provider API accounts; the existing Command Code generate transport sends the matching `reasoning_effort` for Go accounts. List Command Code models from the terminal: From d3c9832d28030a8b8d106ab3987089a819e67375 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Wed, 2 Sep 2026 00:03:45 +0200 Subject: [PATCH 17/18] feat(models): move manual reasoning efforts into a catalog override PR #69 added efforts for meta/muse-spark-* directly to the generated catalog, which the drift check flags and the daily sync job reverts. Keep src/commandcode-catalog.ts byte-identical to upstream and merge a separate src/commandcode-catalog-overrides.ts over it at load time. A test fails as soon as upstream publishes efforts for an overridden model so the override gets removed instead of shadowing the CLI catalog. Verified against the live endpoint: pi --thinking xhigh sends reasoning_effort="xhigh" for meta/muse-spark-1.2-contributor and the request succeeds; --thinking off sends none. Closes #69 --- .agents/skills/refresh-model-catalog/SKILL.md | 2 ++ CHANGELOG.md | 1 + README.md | 2 +- src/commandcode-catalog-overrides.ts | 23 ++++++++++++++++ src/commandcode-catalog.ts | 3 --- src/models.ts | 11 ++++++-- tests/test-models.ts | 26 ++++++++++++++++++- 7 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 src/commandcode-catalog-overrides.ts diff --git a/.agents/skills/refresh-model-catalog/SKILL.md b/.agents/skills/refresh-model-catalog/SKILL.md index 8a598f5..25140ba 100644 --- a/.agents/skills/refresh-model-catalog/SKILL.md +++ b/.agents/skills/refresh-model-catalog/SKILL.md @@ -32,6 +32,8 @@ npm run sync:commandcode-catalog Regenerates `src/commandcode-catalog.ts` and bumps the documented CLI version in `README.md`. Review the diff; the catalog also lists reasoning models without selectable efforts. +Never add efforts to the generated file by hand. Manual effort policy for reasoning models that upstream ships without levels lives in `src/commandcode-catalog-overrides.ts` and is merged at load time. When the sync report lists a model from that file under "New effort metadata", remove its override; `tests/test-models.ts` fails until you do. + ### 3. Update display pricing (manual review) Fetch and compare against `src/pricing.ts`: diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e85f6b..36a8883 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Expose selectable thinking levels (`minimal`, `low`, `medium`, `high`, `xhigh`) for `meta/muse-spark-1.1`, `meta/muse-spark-1.2`, and `meta/muse-spark-1.2-contributor` through a manual catalog override, so `/thinking` and `Shift+Tab` no longer stay locked on `off` for these reasoning models. - Start from the cached model catalog and refresh it in the background instead of blocking host startup on the catalog request; a first start without a cache still waits for the live catalog. - Register the `commandcode-custom` API in the `@earendil-works/pi-ai/compat` registry so sibling extensions that stream with the active Command Code model no longer fail with `No API provider registered for api: commandcode-custom` on plain pi. - Assert structural catalog invariants in the model tests so the daily catalog sync no longer fails on every upstream change. diff --git a/README.md b/README.md index 427822a..1d2633b 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ Open `/model` and select one of the models provided by Command Code. Model avail ### Reasoning support -Reasoning capability and selectable effort levels follow the official CLI catalog independently. Models can therefore be marked as reasoning-capable even when Command Code chooses their depth automatically; for those models (for example `meta/muse-spark-1.1`, `meta/muse-spark-1.2`, and `meta/muse-spark-1.2-contributor`) the thinking level stays `off` because the CLI itself sends no effort parameter and Command Code has not published selectable levels. Models with explicit effort support register a model-specific `thinkingLevelMap`, so pi and OMP expose only valid levels. Pi's native OpenAI- and Anthropic-compatible providers translate the selected level for Provider API accounts; the existing Command Code generate transport sends the matching `reasoning_effort` for Go accounts. +Reasoning capability and selectable effort levels follow the official CLI catalog independently. Models can therefore be marked as reasoning-capable even when Command Code chooses their depth automatically. Models with explicit effort support register a model-specific `thinkingLevelMap`, so pi and OMP expose only valid levels. For a few reasoning models the CLI catalog ships no effort levels although the endpoint accepts `reasoning_effort`; `src/commandcode-catalog-overrides.ts` adds a manual level set for those (currently `meta/muse-spark-1.1`, `meta/muse-spark-1.2`, and `meta/muse-spark-1.2-contributor`) on top of the generated catalog, and the tests fail once upstream publishes its own levels so the override gets removed. Pi's native OpenAI- and Anthropic-compatible providers translate the selected level for Provider API accounts; the existing Command Code generate transport sends the matching `reasoning_effort` for Go accounts. List Command Code models from the terminal: diff --git a/src/commandcode-catalog-overrides.ts b/src/commandcode-catalog-overrides.ts new file mode 100644 index 0000000..6c6ab36 --- /dev/null +++ b/src/commandcode-catalog-overrides.ts @@ -0,0 +1,23 @@ +import type { CommandCodeReasoningEffort } from "./commandcode-catalog.ts" + +/** + * Manual reasoning-effort policy for models the official CLI marks as + * reasoning-capable without publishing selectable efforts. + * + * `src/commandcode-catalog.ts` is generated from the CLI package and must stay + * byte-identical to upstream so the daily drift check works. Entries here are + * merged over the generated catalog at load time and are not touched by + * `npm run sync:commandcode-catalog`. + * + * Add a model only when the effort parameter is known to be accepted by the + * Command Code endpoint; remove it once the CLI catalog ships its own efforts. + */ +export const MODEL_EFFORT_OVERRIDES: Readonly< + Record +> = { + // Meta Muse Spark: the CLI ships no effort levels, but the endpoint accepts + // `reasoning_effort` for these models and other hosts expose the same set. + "meta/muse-spark-1.1": ["minimal", "low", "medium", "high", "xhigh"], + "meta/muse-spark-1.2": ["minimal", "low", "medium", "high", "xhigh"], + "meta/muse-spark-1.2-contributor": ["minimal", "low", "medium", "high", "xhigh"], +} diff --git a/src/commandcode-catalog.ts b/src/commandcode-catalog.ts index d0846e7..19db48f 100644 --- a/src/commandcode-catalog.ts +++ b/src/commandcode-catalog.ts @@ -133,9 +133,6 @@ export const MODEL_EFFORTS: Readonly> = { + ...CATALOG_MODEL_EFFORTS, + ...MODEL_EFFORT_OVERRIDES, +} + +export { MODEL_INPUT_MODALITIES, MODEL_MAX_OUTPUT_TOKENS, MODEL_REASONING } export type { CommandCodeInputType } export const DEFAULT_PROVIDER_API_BASE = "https://api.commandcode.ai/provider/v1" diff --git a/tests/test-models.ts b/tests/test-models.ts index c5573dc..821a2e6 100644 --- a/tests/test-models.ts +++ b/tests/test-models.ts @@ -4,7 +4,11 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { describe, it } from "node:test" -import { COMMAND_CODE_CLI_VERSION } from "../src/commandcode-catalog.ts" +import { MODEL_EFFORT_OVERRIDES } from "../src/commandcode-catalog-overrides.ts" +import { + COMMAND_CODE_CLI_VERSION, + MODEL_EFFORTS as CATALOG_MODEL_EFFORTS, +} from "../src/commandcode-catalog.ts" import { apiForModelId, baseUrlForModel, @@ -192,6 +196,26 @@ describe("commandCodeModelsFromApiResponse()", () => { } }) + it("merges manual effort overrides over the generated catalog", () => { + const validEfforts = new Set(["minimal", "low", "medium", "high", "xhigh", "max"]) + assert.ok(Object.keys(MODEL_EFFORT_OVERRIDES).length > 0) + for (const [modelId, efforts] of Object.entries(MODEL_EFFORT_OVERRIDES)) { + assert.equal(MODEL_REASONING[modelId], true, `${modelId} override needs a reasoning flag`) + assert.equal( + CATALOG_MODEL_EFFORTS[modelId], + undefined, + `${modelId} now has upstream efforts; drop the manual override`, + ) + assert.ok(efforts.length > 0) + assert.ok(efforts.every((effort) => validEfforts.has(effort))) + assert.deepEqual(MODEL_EFFORTS[modelId], efforts) + assert.deepEqual(thinkingMetadataForModel(modelId)?.thinking?.efforts, efforts) + } + for (const [modelId, efforts] of Object.entries(CATALOG_MODEL_EFFORTS)) { + assert.deepEqual(MODEL_EFFORTS[modelId], efforts, `${modelId} upstream efforts changed`) + } + }) + it("builds separate canonical pi and OMP metadata", () => { for (const [modelId, efforts] of Object.entries(MODEL_EFFORTS)) { const metadata = thinkingMetadataForModel(modelId) From a1bfafa5564e089e5a734ffb023aa91aa63df406 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Wed, 2 Sep 2026 00:22:26 +0200 Subject: [PATCH 18/18] test(e2e): default the GOAT vision phase to gpt-5.6-luna google/gemini-3.7-flash currently fails on the Provider API with a zero-data-retention routing 404 for every request, which made the GOAT live suite red regardless of provider changes. GPT-5.6 Luna is available on every plan, accepts image input, and completes the vision request. --- README.md | 2 +- tests/test-live-e2e.mjs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1d2633b..ef5db91 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,7 @@ COMMANDCODE_E2E_GOAT_API_KEY_FILE=/path/to/goat-key \ Each profile runs with an isolated Pi agent directory and asserts transport selection, reasoning across turns, quota plan identity, abort handling, tool calls, and the packed npm artifact. Go must select `generate` and reject unsupported images; GOAT must select `provider` and complete a live vision request. The profile-specific `*_API_KEY` environment variables are also supported for CI secrets, but key files are preferred for local use. -The Go profile defaults to DeepSeek V4 Flash; GOAT defaults to Grok 4.6 because its Provider API stream exposes reasoning consistently across consecutive turns. Override them with `COMMANDCODE_E2E_GO_MODEL`, `COMMANDCODE_E2E_GOAT_MODEL`, or `COMMANDCODE_E2E_PROVIDER_MODEL`. A successful live Anthropic `/provider/v1/messages` test requires a paid account whose plan includes the selected Claude model. +The Go profile defaults to DeepSeek V4 Flash; GOAT defaults to Grok 4.6 because its Provider API stream exposes reasoning consistently across consecutive turns. Override them with `COMMANDCODE_E2E_GO_MODEL`, `COMMANDCODE_E2E_GOAT_MODEL`, or `COMMANDCODE_E2E_PROVIDER_MODEL`. The GOAT vision phase defaults to GPT-5.6 Luna and can be overridden with `COMMANDCODE_E2E_GOAT_VISION_MODEL`. A successful live Anthropic `/provider/v1/messages` test requires a paid account whose plan includes the selected Claude model. See [CONTRIBUTING.md](CONTRIBUTING.md) for local setup and tests. See [RELEASE.md](RELEASE.md) for the release process. diff --git a/tests/test-live-e2e.mjs b/tests/test-live-e2e.mjs index f17ffa5..7a91d88 100644 --- a/tests/test-live-e2e.mjs +++ b/tests/test-live-e2e.mjs @@ -40,7 +40,9 @@ const expectedPlan = : testProfile === "provider" ? "provider" : undefined -const goatVisionModel = process.env.COMMANDCODE_E2E_GOAT_VISION_MODEL ?? "google/gemini-3.7-flash" +// GPT-5.6 Luna is available on every plan and has a ZDR-capable upstream; +// Gemini 3.7 Flash currently fails with a zero-data-retention routing 404. +const goatVisionModel = process.env.COMMANDCODE_E2E_GOAT_VISION_MODEL ?? "gpt-5.6-luna" const marker = "commandcode-live-e2e-ok" function findPiBinary() {