Skip to content

feat(models): add persistent per-model endpoint overrides - #158

Open
patlux wants to merge 3 commits into
mainfrom
model-endpoint-overrides
Open

patlux wants to merge 3 commits into
mainfrom
model-endpoint-overrides

Conversation

@patlux

@patlux patlux commented Oct 15, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #156 on the client configuration side; does not claim to fix upstream capacity.

  • Add /commandcode-endpoint MODEL auto|responses|chat-completions.
  • Store choices independently of the generated cache; resolve the actual native wire and compatibility flags per request.
  • Validate Claude and Responses constraints, preserve unrelated choices, reject invalid configuration without exposing its contents.
  • Show effective routing in status. No automatic retries or mid-stream switching.

Verification

  • Full npm test with required real Pi 1.1.0 and OMP 18.11.1 mock-API tests passes.
  • New unit tests cover persisted overrides, auto/reset, invalid files, and per-model isolation.
  • Real Pi test verifies the command, refresh, process restart, and emitted Chat Completions request for a Responses-advertised model.
  • Format and diff checks pass.

No authenticated performance benchmark was run. File edits outside the command require reload; cross-process settings writes are not coordinated.

@patlux patlux self-assigned this Oct 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Memory benchmark

Compared base 3e43a7c with PR head 2b41afd on the same GitHub-hosted darwin runner. Lower values are better.

Metric Base PR PR − Base Change
Stable RSS 119.6 MiB ± 0.6 MiB 120.0 MiB ± 0.4 MiB +0.5 MiB ± 0.9 MiB +0.4%
Physical footprint 66.1 MiB ± 0.4 MiB 66.6 MiB ± 0.6 MiB +0.3 MiB ± 0.1 MiB +0.5%
Physical peak 76.5 MiB ± 0.3 MiB 77.0 MiB ± 0.8 MiB +0.2 MiB ± 0.1 MiB +0.3%

Interpretation: Differences larger than their measured MAD: Physical footprint +0.3 MiB ± 0.1 MiB, Physical peak +0.2 MiB ± 0.1 MiB.

Extension overhead above pi baseline

Metric Base overhead PR overhead Difference
Stable RSS +5.3 MiB ± 1.1 MiB +6.0 MiB ± 0.3 MiB +0.5 MiB ± 0.9 MiB
Physical footprint +3.7 MiB ± 0.5 MiB +3.8 MiB ± 0.5 MiB +0.3 MiB ± 0.1 MiB
Physical peak +3.9 MiB ± 0.5 MiB +4.4 MiB ± 0.4 MiB +0.2 MiB ± 0.1 MiB

Values are medians of 6 alternating, paired runs. The value after ± is the median absolute deviation (MAD). Each process was sampled 12 times after a 2500 ms warm-up.

Environment: pi 0.84.4, Bun 1.4.0, darwin arm64. RSS comes from ps; physical footprint and peak come from macOS footprint.

This is a comparative signal, not a pass/fail threshold. GitHub-hosted runner noise can affect absolute values.

@patlux patlux left a comment

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.

Feature and tests look solid; the real-Pi test for restart + refresh is the right level. Two things I'd change before merging (fail-closed behavior on a corrupt optional file, and the compat merge), the rest are nits. Details inline.

Comment thread tests/test-models.ts
@@ -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.

Comment thread src/routing.ts
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.

Comment thread src/routing.ts
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.

Comment thread src/routing.ts
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.

Comment thread index.ts
...(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.)

Comment thread index.ts
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.

@ThomasByr

Copy link
Copy Markdown
Contributor

Wow thanks a lot that looks promising!

@pierreraby pierreraby left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved. Verified locally at the exact head in an isolated worktree (npm ci): tsc --noEmit PASS, test-routing 3/3, full test:unit exit 0 (409 checks), test-pi-local PASS (incl. persisted override surviving restart + catalog refresh, forced chat-completions wire emitting a real Chat Completions request), Prettier + git diff --check PASS. Upstream CI fully green. I also had the previous head independently reviewed in a fresh context (APPROVE, no P0/P1); the rebase delta is main integration (release 0.7.8, catalog 1.82.4, quota/transport/redaction fixes) plus routing tests wired into the default suites.

Non-blocking notes for follow-up (edge-case hardening, none affect normal catalog ids or the no-override path): (1) prototype-chain reads on the override map (overrides[model.id], status line) vs. the guarded write side — Object.hasOwn would close it; (2) trim-check vs. stored-key mismatch; (3) compat-flags merge instead of replace on wire switch; (4) no e2e pin that a malformed routing file blocks Provider requests (documented behavior); (5) the recovery message could name commandcode-routing.json without leaking contents.

@ThomasByr

ThomasByr commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

I'm writing my message here since it has more chances to be reviewed.

I'm quite surprised about the response endpoint being used everywhere it can by default.
The official pi command code provider extension explicitly uses the response endpoint only for GPT models as seen here (in the "How routing works" details container).

This decision was documented 21st sep. so before #133. Some models advertising /response don't work well with it -- see image bellow.

Source Image image

@pierreraby

Copy link
Copy Markdown
Collaborator

Thanks for taking the time to write this up here — and you're right on the facts of the vendor doc.

What we can confirm. CommandCodeAI/pi-commandcode-provider exists, describes itself as the official Pi Command Code provider, and its "How routing works" section does pin /v1/responses to GPT and everything else to /v1/chat/completions (Claude on /v1/messages). That repo was created on 2026-09-21 and last pushed the same day, so the table predates the point where /provider/v1/models started advertising supported_endpoints per model.

