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)
This commit is contained in:
Patrick Wozniak
2026-09-01 23:29:30 +02:00
parent f17cec0eee
commit 0700d9b61d
5 changed files with 156 additions and 3 deletions
+3
View File
@@ -2,6 +2,9 @@
## Unreleased ## 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. - 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. - 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`. - 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`.
+4
View File
@@ -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**. 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 ## Authentication
### Login dialog ### Login dialog
+28 -3
View File
@@ -6,7 +6,11 @@
*/ */
import { AssistantMessageEventStream } from "@earendil-works/pi-ai" 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 { import {
getAgentDir, getAgentDir,
type ExtensionAPI, type ExtensionAPI,
@@ -37,6 +41,9 @@ import { registerCommandCodeQuota } from "./src/quota-command.ts"
import { createCommandCodeRuntime } from "./src/runtime.ts" import { createCommandCodeRuntime } from "./src/runtime.ts"
import { createCommandCodeTransportRouter } from "./src/transport.ts" import { createCommandCodeTransportRouter } from "./src/transport.ts"
const COMMAND_CODE_API = "commandcode-custom"
const COMPAT_SOURCE_ID = "pi-commandcode-provider"
function commandCodeHeaders(): Record<string, string> | undefined { function commandCodeHeaders(): Record<string, string> | undefined {
if (process.env.CMD_ZDR === "1" || process.env.COMMANDCODE_ZDR === "1") { if (process.env.CMD_ZDR === "1" || process.env.COMMANDCODE_ZDR === "1") {
return { "x-cmd-zdr": "1" } return { "x-cmd-zdr": "1" }
@@ -54,7 +61,7 @@ function createProviderConfig(
name: "Command Code", name: "Command Code",
baseUrl: apiBase, baseUrl: apiBase,
apiKey: getConfiguredApiKey() ?? "$COMMAND_CODE_API_KEY", apiKey: getConfiguredApiKey() ?? "$COMMAND_CODE_API_KEY",
api: "commandcode-custom", api: COMMAND_CODE_API,
streamSimple: streamCommandCode, streamSimple: streamCommandCode,
headers, headers,
oauth: { oauth: {
@@ -66,7 +73,7 @@ function createProviderConfig(
models: models.map((model) => ({ models: models.map((model) => ({
id: model.id, id: model.id,
name: model.name, name: model.name,
api: "commandcode-custom", api: COMMAND_CODE_API,
baseUrl: baseUrlForModel(apiBase, model.api), baseUrl: baseUrlForModel(apiBase, model.api),
reasoning: model.reasoning, reasoning: model.reasoning,
...(thinkingMetadataForModel(model.id) ?? {}), ...(thinkingMetadataForModel(model.id) ?? {}),
@@ -120,6 +127,24 @@ export default async function (pi: ExtensionAPI) {
streamGenerate, 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) => { pi.on("message_end", async (event, ctx) => {
if (event.message.role !== "assistant") return if (event.message.role !== "assistant") return
const normalized = normalizeCommandCodeMessage(event.message, ctx.model?.provider) const normalized = normalizeCommandCodeMessage(event.message, ctx.model?.provider)
+34
View File
@@ -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")
}
},
})
}
+87
View File
@@ -15,6 +15,12 @@ import { fileURLToPath } from "node:url"
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
const PROJECT_DIR = resolve(__dirname, "..") const PROJECT_DIR = resolve(__dirname, "..")
const EXT_PATH = resolve(PROJECT_DIR, "index.ts") 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 TEST_MODEL = "gpt-5.4"
const CLAUDE_TEST_MODEL = "claude-sonnet-4-6" 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) { async function runRpcOverflowRecovery(timeoutMs = 60_000) {
const child = spawn( const child = spawn(
PI_BIN, PI_BIN,
@@ -797,6 +872,18 @@ try {
JSON.stringify(imageContent), 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") console.log("[pi-local] verify overflow normalization and compaction recovery")
overflowMode = true overflowMode = true
overflowRequestCount = 0 overflowRequestCount = 0