Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Add `/commandcode-endpoint <model-id> auto|responses|chat-completions` for persistent per-model Provider API routing. Choices survive catalog refreshes and restarts, are validated against the model wire, and are shown in `/commandcode-status` (#156). No automatic mid-stream endpoint switching is performed.

## 0.7.8 - 2026-10-15

- Redact sensitive text in direct stream errors and normalized context-overflow messages before returning them to the host. This closes two error-reporting paths that did not use the shared redactor; it is defensive hardening, not a claim of a confirmed credential leak ([#155](https://github.com/patlux/pi-commandcode-provider/pull/155)).
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,18 @@ The Provider API serves models on different wires, and `/provider/v1/models` adv

A model whose catalog entry omits `supported_endpoints` keeps Chat Completions, which every non-Claude model serves, so discovery keeps working when the field is absent. The selection is applied per model, so a catalog that mixes wires streams each model correctly.

### Persistent endpoint overrides

Use `/commandcode-endpoint <model-id> chat-completions` to select Chat Completions for one model, for example:

```txt
/commandcode-endpoint zai-org/GLM-5.3 chat-completions
```

Use `responses` to select Responses where the catalog advertises it, or `auto` to restore catalog routing. Claude models require `auto` (Anthropic Messages). The command waits for the current turn to finish and affects subsequent requests; it never retries a partial stream on another endpoint. `/commandcode-status` shows the effective model endpoint and whether it came from the catalog or a user override. The account transport (`provider` or `generate`) is independent; endpoint overrides apply only to Provider API requests.

Choices are stored in `commandcode-routing.json` inside the host agent directory, separate from the generated model cache. They survive refreshes, restarts and package updates. Do not edit the model cache. A malformed routing file blocks Provider API requests rather than silently ignoring your selection; correct the file and reload. Changes made to the file externally require a reload. Concurrent commands from separate host processes are not coordinated; use one host to change routing settings.

### 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 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 on top of the generated catalog (currently empty because upstream publishes every level). The catalog sync removes an override as soon as upstream publishes its own 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.
Expand Down
73 changes: 68 additions & 5 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ import { MODEL_COSTS, ZERO_MODEL_COST } from "./src/pricing.ts"
import { registerCommandCodeQuota } from "./src/quota-command.ts"
import { normalizeCommandCodeResponsesResponse } from "./src/responses-stream.ts"
import { createCommandCodeRuntime } from "./src/runtime.ts"
import {
isEndpointChoice,
loadEndpointOverrides,
resolveEndpoint,
saveEndpointChoice,
type EndpointOverrides,
} from "./src/routing.ts"
import { createCommandCodeUsageProvider, type UsageProvider } from "./src/usage.ts"
import { transcriptReadersFrom, withTranscriptPromptAndTools } from "./src/transcript.ts"
import { createCommandCodeTransportRouter } from "./src/transport.ts"
Expand Down Expand Up @@ -218,14 +225,55 @@ export default async function (pi: ExtensionAPI) {
// The host dispatches every Command Code model through one custom api id, so
// the wire each model answers on is resolved here from the catalog that
// `createProviderConfig` registered. Unknown ids fall back to the id rule.
const modelApis = new Map<string, CommandCodeApi>()
const resolveModelApi = (modelId: string): CommandCodeApi =>
modelApis.get(modelId) ?? apiForModelId(modelId)
const catalogModels = new Map<string, CommandCodeModel>()
const routingPath = join(getAgentDir(), "commandcode-routing.json")
let routing: EndpointOverrides = {}
let routingWarning: string | undefined
try {
routing = await loadEndpointOverrides(routingPath)
} catch (error) {
routingWarning = error instanceof Error ? error.message : "Invalid routing configuration"
}
const resolveModelApi = (modelId: string): CommandCodeApi => {
if (routingWarning) throw new Error(routingWarning)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note for the fail-closed discussion on src/routing.ts: this throw blocks every Provider API request for every model, including models that have no override at all. If you keep fail-closed, consider scoping it: a model without an entry in a parseable-but-partially-invalid file could still route auto.

const model = catalogModels.get(modelId)
return model ? resolveEndpoint(model, routing) : apiForModelId(modelId)
}
pi.registerCommand("commandcode-endpoint", {
description: "Choose a persistent model endpoint: <model-id> auto|responses|chat-completions",
handler: async (args, ctx) => {
const [modelId, choice, extra] = args.trim().split(/\s+/)
const model = catalogModels.get(modelId ?? "")
if (!model || !isEndpointChoice(choice) || extra) {
ctx.ui.notify(
"Usage: /commandcode-endpoint <catalog-model-id> auto|responses|chat-completions",
"warning",
)
return
}
await ctx.waitForIdle()
try {
resolveEndpoint(model, { [model.id]: choice })
routing = await saveEndpointChoice(routingPath, model.id, choice)
routingWarning = undefined
ctx.ui.notify(
`Command Code endpoint for ${model.id}: ${resolveModelApi(model.id)} (${choice === "auto" ? "catalog" : "user override"}). Applies to the next request and survives refresh/restart.`,
"info",
)
} catch (error) {
ctx.ui.notify(
error instanceof Error ? error.message : "Could not save routing configuration",
"error",
)
}
},
})
let streamWarning: string | undefined
const transport = createCommandCodeTransportRouter({
createStream: () => new AssistantMessageEventStream(),
streamProvider: (model, context, options) => {
const wire = resolveModelApi(model.id)
const catalogModel = catalogModels.get(model.id)
const resolvedOptions: Parameters<typeof streamNativeProvider>[2] =
resolveStreamOptions(options)
const nativeOptions =
Expand All @@ -246,7 +294,12 @@ export default async function (pi: ExtensionAPI) {
...model,
api: wire,
cost: commandCodeCostRatesAt(model.id, model.cost),
compat: model.compatConfig ?? model.compat,
compat: {
...(model.compatConfig ?? model.compat),
// A selected host model can outlive a command or background refresh.
// Wire-specific defaults must follow the actual request, not that stale model.
...(catalogModel ? compatForModel({ ...catalogModel, api: wire }) : {}),

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merging the host model's compat (computed for the catalog wire) with the compat for the overridden wire leaves wire-specific flags from the wrong wire in place. For a Responses model forced to Chat Completions, sessionAffinityFormat: "openai-nosession" and supportsLongCacheRetention: false from the Responses set survive the spread.

compatForModel is authoritative per wire, so replace instead of merge when the catalog model is known:

compat: catalogModel
  ? compatForModel({ ...catalogModel, api: wire })
  : (model.compatConfig ?? model.compat),

(This is also the spot that overlaps with #162's replayReasoningContent.)

},
},
context,
nativeOptions,
Expand Down Expand Up @@ -294,9 +347,19 @@ export default async function (pi: ExtensionAPI) {
}),
loadCachedModels: () => loadCachedCommandCodeModels(modelsCachePath),
createProviderConfig: (models) => {
for (const model of models) modelApis.set(model.id, model.api)
catalogModels.clear()
for (const model of models) catalogModels.set(model.id, model)
return createProviderConfig(models, apiBase, transport.stream)
},
getRoutingStatus: (model) => {
if (routingWarning) return `routing warning: ${routingWarning}`
if (model?.provider !== "commandcode") return "model endpoint: no Command Code model selected"
try {
return `model endpoint: ${resolveModelApi(model.id)} (${routing[model.id] ?? "auto"}; ${routing[model.id] ? "user override" : "catalog"})`
} catch (error) {
return `routing warning: ${error instanceof Error ? error.message : "Invalid route"}`
}
},
getTransport: transport.getTransport,
getStreamWarning: () => streamWarning,
})
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"LICENSE"
],
"scripts": {
"test": "npm run typecheck && node --test tests/test-release.mjs tests/test-pi-child-cleanup.mjs && tsx tests/test-package-manifest.ts && tsx tests/test-api-key.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-model-metadata-check.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-pricing-check.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-quota.ts && tsx tests/test-quota-command.ts && tsx tests/test-usage.ts && tsx tests/test-retry.ts && tsx tests/test-transport.ts && node tests/test-pi-isolated.mjs && node tests/test-pi-authenticated.mjs && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs",
"test": "npm run typecheck && npm run test:routing && node --test tests/test-release.mjs tests/test-pi-child-cleanup.mjs && tsx tests/test-package-manifest.ts && tsx tests/test-api-key.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-model-metadata-check.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-pricing-check.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-quota.ts && tsx tests/test-quota-command.ts && tsx tests/test-usage.ts && tsx tests/test-retry.ts && tsx tests/test-transport.ts && node tests/test-pi-isolated.mjs && node tests/test-pi-authenticated.mjs && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs",
"typecheck": "tsc --noEmit",
"format:check": "prettier --check '**/*.{ts,mjs,json,md}'",
"format": "prettier --write '**/*.{ts,mjs,json,md}'",
Expand All @@ -40,9 +40,10 @@
"sync:commandcode-catalog": "tsx .github/scripts/check-commandcode-model-metadata.ts command-code@latest --write",
"test:quota": "tsx tests/test-quota.ts && tsx tests/test-quota-command.ts && tsx tests/test-usage.ts",
"test:usage": "tsx tests/test-usage.ts",
"test:unit": "tsx tests/test-api-key.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-model-metadata-check.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-pricing-check.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-quota.ts && tsx tests/test-quota-command.ts && tsx tests/test-usage.ts && tsx tests/test-retry.ts && tsx tests/test-transport.ts",
"test:unit": "npm run test:routing && tsx tests/test-api-key.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-model-metadata-check.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-pricing-check.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-quota.ts && tsx tests/test-quota-command.ts && tsx tests/test-usage.ts && tsx tests/test-retry.ts && tsx tests/test-transport.ts",
"test:api-key": "tsx tests/test-api-key.ts",
"test:models": "tsx tests/test-models.ts && tsx tests/test-model-metadata-check.ts",
"test:routing": "tsx tests/test-routing.ts",
"test:runtime": "tsx tests/test-runtime.ts",
"test:pricing": "tsx tests/test-pricing.ts",
"test:pricing-check": "tsx tests/test-pricing-check.ts",
Expand Down
91 changes: 91 additions & 0 deletions src/routing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { randomUUID } from "node:crypto"
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
import { dirname } from "node:path"
import type { CommandCodeApi, CommandCodeModel } from "./models.ts"

export type EndpointChoice = "auto" | "responses" | "chat-completions"
export type EndpointOverrides = Readonly<Record<string, EndpointChoice>>

export function isEndpointChoice(value: unknown): value is EndpointChoice {
return value === "auto" || value === "responses" || value === "chat-completions"
}

export function parseEndpointOverrides(value: unknown): EndpointOverrides {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("Routing configuration must be an object with version: 1 and models")
}
if (!("version" in value) || value.version !== 1 || !("models" in value)) {
throw new Error("Unsupported routing configuration version")
}
const models = value.models
if (typeof models !== "object" || models === null || Array.isArray(models)) {
throw new Error("Routing models must be an object")
}
const entries = Object.entries(models)
if (entries.some(([id, choice]) => !id.trim() || !isEndpointChoice(choice))) {
throw new Error("Routing choices must be auto, responses, or chat-completions")
}
return Object.fromEntries(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseEndpointOverrides keeps explicit "auto" entries, but saveEndpointChoice deletes them. With a hand-edited {"models":{"x":"auto"}} the status line becomes (auto; user override) because routing[id] is truthy. Normalize here (drop auto entries) so both paths agree.

entries.filter((entry): entry is [string, EndpointChoice] => isEndpointChoice(entry[1])),
)
}

export async function loadEndpointOverrides(path: string): Promise<EndpointOverrides> {
try {
const value: unknown = JSON.parse(await readFile(path, "utf8"))
return parseEndpointOverrides(value)
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return {}
// Do not echo file contents, JSON parse excerpts, paths, or arbitrary values.
throw new Error(
"Could not read Command Code routing configuration; check its version and model choices",

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two issues with the fail-closed path:

  1. The message never names the file. The user sees every Provider API request fail with "check its version and model choices" and no hint where to look. The basename commandcode-routing.json is not sensitive — include it ("…in commandcode-routing.json in the agent directory").

  2. /commandcode-endpoint cannot repair the situation either, because saveEndpointChoice re-reads the broken file first and throws. So a corrupt optional convenience file bricks the whole provider until the user finds and hand-edits it.

I'd either fail open (ignore the file, keep routingWarning visible in status + stream warning, let /commandcode-endpoint … auto rewrite it from in-memory state), or at minimum make the command able to overwrite a malformed file so there is an in-host recovery path.

)
}
}

export function resolveEndpoint(
model: CommandCodeModel,
overrides: EndpointOverrides,
): CommandCodeApi {
const choice = overrides[model.id] ?? "auto"
if (choice === "auto") return model.api
if (model.api === "anthropic-messages") {
throw new Error("Claude models require Anthropic Messages; use auto")
}
if (choice === "responses" && model.api !== "openai-responses") {
throw new Error(
"The current catalog does not advertise Responses for this model; refresh the catalog or use auto",
)
}
return choice === "responses" ? "openai-responses" : "openai-completions"
}

/** The routing file is separate from the generated model cache and package contents. */
export async function saveEndpointChoice(
path: string,
modelId: string,
choice: EndpointChoice,
): Promise<EndpointOverrides> {
const overrides = { ...(await loadEndpointOverrides(path)) }
if (choice === "auto") delete overrides[modelId]
else
Object.defineProperty(overrides, modelId, {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment on why Object.defineProperty instead of plain assignment would help (I assume __proto__ as a model id). If that is the concern, the read side is the other half: overrides["constructor"] in resolveEndpoint/getRoutingStatus currently returns a function. Realistically catalog ids never collide with Object.prototype, so a comment plus Object.hasOwn on reads is enough — or build the map with Object.create(null) and skip both tricks.

value: choice,
enumerable: true,
configurable: true,
writable: true,
})
await mkdir(dirname(path), { recursive: true })
const temporary = `${path}.${randomUUID()}.tmp`
try {
await writeFile(temporary, JSON.stringify({ version: 1, models: overrides }, null, 2) + "\n", {
encoding: "utf8",
mode: 0o600,
flag: "wx",
})
await rename(temporary, path)
} finally {
await rm(temporary, { force: true })
}
return overrides
}
6 changes: 5 additions & 1 deletion src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface CommandCodeUi {
export interface CommandCodeCommandContext {
ui: CommandCodeUi
waitForIdle?: () => Promise<void>
model?: { id: string; provider: string }
}

export interface CommandCodeRuntimeApi<
Expand All @@ -31,6 +32,7 @@ export interface CommandCodeRuntimeOptions<TProviderConfig> {
loadCachedModels: () => Promise<readonly CommandCodeModel[]>
createProviderConfig: (models: readonly CommandCodeModel[]) => TProviderConfig
getTransport?: () => "unknown" | "provider" | "generate"
getRoutingStatus?: (model: CommandCodeCommandContext["model"]) => string
getStreamWarning?: () => string | undefined
now?: () => number
logWarning?: (message: string) => void
Expand Down Expand Up @@ -311,7 +313,9 @@ export class CommandCodeRuntime<TProviderConfig, TContext extends CommandCodeCom
handler: async (_args, ctx) => {
const status = this.getStatus()
ctx.ui.notify(
formatCommandCodeStatus(status),
[formatCommandCodeStatus(status), this.options.getRoutingStatus?.(ctx.model)]
.filter(Boolean)
.join("\n"),
status.warning || status.streamWarning ? "warning" : "info",
)
},
Expand Down
1 change: 1 addition & 0 deletions tests/test-models.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import "./test-routing.ts"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This side-effect import plus the new npm run test:routing in test/test:unit runs the routing suite twice per npm test. Keep the script entry and drop the import — importing one test file into another is also an unusual pattern in this repo.

import assert from "node:assert/strict"
import { mkdtemp, mkdir, readFile, readdir, rm, utimes, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
Expand Down
59 changes: 58 additions & 1 deletion tests/test-pi-local.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@

import assert from "node:assert/strict"
import { spawn, spawnSync } from "node:child_process"
import { accessSync, constants, mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"
import {
accessSync,
constants,
mkdtempSync,
rmSync,
mkdirSync,
writeFileSync,
readFileSync,
} from "node:fs"
import { createServer } from "node:http"
import { tmpdir } from "node:os"
import { delimiter, dirname, join, resolve } from "node:path"
Expand Down Expand Up @@ -656,6 +664,22 @@ async function runRpcExtensionCommands(timeoutMs = 30_000) {
await new Promise((resolve) => setTimeout(resolve, 100))
}

send({
id: "endpoint-choice",
type: "prompt",
message: `/commandcode-endpoint ${TEST_MODEL} chat-completions`,
})
await waitFor(
(event) =>
event.type === "extension_ui_request" &&
event.message?.includes("Applies to the next request"),
)
assert.equal(
JSON.parse(readFileSync(join(agentDir, "commandcode-routing.json"), "utf8")).models[
TEST_MODEL
],
"chat-completions",
)
includeRefreshedModel = true
send({ id: "refresh", type: "prompt", message: "/commandcode-refresh" })
await waitFor((event) => event.type === "response" && event.id === "refresh" && event.success)
Expand All @@ -679,6 +703,16 @@ async function runRpcExtensionCommands(timeoutMs = 30_000) {
event.message.includes("model count: 4"),
)

assert.match(statusAfter.message, /model endpoint: openai-completions.*user override/)
send({
id: "endpoint-auto",
type: "prompt",
message: `/commandcode-endpoint ${TEST_MODEL} auto`,
})
await waitFor(
(event) =>
event.type === "extension_ui_request" && event.message?.includes("(catalog). Applies"),
)
return {
commandNames,
statusBefore: statusBefore.message,
Expand Down Expand Up @@ -1167,6 +1201,29 @@ try {
assert.equal(lastRequestBody?.stream, true)
assert.ok(Array.isArray(lastRequestBody?.input), "Responses request should use the input array")

console.log(
"[pi-local] persisted endpoint override survives process restart and live catalog refresh",
)
writeFileSync(
join(agentDir, "commandcode-routing.json"),
JSON.stringify({ version: 1, models: { [RESPONSES_TEST_MODEL]: "chat-completions" } }),
)
const overridden = await runPi([
"--no-extensions",
"-e",
EXT_PATH,
"-p",
"say mock token",
"--provider",
"commandcode",
"--model",
RESPONSES_TEST_MODEL,
])
assert.equal(overridden.code, 0, overridden.stderr)
assert.equal(lastRequestPath, "/provider/v1/chat/completions")
assert.ok(Array.isArray(lastRequestBody.messages))
rmSync(join(agentDir, "commandcode-routing.json"))

console.log("[pi-local] Responses reasoning deltas stream before the item completes")
requestCount = 0
let releaseResponsesReasoningGate
Expand Down
Loading
Loading