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