feat(http): a per-run model-credential proxy — a run's bearer buys model calls through the bot, pinned to its preset's model and metered as its own turns, and no container ever holds a key - #1012
Conversation
There was a problem hiding this comment.
LGTM: Well-factored per-run model proxy with bearer minting/revocation wired at the right lifecycle points; spec and thorough tests land in the same diff; only minor/nit observations.
- [minor] F1 src/index.ts:505 — Model proxy captures
config.config.providersonce at boot — a config reload won't be seen by the proxy - [nit] F2 src/channels/modelProxy.ts:526 — Log line's in= uses payload.length (UTF-16 chars), not bytes
- [nit] F3 src/channels/modelProxy.ts:507 — Race between verify and consumeTurn on a just-revoked run yields a '0-turn budget' refusal message
Verdict: approve — PR #1012 (feat/u25-model-proxy, head afa9dfd3) is a clean, well-tested addition with no blocking issues.
What it does: two provider-shaped routes (POST /v1/messages, POST /v1/chat/completions) on the bot's HTTP server, gated by a per-run bearer (sbr_<runId>.<secret>, constant-time secret compare) minted at executor provision and revoked at run end (both in RunEnding.finished before the seal and in the dispatcher's outer finally). Requests are pinned to the preset's model/max_tokens, turn-budgeted before forwarding, metered as model.turn spans with the native runner's attrs, and forwarded with the real key that never leaves the process. Plus an operator probe (/admin/model-proxy/bearer behind deploy:write) and a shim scan test holding the Worker to blind forwarding.
Verified: full 25-file diff read; specs:coverage clean (new model-proxy.md covers every new path); test-guard ok; http-ingress.md item 10 added consistently; door ordering (path → method → bearer, body unread on refusal), turn counted before forward, key/bearer never logged, revoked entries kept until expiry then swept — all match the spec's numbered behaviors. Contract check: spec rows all bound to new tests in the diff.
Findings:
- F1 (minor)
src/index.ts:505—createModelProxyHandleris handedconfig.config.providersonce at boot. If the ConfigStore hot-reloads providers (baseUrl/apiKeyEnv changes), the proxy keeps the stale map whilemintRunBearerreads the live one; consider passing a getter or the store. - F2 (nit)
src/channels/modelProxy.ts:526— the log'sin=${payload.length}counts UTF-16 code units, not bytes; the comment promises byte counts. - F3 (nit)
src/channels/modelProxy.ts:507— if a run is revoked betweenverifyandconsumeTurn, the refusal path publishes arun_notereading "past the 0-turn budget (0 turns used)"; a revoked-run branch would read better.
Nice touches: the sweep() on every mint keeps the in-process store bounded, the SseMeter handles split data: lines and CRLF, and the public-hygiene allowlist entry for the Anthropic version string is correctly scoped.
afa9dfd to
e684393
Compare
|
Re-review requested at e684393 — rebased over #1013 (its
Gates at this head: |
There was a problem hiding this comment.
LGTM: Re-review at e684393: prior findings F1–F3 verified fixed and the two CodeQL hardenings (no path echo, closed content-type table with nosniff) land cleanly with tests and matching spec edits; no new findings.
Verdict: approve — re-review at e6843937 (rebased over #1013): every prior finding fixed, both CodeQL hardenings landed with tests and spec updates, no new findings.
Prior findings, verified at the new head:
- F1 (fixed) —
ModelProxyDeps.providersis now() => Record<string, ProviderConfig>andsrc/index.ts:504passes() => config.config.providers, so a config reload reaches the proxy at each call, matching the mint path. Spec item 4 updated to promise this. - F2 (fixed) — the log line uses
Buffer.byteLength(payload)(src/channels/modelProxy.ts:535), soin=is bytes as the comment says. - F3 (fixed) —
consumeTurnnow returns a discriminatedTurnVerdict(reason: "ended" | "budget"); a run revoked between the door and the turn is403 revokedwith norun_noteand no span, proven by the new interleaving test (decideDoor→ revoke →handleAdmitted) assertingpublishedempty and nothing forwarded. Spec items 5 and the validation rows updated to match.
CodeQL fixes, checked:
- No request path is echoed in a refusal — the 404 message is a constant and
wrong_shapenames the route from the proxy's ownPROXY_PATHStable; the test injects an<img onerror>path and asserts it never appears in the body. - The node adapter writes content types from a closed three-literal table (
bodyKindOf: json / sse / text) withX-Content-Type-Options: nosniffon every response; an upstream error page claimingtext/htmlis rewrittentext/plain, body verbatim — spec item 9 and its validation row updated in the same diff.
Verified: the base diff is still the same 25-file change (the extra files in the delta since afa9dfd are the rebase's mainline commits, not this PR); specs:coverage clean; test-guard ok — 8 test file(s) changed, no verification removed; the TurnVerdict narrowing is sound (the budget branch still reads its counts). Nothing new to report.
…isioned and revoked the moment the run ends The credential a harness in an execution container will present in place of a model key (record 0032, step 1; record 0016's boundary): RunBearerStore mints one token per run — sbr_<runId>.<secret>, bound to the run id, pinned to the resolved provider and model and the preset's maxTokens/maxTurns, expiring at the effective budget plus a five-minute margin — the provision stage mints it right after the workspace attach, RunEnding.finished revokes it before the seal and the dispatch's outer finally revokes it for a run that never reached its loop. In-process, constant-time, never logged. Nothing consumes it yet. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…/chat/completions behind the run bearer, pinned to the preset's model and caps, metered as the run's own turns, forwarded with the bot's key Two routes on the bot, Anthropic- and OpenAI-shaped: the door decided from the headers (401/403/404 by reason), model and max_tokens pinned whatever the body named, everything else forwarded byte-for-byte, the turn budget spent before the call and refused as a turn_budget_exhausted note, one model.turn span per call with the runner's attrs read off the streamed or buffered answer, the real key revealed into the upstream request and nowhere else. The shim forwards both paths blind (the model-proxy route word; a scan holds it to that); POST /admin/model-proxy/bearer mints an operator's probe bearer for a live run under deploy:write so the path can be receipted. The model-proxy spec binds every row; http-ingress item 10, the code map and the how-to say the shim's and the operator's parts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
e684393 to
e6895d2
Compare
|
Re-review requested at e6895d2 — rebased over #1011 (the in-process ship loop's deletion stands: the ship pipeline's code-map row is gone and the proxy's row sits beside the coordinator's; The round-2 verdict at Gates at this head: |
There was a problem hiding this comment.
LGTM: Re-review at e6895d2: a clean rebase over #1011 — the PR's own 25-file change is substantively unchanged from the approved e684393, doc anchors follow main's rewritten items, gates green.
Verdict: approve — re-review at e6895d26: a clean rebase over #1011 with no substantive change to this PR since the approved e6843937. No findings.
Delta checked, file by file: git diff e6843937..HEAD restricted to the PR's 25 files touches only 7 of them, and every hunk is #1011's mainline landing under the rebase, not a change to this PR's work:
src/index.ts,src/core/dispatcher.ts,src/core/dispatcher.test.ts— the deltas are #1011's in-process ship-pipeline deletion (theagent:shipsuite rewritten as the hand-off to the plan runner,InMemoryCoordinatorInstanceStoredoubles, themakeExecutornever-called guard). The PR's own hunks in these files —mintRunBearerat provision,onFinishedrevoke plus the outer-finally revoke, the proxy wiring with the getterproviders: () => config.config.providers(F1's fix), the probe route — are byte-for-byte what was approved.docs/reference/code-map.md,specs/README.md,specs/http-ingress.md,specs/tracing.md— as the request notes: the model-proxy code-map row now sits beside the coordinator's rewritten row; http-ingress item 10 follows main's rewritten item 9; tracing item 17's emitter sentence follows main's "nothing opensship.roundany more" phrasing, with the PR's model-proxy sentence intact and consistent with it.
Confirmed at this head: the PR's diff vs origin/main is exactly GitHub's 25 files, +2548/−12; specs:coverage clean (every changed path covered, model-proxy.md included); test-guard ok — 8 test file(s) changed, no verification removed. All prior findings (F1 getter, F2 byte length, F3 revoked-run 403, CodeQL 84/85) remain fixed as verified at e6843937.
Switchboard's agents will soon run inside a separate harness process in an execution container, and that process must call the model without ever holding a model key. This PR adds the seam that makes that possible — a per-run credential the bot mints and honours on two provider-shaped proxy routes — with no change to how any run works today.
What & why
Record 0032 decides that pi becomes the harness for every preset, retired in a five-step replacement series. This is step 1 — the board item is #1006 (U25), under the adoption record #832 and the exploration #765: "the proxy and the run bearer on the bot, with the meter: no behaviour changes". Its constraint is record 0016's credential boundary — no Worker and no execution container holds a model key; the bot process does — so a harness in a container must believe it holds a key while holding a token minted for this run alone.
What the record decided, and what lands here, no more:
<provider>/<model>and the preset'smaxTokens/maxTurns, expiring at the run's effective budget plus five minutes, revoked the moment the run is reported finished. Token shapesbr_<runId>.<secret>, constant-time compare, in-process store, never logged.POST /v1/messages(Anthropic-shaped) andPOST /v1/chat/completions(OpenAI-shaped), the two shapes pi speaks natively through amodels.jsonprovider entry. The bearer is the whole door (401 / 403 / 404 by reason, decided from the headers). The request'smodeland output cap are pinned to the grant whatever the body named; everything else —cache_controlmarkers, tool definitions,thinking,output_config.effort, stop sequences — is forwarded byte-for-byte to the real provider with the real key from the bot's process secrets. Streamed answers are forwarded chunk for chunk.model.turnspan per proxied call with the same attrs the native runner sets (model,stopReason,inputTokens,outputTokens,cacheReadTokens,cacheWriteTokens,ttftMs), read off the streamed or buffered answer, so the run page, the friction analyzer and the costs page keep one vocabulary. A call pastmaxTurnsis refused as a typedturn_budget_exhaustedrun note.model-proxy) and a source scan holds the shim to knowing nothing else about them and to holding the model keys only for the container's environment.POST /admin/model-proxy/bearerunderdeploy:writemints one more bearer on a live run's entry, so the live row can be receipted against a real run before any harness consumes the proxy (the record's step-1 receipt is the spike's driver run through the proxy; it needs a bearer in hand).Nothing consumes the proxy yet: today's runs still call the provider in-process. The bearer exists around every run from here on but buys nothing unless presented. Rebased over #1013 and #1011: #1013's
http-ingress.mditem 9 andtracing.mditems 17–18 stand and item 10 and the emitter sentence follow them; #1011's deletions stand — the ship pipeline's code-map row is gone and the proxy's sits beside the coordinator's.Tour
1. The grant — what a bearer buys
A bearer authorizes exactly one run's model calls, and the grant carries everything the proxy needs to serve one without a second lookup: the run id, the wire model, the provider entry and its shape, the two caps, the absolute expiry, the span the proxied turns hang under and the run's own stream for a refusal's note.
Look for:
spanis the request root today and the harness bridge'srun.agentlater — the grant carries it so the parent moves without touching the proxy.switchboard/src/core/modelProxy/runBearers.ts
Lines 28 to 47 in e6895d2
2. Mint and issue
mintis the run's first bearer (a second mint for the same run replaces the entry, so a resume never leaves a stale credential valid);issueis the operator's extra bearer on the same entry — same expiry, same turn counter — and answers nothing for a run that ended or expired.switchboard/src/core/modelProxy/runBearers.ts
Lines 85 to 101 in e6895d2
3. Verify — by reason, in constant time
The token names its run, so the store tells an unknown run from a wrong secret for a known one, and revoked from expired, without leaking material. Every secret of the entry is compared with
timingSafeEqual.Look for: the order — revoked before expired, so a late call on an ended run always reads
revoked.switchboard/src/core/modelProxy/runBearers.ts
Lines 106 to 117 in e6895d2
4. Turns and revocation
A turn is counted before the call is forwarded, so concurrent calls cannot overrun the cap. The refusal names its reason:
endedfor a run revoked since its bearer verified (or never minted here),budgetpastmaxTurnswith the counts — so the proxy never reports a zero-turn budget for a run that simply ended.revokekeeps the entry until its expiry so a late call is answeredrevoked, notunknown_run.switchboard/src/core/modelProxy/runBearers.ts
Lines 123 to 141 in e6895d2
5. The token shape
sbr_<runId>.<secret>: a run id carries no., so the first dot splits exactly; anything else ismalformed.switchboard/src/core/modelProxy/runBearers.ts
Lines 169 to 184 in e6895d2
6. Minted as the executor is provisioned
The provision stage's new function: the resolved model ref parsed into provider and model, the provider's type from the config, the preset's caps, the effective profile's minutes plus the margin as the expiry, the request root as the span, the registry as the stream. Nothing without a store (the CLI, tests).
switchboard/src/core/dispatch/provision.ts
Lines 665 to 684 in e6895d2
7. The dispatcher: mint right after the attach
Once the workspace is attached and the fence check passed — the executor exists — the bearer is minted. It sits before the attach-head gate on purpose: a run refused there never reaches its loop, which is why the outer finally also revokes (step 9).
switchboard/src/core/dispatcher.ts
Lines 597 to 603 in e6895d2
8. Revoked the moment the run is reported finished
RunEndinggains anonFinishedhook that runs before the seal; the dispatcher revokes the run's bearer there, so no call after the run's end buys a model turn. A throwing hook is logged with the run id only.switchboard/src/core/runEnding.ts
Lines 96 to 103 in e6895d2
9. …and again in the outer finally
The ending's hook runs only for a run the loop finished; a bearer minted for a run that was refused after the attach or threw in the prompt is revoked here.
switchboard/src/core/dispatcher.ts
Lines 874 to 877 in e6895d2
10. The proxy's door — from the headers alone
Path (404, without repeating the path), method (405), a presented bearer (401) — either
Authorization: Bearerorx-api-key, since the Anthropic SDK sends the latter — then the store's verdict mapped to a status by reason. Decidable before the body, so the adapter never buffers a refused call.switchboard/src/channels/modelProxy.ts
Lines 160 to 186 in e6895d2
11. Pinning — the two fields the proxy touches
modeland the output cap; on the OpenAI shape a body that caps withmax_completion_tokensis pinned on that key and loses a straymax_tokens, so no second cap survives. Pure, and the only mutation of the body anywhere in the proxy.switchboard/src/channels/modelProxy.ts
Lines 193 to 206 in e6895d2
12. The upstream, Anthropic shape — where the real key is revealed
The run's
providers:entry (read live through a getter, so a config reload reaches the proxy as it reaches the mint) decides the URL and the header:x-api-keywith the API version the client sent, else the SDK default. Only three request headers are forwarded; the run bearer's own never are. An unnamed provider or an unset key variable is a 503 by name.Look for:
key.reveal()— the one place the provider key crosses a boundary in this change.switchboard/src/channels/modelProxy.ts
Lines 224 to 245 in e6895d2
13. The upstream, OpenAI shape
<baseUrl>/chat/completionswithAuthorization: Bearer <key>when the entry names a key variable, and no authorization header at all for a keyless local endpoint.switchboard/src/channels/modelProxy.ts
Lines 246 to 259 in e6895d2
14. The turn — an ended run, then the budget refusal as a typed run event
The turn is spent after the upstream is known valid (a deployment fault never costs a turn) and before the call is forwarded. A run that ended between the door and here is
403 revokedwith no note; the call pastmaxTurnspublishes onerun_noteof kindturn_budget_exhaustednaming the counts and is refused403— a429would be retried by the SDK, and this is a refusal.switchboard/src/channels/modelProxy.ts
Lines 508 to 530 in e6895d2
15. The span and the call
One
model.turnhandle per forwarded call, opened under the grant's span withmodelat start; the log'sin=is the payload's UTF-8 byte count; the fetch carries the caller's abort signal so a client that goes away aborts upstream. An unreachable provider ends the spanerrorand answers 502.switchboard/src/channels/modelProxy.ts
Lines 531 to 549 in e6895d2
16. The meter over a stream
A streamed answer is forwarded chunk for chunk through
meteredStream; each chunk feeds theSseMeterand stamps the first arrival; the span ends with the runner's attrs only when the last chunk has passed, orerrorwhen the upstream breaks the stream. An upstream status outside 2xx is forwarded verbatim with the span endederrorandhttpStatus.switchboard/src/channels/modelProxy.ts
Lines 557 to 582 in e6895d2
17. What the meter reads
Anthropic:
message_startcarries the input and cache counts,message_deltathe stop reason and the final output count. OpenAI: a choice'sfinish_reasonand theusageframe when the client asked for one. Both reuse the provider adapters' ownusageFrom*parsers and stop-reason mappings, so the attrs are the runner's.switchboard/src/channels/modelProxy.ts
Lines 374 to 401 in e6895d2
18. The attrs, as the runner sets them
model,stopReason, the four token counts andttftMs— each only when known, so a usage-less OpenAI stream ends a clean span with no counts rather than zeros.switchboard/src/channels/modelProxy.ts
Lines 406 to 421 in e6895d2
19. The response head — content types from a closed table, with nosniff
Everything this route writes — a refusal, a provider's answer, a provider's (or a gateway's) error page — gets its content type from three literals chosen by kind: the provider's JSON or event stream as such, anything else as
text/plain, plusX-Content-Type-Options: nosniff. A browser never renders any of it as a document, whatever an upstream body claims to be. The other forwarded headers ride as they came.switchboard/src/channels/modelProxy.ts
Lines 633 to 641 in e6895d2
20. The node adapter writes the stream under back-pressure
Head first (flushed), then each chunk with
drainawaited when the socket is full, thenend; a broken stream destroys the response. Aclosebefore the response finished aborts the upstream call.switchboard/src/channels/modelProxy.ts
Lines 667 to 676 in e6895d2
21. The operator's probe bearer
Past the
deploy:writedoor and a{ runId }body: an unknown run is 404, a run that ended 409, otherwise one more bearer on the run's entry, answered once and logged as the operator and the run — never the bearer.switchboard/src/channels/adminModelProxy.ts
Lines 86 to 107 in e6895d2
22. The shim's part — a route word, and a scan that holds it to nothing more
The shim already forwards every path it does not answer itself; its route table gives the two paths one word. A plain-Node scan in the worker-bot project holds
worker.tsto that: three self-answered paths, the fallthrough to the container, no/v1/anywhere, and the model keys only in the Env type and the forward list.switchboard/deploy/cloudflare/modelProxyForwarding.test.ts
Lines 17 to 29 in e6895d2
23. Wired in the bot
The store is created once and handed to the dispatcher's deps; the handler reads the
providers:block through a getter; the two routes and the mint route sit beside/ingressand/mcp, before the dashboard gate — the Access application does not cover them, and the bearer is the door.switchboard/src/index.ts
Lines 703 to 716 in e6895d2
24. The test that proves the meter
A fake upstream streams the real Anthropic frame sequence; the test asserts the chunks come back byte-identical and in order, the span is still open before the stream drains, and ends under the root with exactly the runner's attrs.
switchboard/src/channels/modelProxy.test.ts
Lines 500 to 524 in e6895d2
25. The test that proves the response head
Through the node adapter: a refusal is written as JSON with
nosniff; an upstream 502 body that claimstext/html(a script tag) is written as plain text and never rendered.switchboard/src/channels/modelProxy.test.ts
Lines 791 to 817 in e6895d2
26. The test that proves the bearer's life through a dispatch
The provider double, mid-turn, issues a bearer on the run's entry and verifies it (zero proxied turns); after the dispatch the grant reads back with the resolved model, the general preset's caps and five-minute budget plus the margin — revoked, and nothing buys a call after the end.
switchboard/src/core/dispatcher.test.ts
Lines 10587 to 10624 in e6895d2
27. The spec
A new spec binds every row above to its test and carries the
[agent]live row (the probe recipe); items 2–6 are the bearer, the door, the pinning, the budget and the meter.switchboard/docs/reference/specs/model-proxy.md
Lines 12 to 16 in e6895d2
28. Remaining changes
src/core/modelProxy/runBearers.test.ts— the store's unit tests: mint/verify, expiry/revocation/sweep, turns (endedandbudget) and the operator's extra bearer.src/channels/modelProxy.test.ts— the door (a refusal never echoes the path), the body, both shapes' pinning and pass-through, the meter, the budget and the ended-run branch (L623–L635), upstream failures, the log's silence, the node adapter.src/channels/adminModelProxy.test.ts— the probe route's door, answers and body validation.src/core/dispatch/provision.test.ts—mintRunBearerwith and without a store.src/core/runEnding.test.ts— theonFinishedhook runs once, before any seal, and a throwing hook is logged.src/core/trace/workerTrace.ts,src/core/trace/workerTrace.test.ts— themodel-proxyroute word and no other/v1path.deploy/cloudflare/vitest.config.mjs— the worker-bot project includes the new scan.src/core/dispatcher.ts— theonFinishedwiring oncreateRunEnding(L228–L234) beside the mint and the finally shown above.src/index.ts— the store (L349–L352), the handler with the providers getter (L502–L507) and the startup log line naming the two paths.src/channels/modelProxy.ts—bodyKindOf(L615–L620), the closed content-type table the adapter reads.docs/reference/specs/http-ingress.md— item 10 (the shim's part) and its row, after fix(ship): the runner trusts the review child's own record of its post — a review posted a second ago no longer reads as unposted, and GitHub is asked patiently only when the record is silent #1013's item 9; the Code header names the new wiring.docs/reference/specs/tracing.md— item 17 names the proxy as one moremodel.turnemitter, after fix(ship): the runner trusts the review child's own record of its post — a review posted a second ago no longer reads as unposted, and GitHub is asked patiently only when the record is silent #1013's sentences.docs/reference/specs/README.md— the index row.docs/reference/code-map.md— the module row.docs/how-to/operate-production.md— "Probe the model proxy": the mint and the curl.docs/reference/authorization.md—deploy:writenow also names the probe route.scripts/public-hygiene.allow— the API version constant's line (date-shaped, not a date) allowed by name.Decisions
sbr_<runId>.<secret>lets the proxy answer404 unknown_runand401 unknown_bearerdistinctly without a second lookup and keeps the store keyed by run, which is what revocation needs. The secret is 32 random bytes compared in constant time; the run id in the clear reveals nothing a run page URL does not.RunEnding.finishedrevokes before the seal (a run the loop finished); the dispatch's outer finally revokes for a run that never reached its loop. Both are idempotent no-ops on the other's work.revoked, not a budget. The turn store answersendedfor a revoked entry (review finding F3), so the proxy gives the door's answer and never publishes a "0-turn budget" note.run.agent, and the proxy is untouched. The streamed-span taxonomy listsrun.agentasmodel.turn's parent — a static invariant over the streamed set, nothing at runtime rejects a root parent, and the timeline classifies by name — so a probe's turn reads on the run page as any turn does.x-api-key; the OpenAI shape sendsAuthorization: Bearer. Accepting either on either route costs nothing and removes a class of "wrong header" failures.403, not429, for the turn budget. A rate-limit status is one every SDK retries with backoff; the budget refusal is final. The body is written in the route's shape so the SDK surfaces the message as the provider's own error.text/plain— withnosniff, so nothing this route writes is ever rendered as a document, whatever an upstream error page claims.max_completion_tokensis pinned on its own key when the body used it (the newer OpenAI models require it), elsemax_tokens; a stray second cap is dropped rather than left to disagree.providers:block is read through a getter (review finding F1), so a config reload reaches the proxy exactly as it reaches the mint; nothing is captured at boot.stream_options.include_usageis injected on the OpenAI shape: the proxy touches two fields and nothing else, and some compatible servers reject unknown fields. A usage-less stream ends a clean span with no counts.deploy:write. The record decided no operator surface; the live row cannot be receipted without a bearer in hand until a harness consumes the proxy, and an admin route gated like the restart and the crash is the cheapest honest way to get one. Amodel-proxy:mintgrant of its own is one policy row away if it ever needs a narrower holder.RunEndinghook, provision mint, dispatcher wiring) lands first and is complete on its own — a credential nothing yet consumes; the proxy, the shim's part, the probe and the docs follow.2023-06-01is allowed by name in the hygiene ratchet: the Messages API names versions by date and this is the SDK's default, not an incident retelling.Validation
Every criterion is bound to a test in
docs/reference/specs/model-proxy.md(rows 1–19) andhttp-ingress.mditem 10.ended/budget), the operator's extra bearer, secrets never read backsrc/core/modelProxy/runBearers.test.ts(12 cases)src/channels/modelProxy.test.ts::the door…::*::pinning and pass-through — the Anthropic shape::*,::… — the OpenAI shape::*::the meter — one model.turn span per proxied call, the runner's attrs::*turn_budget_exhausted, therun_notenaming the counts, nothing forwarded, no span; an ended run between door and turn is 403revokedwith no note::the turn budget — a refusal is a typed run event::*,::the turn budget — a run that ended between the door and the turn::*httpStatus; unreachable 502; broken stream errors both::upstream failures::*::what the proxy never says::*flushHeaders, client close aborts upstream, content types from the closed table withnosniff, an HTML-claiming upstream body written as plain text::createModelProxyHandler — the node adapter::*src/channels/adminModelProxy.test.ts::*dispatch(): minted before the first turn with the resolved model and the preset's caps and budget, valid mid-turn, revoked after finish; a refused dispatch mints nothingsrc/core/dispatcher.test.ts::the model proxy's run bearer through dispatch()::*,src/core/dispatch/provision.test.ts::mintRunBearer…::*,src/core/runEnding.test.ts::…::finished(id) runs the onFinished hook…deploy/cloudflare/modelProxyForwarding.test.ts::*,src/core/trace/workerTrace.test.ts::shimRoute::names the model proxy's two paths…git stashofrunEnding.ts,workerTrace.ts,dispatcher.tsalone → exactly 3 failures (the hook, the route word, the mint-through-dispatch), restored afternpm run verifye6843937(the same 25-file diff; the rebase over #1011 changed no file of this PR) — root: 372 files / 6844 tests passed, 2 skipped; the memory Worker (workerd): 23 files / 292 tests; the package smoke: 3 files / 26 tests; the web app: 7 files / 107 tests; 16ok —check lines. Re-run ate6895d26: exit 0 — root: 373 files / 6809 tests passed, 2 skipped; the memory Worker 23 / 292, the package smoke 3 / 26, the web app 7 / 107; 16ok —check linesnpm run specs:coverage -- --changed origin/main...HEAD --test-guardspecs:coverage — every changed source path has a covering spec·test-guard ok — 8 test file(s) changednpm run check:pr-titlecheck:pr-title okCodeQLcheck at this head[agent]row inmodel-proxy.md(the how-to's curl)Local run at this head:
npx vitest run --changed origin/main→ 373 test files, 6809 tests passed, 2 skipped (botandworker-botprojects);npm run check:consistency,npm run typecheck,npm run lintall exit 0;specs:coverage --test-guardclean.🤖 Generated with Claude Code