What changed here, in #133 (2026-10-05). Instead of hard-coding the model families, this extension routes on the field the API advertises:

// src/models.ts:156
if (id.startsWith("claude-")) return "anthropic-messages"
return supportedEndpoints?.includes("/responses") ? "openai-responses" : "openai-completions"

So the divergence from the vendor table only affects models that explicitly advertise /responses. I re-read the live catalog just now: 96 models, of which 73 advertise both wires, 12 advertise /chat/completions only (z-ai/glm-5.3-flash, minimax/minimax-m3.1-flash, ornith/ornith-1.5-122b, …) and 11 are /messages only (Claude). The chat-completions-only models already land on the wire the vendor doc prescribes.

Why it was done this way — not a preference, a measurement. On 2026-10-03/04, on models advertising both wires, prompt caching showed up only on /responses: deepseek-v4.1-flash reported 1251/1333 cached input tokens there and 0 on /chat/completions, and a ~108k-token workload went from $0.0112 (0% cache, chat completions) to $0.00084 (~95–99% cache, responses), roughly 13× cheaper for identical work. That gap is what motivated routing by advertisement rather than by model family.

Where your point still stands. An advertisement is not a quality guarantee, and GLM-5.3 on /responses in #156 (slow, frequent rate_limit_error) is exactly that failure mode. Two things already in flight on this repo address it, both by @patlux:

One ask. The image attached here is the routing table itself, not the failing response — could you add the model id plus the exact error (status, body, or the pi error line) for the case you saw? With that we can tell apart "the gateway mis-advertises /responses for this model" from "the model is just slow/rate-limited", which would let us consider pinning defaults per model instead of per advertised endpoint. Whether the default should follow the vendor table (GPT → responses, everything else → chat completions, opt-in for the rest) is a product/routing decision on @patlux's table, not something I'd change unilaterally — but the cost numbers above belong in that discussion, so tag me if it gets there.

@ThomasByr

Copy link
Copy Markdown
Contributor

@pierreraby My bad, the image was there only to back-up my claim.
The rate_limit_error screenshot is attached bellow.
image
Model id is commandcode/zai-org/GLM5.3 (thinking high).

I also stumbled upon a message saying something about the OAI Response stream ending before the stop event.
This is much more rare however.

In practice, all of these errors greatly increase cache misses and bump up cost usage.
If the GLM5 family of models (or any other model really) works well with /chat/completions my guess is it would be better for most user to have it as a default?

@pierreraby

Copy link
Copy Markdown
Collaborator

@ThomasByr Thanks — the screenshot is what was missing, and now the failure mode is unambiguous.

What the error is. commandcode API error (429): {"message":"Upstream model provider is temporarily unavailable…","type":"rate_limit_error"} is Command Code's own body returned on the HTTP response; the (429) wrapper is pi's provider-error formatting, our own messages read Command Code API error 429: …. So the gateway is refusing GLM-5.3 on that endpoint — not auth, not quota, not the payload.

Why GLM-5.3 lands on /responses. The wire is chosen per model from /provider/v1/models: claude-* → /v1/messages, anything advertising /responses → OpenAI Responses, everything else → Chat Completions (apiForModelId in src/models.ts). The live catalog right now:

  • zai-org/GLM-5.3 → ["/chat/completions", "/responses"], so Responses wins
  • z-ai/glm-5.3-flashx → both as well
  • z-ai/glm-5.3-flash → /chat/completions only

So it is by construction, not an accidental default. It is also deliberate: on /responses the gateway caches the prompt — measured on deepseek-v4.1-flash at 1251/1333 cached tokens (~95–99%) versus 0 on /chat/completions, roughly 13x cheaper on a 108k-token workload (#133). That argument only holds where the endpoint actually behaves, and your report is exactly the counter-example.

Retry behaviour. Our bounded, Retry-After-aware 429 retry wraps the /alpha/generate fallback only; on the Responses wire the request is made by pi-ai, so a 429 surfaces immediately as you see it. That is worth a separate look, but retrying does not rescue a persistently unavailable upstream.

Your second symptom. "Stream ended before the stop event" is our truncation guard wording on the generate path (Stream ended unexpectedly before completion (no finish event) — response was truncated); from the host side a Responses stream cut before response.completed looks the same. Knowing the frequency and the model there would help.

Where this goes. A per-model endpoint choice is exactly what #158 adds (/commandcode-endpoint zai-org/GLM-5.3 chat-completions), so your report is the strongest argument for it landing — that is the owner's call, together with whether the default should prefer Chat Completions whenever both endpoints are advertised, which trades away the caching win above. #164 separately removes the silent fallback on 403.

Two things would make this actionable: does the 429 arrive before any token is streamed (plain HTTP response) or mid-stream, and does an immediate retry in the same session ever succeed? A pi version for the run would also help me match it to the exact code path.

@ThomasByr

ThomasByr commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

@pierreraby

  • pi is always to the latest version whenever i run it (1.4.0 as of today)
  • the error always arrives right after a tool call or a new message from me (follow-up or steer) -- so never mid-stream (*)
  • sometimes auto-retry succeeds, sometimes it doesn't and I have to wait several couple minutes for it to start working again

(*) however, it's almost always tied to when the throughput drops to 1 tok/s

for the second symptom, this happens at most ~once every day, model is the same.


P.S. Maybe selecting completion instead of responses will eventually bring the caching issue to GLM models as well. I guess we'll have to try and see

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GLM-5.3 single token per second and rate_limit_error on the Responses endpoint

3 participants