Skip to content

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

Merged
justinhelmer merged 2 commits into
mainfrom
feat/u25-model-proxy
Sep 14, 2026

Conversation

@justinhelmer

@justinhelmer justinhelmer commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

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:

  • A run bearer — minted the moment a run's executor is provisioned, bound to the run id, pinned to the resolved <provider>/<model> and the preset's maxTokens/maxTurns, expiring at the run's effective budget plus five minutes, revoked the moment the run is reported finished. Token shape sbr_<runId>.<secret>, constant-time compare, in-process store, never logged.
  • Two proxy routes on the botPOST /v1/messages (Anthropic-shaped) and POST /v1/chat/completions (OpenAI-shaped), the two shapes pi speaks natively through a models.json provider entry. The bearer is the whole door (401 / 403 / 404 by reason, decided from the headers). The request's model and output cap are pinned to the grant whatever the body named; everything else — cache_control markers, 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.
  • The meter — one model.turn span 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 past maxTurns is refused as a typed turn_budget_exhausted run note.
  • The shim's part — the bot shim already forwards every path it does not answer itself; the two paths get a route word (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.
  • An operator's probe bearerPOST /admin/model-proxy/bearer under deploy:write mints 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.md item 9 and tracing.md items 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: span is the request root today and the harness bridge's run.agent later — the grant carries it so the parent moves without touching the proxy.

export interface RunBearerGrant {
runId: string;
/** `<provider>/<model>` as `run_meta` carries it — the `model.turn` span's `model` attr. */
modelRef: string;
/** The `providers:` entry the call is forwarded to, and its wire shape. */
providerName: string;
providerType: ProviderConfig["type"];
/** The bare model id the wire carries, whatever the request named. */
model: string;
/** The per-call output cap and the turn cap, the preset's. */
maxTokens: number;
maxTurns: number;
/** Absolute: the run's budget plus `BEARER_MARGIN_MS`, from the mint. */
expiresAt: number;
/** The span every proxied `model.turn` hangs under: the request root today;
* the harness bridge's `run.agent` once a harness drives the run. */
span: Span;
/** The run's stream — where a refusal's `run_note` lands. */
publish: (event: RunEvent) => void;
}

2. Mint and issue

mint is the run's first bearer (a second mint for the same run replaces the entry, so a resume never leaves a stale credential valid); issue is the operator's extra bearer on the same entry — same expiry, same turn counter — and answers nothing for a run that ended or expired.

mint(grant: RunBearerGrant): string {
this.sweep();
const secret = randomBytes(SECRET_BYTES);
this.entries.set(grant.runId, { grant, secrets: [secret], turns: 0, revoked: false });
return token(grant.runId, secret);
}
/** Another bearer for a run still live: the same entry, expiry and turn
* counter (an operator's probe spends the run's own turns). Nothing for a
* run this store never minted, one that ended, or one past its expiry. */
issue(runId: string): { token: string; expiresAt: number } | undefined {
const entry = this.entries.get(runId);
if (!entry || entry.revoked || this.opts.clock() >= entry.grant.expiresAt) return undefined;
const secret = randomBytes(SECRET_BYTES);
entry.secrets.push(secret);
return { token: token(runId, secret), expiresAt: entry.grant.expiresAt };
}

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.

verify(presented: string): BearerVerdict {
const parsed = parse(presented);
if (!parsed) return { ok: false, reason: "malformed" };
const entry = this.entries.get(parsed.runId);
if (!entry) return { ok: false, reason: "unknown_run", runId: parsed.runId };
let matched = false;
for (const secret of entry.secrets) if (constantTimeEqual(secret, parsed.secret)) matched = true;
if (!matched) return { ok: false, reason: "unknown_bearer", runId: parsed.runId };
if (entry.revoked) return { ok: false, reason: "revoked", runId: parsed.runId };
if (this.opts.clock() >= entry.grant.expiresAt) return { ok: false, reason: "expired", runId: parsed.runId };
return { ok: true, grant: entry.grant, turns: entry.turns };
}

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: ended for a run revoked since its bearer verified (or never minted here), budget past maxTurns with the counts — so the proxy never reports a zero-turn budget for a run that simply ended. revoke keeps the entry until its expiry so a late call is answered revoked, not unknown_run.

consumeTurn(runId: string): TurnVerdict {
const entry = this.entries.get(runId);
if (!entry || entry.revoked) return { ok: false, reason: "ended" };
if (entry.turns >= entry.grant.maxTurns) {
return { ok: false, reason: "budget", turns: entry.turns, maxTurns: entry.grant.maxTurns };
}
entry.turns++;
return { ok: true, turn: entry.turns };
}
/** The run ended: every bearer of it stops buying calls. The entry stays
* until its expiry so a late call is answered `revoked`, not `unknown_run`.
* True when a live entry was revoked; false for an unknown or already-ended run. */
revoke(runId: string): boolean {
const entry = this.entries.get(runId);
if (!entry || entry.revoked) return false;
entry.revoked = true;
return true;
}

5. The token shape

sbr_<runId>.<secret>: a run id carries no ., so the first dot splits exactly; anything else is malformed.

function token(runId: string, secret: Buffer): string {
return `${BEARER_PREFIX}${runId}.${secret.toString("base64url")}`;
}
/** `sbr_<runId>.<secret>` → its parts, or nothing for any other shape. A run id
* carries no `.` (`RUN_ID_PATTERN`), so the first dot splits exactly. */
function parse(presented: string): { runId: string; secret: Buffer } | undefined {
if (!presented.startsWith(BEARER_PREFIX)) return undefined;
const rest = presented.slice(BEARER_PREFIX.length);
const dot = rest.indexOf(".");
if (dot <= 0 || dot === rest.length - 1) return undefined;
const runId = rest.slice(0, dot);
const secret = rest.slice(dot + 1);
if (!/^[A-Za-z0-9_-]{1,64}$/.test(runId) || !/^[A-Za-z0-9_-]+$/.test(secret)) return undefined;
return { runId, secret: Buffer.from(secret, "base64url") };
}

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).

export function mintRunBearer(deps: ProvisionDeps, ctx: MintBearerContext): string | undefined {
const store = deps.runBearers;
if (!store) return undefined;
const { runId, agent, profile, resolved, registry, root, clock } = ctx;
const { provider: providerName, model } = parseModelRef(resolved.modelRef);
const providerCfg = deps.config.config.providers[providerName];
if (!providerCfg) return undefined;
return store.mint({
runId,
modelRef: resolved.modelRef,
providerName,
providerType: providerCfg.type,
model,
maxTokens: agent.maxTokens,
maxTurns: agent.maxTurns,
expiresAt: clock() + profile.minutes * 60_000 + BEARER_MARGIN_MS,
span: root,
publish: (event) => registry.publish(runId, event),
});
}

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).

// The run's model-proxy bearer (dispatch/provision.ts; docs/reference/specs/model-proxy.md):
// minted the moment the executor is provisioned, bound to this run, pinned
// to its preset's model and caps, expiring at its budget plus the margin.
// Revoked by the ending above when the run finishes, and by the outer
// finally for a run that never reached its loop. Nothing consumes it yet
// but the proxy's own probe; a harness in the workspace will.
mintRunBearer(deps, { runId, agent, profile, resolved, registry, root, clock });

8. Revoked the moment the run is reported finished

RunEnding gains an onFinished hook 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.

finished(runId, hooks) {
finished.push({ id: runId, ...(hooks?.afterSeal ? { afterSeal: hooks.afterSeal } : {}) });
try {
deps.onFinished?.(runId);
} catch (err) {
log(`[ending] finished ${runId}: ${describe(err)}`);
}
},

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.

// …and a bearer minted for a run that never reached its loop (a head gate
// after the attach, a throw in the prompt) is revoked here — the ending's
// hook ran only for a run the loop finished.
if (registered) deps.runBearers?.revoke(registered.id);

10. The proxy's door — from the headers alone

Path (404, without repeating the path), method (405), a presented bearer (401) — either Authorization: Bearer or x-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.

const shape = proxyShapeOf(path);
// A refusal never echoes what the caller sent: the path is not repeated.
if (!shape) return refused(undefined, 404, "not_found", "no model proxy at this path");
if ((method ?? "GET").toUpperCase() !== "POST") return refused(shape, 405, "method_not_allowed", "POST only");
const presented = presentedBearer(headers);
if (presented === undefined) {
return refused(
shape,
401,
"missing_bearer",
"a run bearer is required, as `Authorization: Bearer <bearer>` or `x-api-key: <bearer>`",
);
}
const verdict = bearers.verify(presented);
if (verdict.ok) return { ok: true, shape, grant: verdict.grant, turns: verdict.turns };
switch (verdict.reason) {
case "malformed":
return refused(shape, 401, "malformed_bearer", "the bearer is not a run bearer");
case "unknown_run":
return refused(shape, 404, "unknown_run", "the bearer names a run this bot does not hold", verdict.runId);
case "unknown_bearer":
return refused(shape, 401, "unknown_bearer", "the bearer was not minted for its run", verdict.runId);
case "expired":
return refused(shape, 403, "expired", "the bearer expired with the run's budget", verdict.runId);
case "revoked":
return refused(shape, 403, "revoked", "the run ended and its bearer with it", verdict.runId);
}

11. Pinning — the two fields the proxy touches

model and the output cap; on the OpenAI shape a body that caps with max_completion_tokens is pinned on that key and loses a stray max_tokens, so no second cap survives. Pure, and the only mutation of the body anywhere in the proxy.

export function pinRequest(
shape: ProxyShape,
body: Record<string, unknown>,
grant: Pick<RunBearerGrant, "model" | "maxTokens">,
): Record<string, unknown> {
const pinned: Record<string, unknown> = { ...body, model: grant.model };
if (shape === "openai-compatible" && "max_completion_tokens" in pinned) {
pinned.max_completion_tokens = grant.maxTokens;
delete pinned.max_tokens;
} else {
pinned.max_tokens = grant.maxTokens;
}
return pinned;
}

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-key with 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.

if (!cfg || cfg.type !== shape) {
return {
ok: false,
code: "provider_unconfigured",
message: `provider "${providerName}" is not configured for ${PROXY_PATHS[shape]}`,
};
}
const headers: Record<string, string> = { "content-type": "application/json" };
for (const name of FORWARDED_REQUEST_HEADERS) {
const value = header(requestHeaders, name);
if (value !== undefined) headers[name] = value;
}
if (shape === "anthropic") {
const keyEnv = cfg.apiKeyEnv ?? ANTHROPIC_API_KEY_ENV;
const key = secrets.named(keyEnv);
if (!key)
return { ok: false, code: "provider_key_missing", message: `provider "${providerName}": ${keyEnv} is not set` };
headers["x-api-key"] = key.reveal();
headers["anthropic-version"] ??= DEFAULT_ANTHROPIC_VERSION;
const base = (cfg.baseUrl ?? DEFAULT_ANTHROPIC_BASE_URL).replace(/\/+$/, "");
return { ok: true, url: `${base}${ANTHROPIC_MESSAGES_PATH}`, headers };
}

13. The upstream, OpenAI shape

<baseUrl>/chat/completions with Authorization: Bearer <key> when the entry names a key variable, and no authorization header at all for a keyless local endpoint.

if (!cfg.baseUrl) {
return { ok: false, code: "provider_unconfigured", message: `provider "${providerName}" names no baseUrl` };
}
if (cfg.apiKeyEnv) {
const key = secrets.named(cfg.apiKeyEnv);
if (!key)
return {
ok: false,
code: "provider_key_missing",
message: `provider "${providerName}": ${cfg.apiKeyEnv} is not set`,
};
headers.authorization = `Bearer ${key.reveal()}`;
}
return { ok: true, url: `${cfg.baseUrl.replace(/\/+$/, "")}/chat/completions`, headers };

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 revoked with no note; the call past maxTurns publishes one run_note of kind turn_budget_exhausted naming the counts and is refused 403 — a 429 would be retried by the SDK, and this is a refusal.

const turn = deps.bearers.consumeTurn(grant.runId);
if (!turn.ok && turn.reason === "ended") {
// The run ended between the door and here: its bearer verified a moment
// ago and is revoked now. The same answer the door gives, no budget note.
log(`[model-proxy] 403 revoked run=${grant.runId}`);
return refusalResponse(shape, 403, "revoked", "the run ended and its bearer with it");
}
if (!turn.ok) {
const used = `${turn.turns} turn${turn.turns === 1 ? "" : "s"} used`;
grant.publish({
type: "run_note",
kind: "turn_budget_exhausted",
summary: `model proxy refused a call past the ${turn.maxTurns}-turn budget (${used})`,
at: deps.clock(),
});
log(`[model-proxy] 403 turn_budget_exhausted run=${grant.runId} turns=${turn.turns}/${turn.maxTurns}`);
return refusalResponse(
shape,
403,
"turn_budget_exhausted",
`the run's ${turn.maxTurns}-turn budget is spent (${used})`,
);
}

15. The span and the call

One model.turn handle per forwarded call, opened under the grant's span with model at start; the log's in= 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 span error and answers 502.

const payload = JSON.stringify(pinRequest(shape, body, grant));
const startedAt = deps.clock();
const span = grant.span.start("model.turn", { attrs: { model: grant.modelRef }, startedAt });
const outcome = (status: number, outBytes: number) =>
`[model-proxy] run=${grant.runId} turn=${turn.turn}/${grant.maxTurns} ${shape}${status} in=${Buffer.byteLength(payload)} out=${outBytes} ${Math.max(0, deps.clock() - startedAt)}ms`;
let res: Response;
try {
res = await (deps.fetch ?? fetch)(upstream.url, {
method: "POST",
headers: upstream.headers,
body: payload,
...(req.signal ? { signal: req.signal } : {}),
});
} catch (err) {
span.fail(err);
span.end("error");
log(`[model-proxy] run=${grant.runId} turn=${turn.turn}/${grant.maxTurns} ${shape} → upstream unreachable`);
return refusalResponse(shape, 502, "upstream_unreachable", "the model provider did not answer");
}

16. The meter over a stream

A streamed answer is forwarded chunk for chunk through meteredStream; each chunk feeds the SseMeter and stamps the first arrival; the span ends with the runner's attrs only when the last chunk has passed, or error when the upstream breaks the stream. An upstream status outside 2xx is forwarded verbatim with the span ended error and httpStatus.

const contentType = res.headers.get("content-type") ?? "";
if (contentType.includes("text/event-stream") && res.body) {
const meter = new SseMeter(shape);
let firstAt: number | undefined;
let outBytes = 0;
const stream = meteredStream(res.body, {
onChunk: (chunk) => {
firstAt ??= deps.clock();
outBytes += chunk.byteLength;
meter.feed(chunk);
},
onDone: () => {
span.setAttrs(turnAttrs(grant, meter.result(), firstAt !== undefined ? firstAt - startedAt : undefined));
span.end("ok");
log(outcome(res.status, outBytes));
},
onError: (err) => {
span.setAttrs(turnAttrs(grant, meter.result(), firstAt !== undefined ? firstAt - startedAt : undefined));
span.fail(err);
span.end("error");
log(
`[model-proxy] run=${grant.runId} turn=${turn.turn}/${grant.maxTurns} ${shape} → stream broke after ${outBytes} bytes`,
);
},
});
return { status: res.status, headers, body: stream };

17. What the meter reads

Anthropic: message_start carries the input and cache counts, message_delta the stop reason and the final output count. OpenAI: a choice's finish_reason and the usage frame when the client asked for one. Both reuse the provider adapters' own usageFrom* parsers and stop-reason mappings, so the attrs are the runner's.

private apply(json: unknown): void {
const event = record(json);
if (!event) return;
if (this.shape === "anthropic") {
if (event.type === "message_start") {
const usage = usageFromAnthropic(record(event.message)?.usage);
if (usage) this.usage = { ...this.usage, ...usage };
} else if (event.type === "message_delta") {
const stop = record(event.delta)?.stop_reason;
if (typeof stop === "string") this.stopReason = anthropicStopReason(stop);
const usage = record(event.usage);
if (usage) {
if (typeof usage.output_tokens === "number") this.usage.outputTokens = usage.output_tokens;
if (typeof usage.input_tokens === "number") this.usage.inputTokens = usage.input_tokens;
if (typeof usage.cache_read_input_tokens === "number")
this.usage.cacheReadTokens = usage.cache_read_input_tokens;
if (typeof usage.cache_creation_input_tokens === "number") {
this.usage.cacheWriteTokens = usage.cache_creation_input_tokens;
}
}
}
return;
}
const choice = Array.isArray(event.choices) ? record(event.choices[0]) : undefined;
if (typeof choice?.finish_reason === "string") this.stopReason = openAiStopReason(choice.finish_reason);
const usage = usageFromOpenAI(event.usage);
if (usage) this.usage = { ...this.usage, ...usage };
}

18. The attrs, as the runner sets them

model, stopReason, the four token counts and ttftMs — each only when known, so a usage-less OpenAI stream ends a clean span with no counts rather than zeros.

export function turnAttrs(grant: Pick<RunBearerGrant, "modelRef">, meter: TurnMeter, ttftMs?: number): SpanAttrs {
const u = meter.usage;
return {
model: grant.modelRef,
...(meter.stopReason ? { stopReason: meter.stopReason } : {}),
...(u
? {
inputTokens: u.inputTokens,
outputTokens: u.outputTokens,
...(u.cacheReadTokens !== undefined ? { cacheReadTokens: u.cacheReadTokens } : {}),
...(u.cacheWriteTokens !== undefined ? { cacheWriteTokens: u.cacheWriteTokens } : {}),
}
: {}),
...(ttftMs !== undefined ? { ttftMs } : {}),
};
}

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, plus X-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.

const head = (status: number, headers: Record<string, string>) => {
for (const [name, value] of Object.entries(headers)) if (name !== "content-type") res.setHeader(name, value);
const kind = bodyKindOf(headers["content-type"]);
if (kind === "sse") res.setHeader("content-type", "text/event-stream; charset=utf-8");
else if (kind === "json") res.setHeader("content-type", "application/json; charset=utf-8");
else res.setHeader("content-type", "text/plain; charset=utf-8");
res.setHeader("x-content-type-options", "nosniff");
res.writeHead(status);
};

20. The node adapter writes the stream under back-pressure

Head first (flushed), then each chunk with drain awaited when the socket is full, then end; a broken stream destroys the response. A close before the response finished aborts the upstream call.

head(result.status, result.headers);
res.flushHeaders();
try {
for await (const chunk of result.body) {
if (!res.write(chunk)) await once(res, "drain");
}
res.end();
} catch (err) {
res.destroy(err instanceof Error ? err : new Error(String(err)));
}

21. The operator's probe bearer

Past the deploy:write door 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.

const facts = deps.bearers.grantOf(runId);
if (!facts) {
json(404, { ok: false, error: "unknown_run" });
return;
}
const issued = deps.bearers.issue(runId);
if (!issued) {
json(409, { ok: false, error: "run_ended", runId });
return;
}
log(
`[admin/model-proxy] ${auth.subject} → bearer for ${runId} (expires ${new Date(issued.expiresAt).toISOString()})`,
);
json(201, {
ok: true,
runId,
bearer: issued.token,
expiresAt: issued.expiresAt,
model: facts.modelRef,
path: PROXY_PATHS[facts.providerType],
turns: { used: facts.turns, max: facts.maxTurns },
});

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.ts to 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.

it("answers three paths itself — the restart, the coordinator's instance create and its status — and hands every other path to the container, so /v1/messages and /v1/chat/completions reach the bot unread", () => {
expect(source).toMatch(/pathname === "\/admin\/restart"\s*\?\s*await handleAdminRestart\(/);
expect(source).toMatch(/pathname === COORDINATOR_INSTANCES_PATH\s*\?\s*await handleCoordinatorInstances\(/);
expect(source).toMatch(/statusId !== undefined\s*\?\s*await handleCoordinatorInstanceStatus\(/);
expect(source).toMatch(/:\s*await getContainer\(env\.SWITCHBOARD, INSTANCE\)\.fetch\(forwarded\)/);
// A path the shim's own route table gives no root to is forwarded as it came.
expect(source).toMatch(
/if \(route === undefined\) return getContainer\(env\.SWITCHBOARD, INSTANCE\)\.fetch\(inbound\);/,
);
// Nothing in the shim knows the proxy's paths: no route of its own, no rewrite, no read of a body.
expect(source).not.toContain("/v1/");
expect(source).not.toContain("model-proxy");
});

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 /ingress and /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

if (isModelProxyPath(path)) {
modelProxy(req, res);
return;
}
// An operator's bearer for a live run (a `deploy:write` ingress bearer, like
// the restart and the crash), so the proxy can be probed against a real run.
if (path === MODEL_PROXY_BEARER_PATH) {
handleAdminModelProxyBearer(req, res, {
tokens: processSecrets.get("SWITCHBOARD_INGRESS_TOKENS"),
grantsFor: (id) => config.grantsFor(id),
bearers: runBearers,
});
return;
}

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.

it("a streamed Anthropic reply is forwarded chunk for chunk in order with its headers, and the span ends only after the last chunk with model, stop reason, the four token counts and the time to first token", async () => {
const h = harness({ answer: () => streamingResponse(anthropicStreamChunks(), h.clock, 250) });
const token = h.bearers.mint(h.grant("run-1"));
const res = await handleModelProxyRequest(request({ headers: bearer(token) }).req, h.deps);
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toBe("text/event-stream; charset=utf-8");
expect(res.headers["request-id"]).toBe("req_abc");
expect(h.starts.filter((s) => s.name === "model.turn")).toHaveLength(1);
expect(h.ends.filter((s) => s.name === "model.turn")).toHaveLength(0); // open until the stream drains
const chunks = await drain(res.body);
expect(chunks).toEqual(anthropicStreamChunks());
const [turn] = h.ends.filter((s) => s.name === "model.turn");
expect(turn.parentSpanId).toBe(h.root.id);
expect(turn.status).toBe("ok");
expect(turn.attrs).toEqual({
model: "anthropic/claude-opus-5",
stopReason: "tool_use",
inputTokens: 1200,
outputTokens: 42,
cacheReadTokens: 1000,
cacheWriteTokens: 150,
ttftMs: 250,
});
expect(h.bearers.grantOf("run-1")?.turns).toBe(1);
});

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 claims text/html (a script tag) is written as plain text and never rendered.

it("writes every content type from a closed table with nosniff: a refusal is JSON, an upstream error page that claims to be HTML is written as plain text and never rendered", async () => {
const h = harness({
answer: () =>
new Response("<script>alert(1)</script>", {
status: 502,
headers: { "content-type": "text/html; charset=utf-8" },
}),
});
const token = h.bearers.mint(h.grant("run-1"));
const handler = createModelProxyHandler(h.deps);
const refused = fakeReqRes("POST", ANTHROPIC_MESSAGES_PATH, {}, "{}");
handler(refused.req, refused.res);
await vi.waitFor(() => expect(refused.ended()).toBe(true));
expect(refused.headers()["content-type"]).toBe("application/json; charset=utf-8");
expect(refused.headers()["x-content-type-options"]).toBe("nosniff");
const page = fakeReqRes("POST", ANTHROPIC_MESSAGES_PATH, bearer(token), JSON.stringify(anthropicRequest()));
handler(page.req, page.res);
await vi.waitFor(() => expect(page.ended()).toBe(true));
expect(page.status()).toBe(502);
expect(page.headers()["content-type"]).toBe("text/plain; charset=utf-8");
expect(page.headers()["x-content-type-options"]).toBe("nosniff");
expect(page.text()).toBe("<script>alert(1)</script>");
expect(bodyKindOf("application/json")).toBe("json");
expect(bodyKindOf("text/event-stream; charset=utf-8")).toBe("sse");
expect(bodyKindOf("text/html")).toBe("text");
expect(bodyKindOf(undefined)).toBe("text");
});

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.

describe("the model proxy's run bearer through dispatch()", () => {
it("is minted for the run before its first model turn, pinned to the resolved model and the preset's caps and hung under the request root, and is revoked once the run finishes", async () => {
const clock = { now: 1_700_000_000_000 };
const store = new RunBearerStore({ clock: () => clock.now });
const seen: Array<{ runId: string | undefined; verified: boolean; turns: number | undefined }> = [];
let runId: string | undefined;
const provider: Provider = {
name: "fake",
async complete(): Promise<CompletionResult> {
// Mid-turn: the run's entry exists, a bearer issued on it verifies, and no turn has gone through the proxy.
const issued = runId ? store.issue(runId) : undefined;
const verdict = issued ? store.verify(issued.token) : undefined;
seen.push({ runId, verified: verdict?.ok === true, turns: verdict?.ok ? verdict.turns : undefined });
return { content: [{ type: "text", text: "answer" }], stopReason: "end_turn" };
},
};
const deps = makeDeps(YAML_FIXTURE, provider);
deps.runBearers = store;
deps.clock = () => clock.now;
const base = fakeIO();
const io: ChannelIO = { ...base.io, runStarted: ({ id }) => void (runId = id) };
await dispatch(deps, msg("hello there"), io);
expect(base.replies).toContain("answer");
expect(seen).toEqual([{ runId, verified: true, turns: 0 }]);
const facts = store.grantOf(runId!);
expect(facts).toMatchObject({
runId,
modelRef: "anthropic/general-model",
providerName: "anthropic",
providerType: "anthropic",
model: "general-model",
maxTurns: 8,
maxTokens: 16000,
turns: 0,
revoked: true,
expiresAt: clock.now + 5 * 60_000 + BEARER_MARGIN_MS, // the general preset's five minutes, plus the margin
});
expect(store.issue(runId!)).toBeUndefined(); // nothing buys a call after the run's end

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.

2. **The run bearer.** One is minted the moment a run's executor is provisioned (the dispatch's attach, `mintRunBearer`), bound to the run's id and pinned to the resolved `<provider>/<model>`, the preset's `maxTokens` and `maxTurns`, expiring at the run's effective budget plus `BEARER_MARGIN_MS` (five minutes, so the write-up a budget exhaustion asks for still has a credential). The token is `sbr_<runId>.<secret>` — the run named in the clear, 32 random bytes as the secret — so the proxy tells an unknown run from a wrong secret without a second lookup, and secrets compare in constant time. It is **revoked** the moment the run is reported finished (`RunEnding.finished` runs `onFinished` before the seal) and again by the dispatch's outer finally for a run that never reached its loop; a revoked entry stays until its expiry so a late call is answered `revoked`, then is swept. The store is in-process: a bot restart drops every bearer with the runs that held them, and the generation that resumes a run mints its own. A second mint for the same run replaces the first. Without a store (the CLI, tests) nothing is minted and the run is byte-identical to before. The token returns to the caller alone: never logged, never on the record, never in a file.
3. **The door, from the headers alone, in order.** A path that is not one of the two is `404`; a non-POST `405`; no bearer `401 missing_bearer` (the bearer rides `Authorization: Bearer <bearer>`, the OpenAI shape, or `x-api-key: <bearer>`, the Anthropic SDK's header — either is accepted on either route); a token that is not a run bearer `401 malformed_bearer`; a run this bot never minted `404 unknown_run`; the right run with a wrong secret `401 unknown_bearer`; a bearer past its expiry `403 expired`; one whose run ended `403 revoked`. The node adapter decides this before it reads a byte of the body and destroys a refused request. Past the door: a body over the cap (32 MB, the Messages API's own) is `413`; a body that is not a JSON object `400 invalid_body`. Every refusal is written in the route's shape so the client's SDK reads it as the provider's own error: `{ type: "error", error: { type: <code>, message } }` on the Anthropic route, `{ error: { type: <code>, message } }` on the OpenAI one. A refusal never echoes what the caller sent: an unknown path is refused without repeating it, and the wrong-shape message names the route hit from the proxy's own table.
4. **Pinning and the upstream.** `model` on the wire is the grant's bare model id whatever the body named; the output cap is the grant's `maxTokens` — as `max_tokens`, or on the OpenAI shape as `max_completion_tokens` when the body capped with that key (a stray `max_tokens` beside it is dropped, so no second cap survives). The upstream is the run's `providers:` entry: the Anthropic shape goes to `<baseUrl ?? https://api.anthropic.com>/v1/messages` with the real key as `x-api-key` and the client's `anthropic-version` (the SDK's default when the client sent none) and `anthropic-beta`; the OpenAI shape to `<baseUrl>/chat/completions` with `Authorization: Bearer <key>` when the entry names an `apiKeyEnv`, and no authorization header at all when it does not (a local endpoint). Only `accept`, `anthropic-version` and `anthropic-beta` are forwarded from the request; the run bearer's own headers never are. A provider the config does not name, or one without a `baseUrl` on the OpenAI shape, is `503 provider_unconfigured`; a key variable that is unset `503 provider_key_missing` naming the variable — both decided before a turn is spent. The key is revealed into the upstream request and nowhere else. The `providers:` block is read at each call, so a config reload reaches the proxy as it reaches the mint.
5. **The turn budget is enforced here, as a typed run event.** A turn is counted before the call is forwarded (so concurrent calls cannot overrun) and the call past the grant's `maxTurns` is refused `403 turn_budget_exhausted`, forwarding nothing and opening no span; the refusal publishes one `run_note` of kind `turn_budget_exhausted` on the run's stream naming the counts (`model proxy refused a call past the 60-turn budget (60 turns used)`) — the same kind the native loop notes when its own turn cap ends the loop, so the run page and the friction analyzer read one vocabulary. A `403` rather than a `429`: a rate-limit status is one an SDK retries, and this is a refusal. An upstream failure spends the turn like a failed native turn does. A run that ended between the door and the turn — its bearer verified a moment ago, the run revoked since — is `403 revoked` with no note: the store answers `ended`, never a zero-turn budget.
6. **The meter: one `model.turn` per proxied call, the runner's attrs.** Every forwarded call opens one `model.turn` span under the span the grant carries — the request root today; the harness bridge's `run.agent` once a harness drives the run — with `model` (the `<provider>/<model>` ref, as `run_meta` carries it) at start, and ends it with the runner's attrs: `stopReason` mapped as the provider adapters map it (Anthropic `end_turn` and `stop_sequence` → `end_turn`, `tool_use`, `max_tokens`, anything else `other`; OpenAI `stop` → `end_turn`, `tool_calls` → `tool_use`, `length` → `max_tokens`), `inputTokens`, `outputTokens`, `cacheReadTokens` and `cacheWriteTokens` when the provider reported them, and `ttftMs` (the first chunk's arrival) on a streamed answer. A streamed answer is forwarded chunk for chunk as it arrives and the span ends only after the last chunk; the `SseMeter` reads the counts as the bytes pass — Anthropic's `message_start` carries the input and cache counts, `message_delta` the stop reason and the final output count; an OpenAI stream's `finish_reason` and its `usage` frame when the client asked for one (`stream_options.include_usage`) — a `data:` line may span chunks and CRLF framing is accepted. A buffered JSON answer is metered whole. An upstream status outside 2xx is forwarded verbatim (status, headers, body) and ends the span `error` with `httpStatus`; an upstream that cannot be reached is `502 upstream_unreachable`; a stream the upstream breaks ends the span `error` and errors the forwarded stream. The span's parent is the request root, so it streams onto the run's record through the same sink every setup span uses and is dropped, like any late span, once the run is sealed.

28. Remaining changes

  • src/core/modelProxy/runBearers.test.ts — the store's unit tests: mint/verify, expiry/revocation/sweep, turns (ended and budget) 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.tsmintRunBearer with and without a store.
  • src/core/runEnding.test.ts — the onFinished hook runs once, before any seal, and a throwing hook is logged.
  • src/core/trace/workerTrace.ts, src/core/trace/workerTrace.test.ts — the model-proxy route word and no other /v1 path.
  • deploy/cloudflare/vitest.config.mjs — the worker-bot project includes the new scan.
  • src/core/dispatcher.ts — the onFinished wiring on createRunEnding (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.tsbodyKindOf (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 more model.turn emitter, 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.mddeploy:write now also names the probe route.
  • scripts/public-hygiene.allow — the API version constant's line (date-shaped, not a date) allowed by name.

Decisions

  • The token names its run. sbr_<runId>.<secret> lets the proxy answer 404 unknown_run and 401 unknown_bearer distinctly 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.
  • Expiry is the effective budget plus five minutes, from the profile's minutes rather than the preset's — a run a boundary clipped gets a shorter bearer — and the margin is the record's, so a budget-exhaustion write-up and the post-step still have a credential.
  • Revocation happens twice, on purpose. RunEnding.finished revokes 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.
  • An ended run between the door and the turn is revoked, not a budget. The turn store answers ended for a revoked entry (review finding F3), so the proxy gives the door's answer and never publishes a "0-turn budget" note.
  • The span the turns hang under is the grant's, not the proxy's. Today that is the request root; when the harness bridge lands it hands its own run.agent, and the proxy is untouched. The streamed-span taxonomy lists run.agent as model.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.
  • Both header spellings. pi's Anthropic-shaped provider goes through the Anthropic SDK, which sends x-api-key; the OpenAI shape sends Authorization: Bearer. Accepting either on either route costs nothing and removes a class of "wrong header" failures.
  • 403, not 429, 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.
  • A refusal never echoes what the caller sent, and the response head is a closed table. CodeQL flagged the request path in a 404 message and the forwarded upstream body as reflected XSS sinks. The path is no longer repeated (the wrong-shape message names the route hit from the proxy's own table), and the adapter writes every content type from three literals — the provider's JSON or event stream as such, anything else as text/plain — with nosniff, so nothing this route writes is ever rendered as a document, whatever an upstream error page claims.
  • max_completion_tokens is pinned on its own key when the body used it (the newer OpenAI models require it), else max_tokens; a stray second cap is dropped rather than left to disagree.
  • The turn is spent after the upstream is known and before the call. A missing key or an unnamed provider is a deployment fault and costs the run nothing; an upstream error spends the turn the way a failed native turn does.
  • The 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.
  • No stream_options.include_usage is 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.
  • The probe route rides 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. A model-proxy:mint grant of its own is one policy row away if it ever needs a narrower holder.
  • Two commits: the bearer (store, RunEnding hook, 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-01 is 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) and http-ingress.md item 10.

Criterion Proof Receipt
Bearer: mint/verify/expiry/revocation/sweep, turns (ended / budget), the operator's extra bearer, secrets never read back src/core/modelProxy/runBearers.test.ts (12 cases) passes locally (below)
The door: 401 missing/malformed, 404 unknown run, 401 wrong secret, 403 expired/revoked, 405, 404 non-proxy path without echoing it, both header spellings, 400 wrong shape — body unread, nothing forwarded src/channels/modelProxy.test.ts::the door…::* passes locally
Pinning and pass-through, both shapes: model and cap pinned, every other field byte-identical, the real key in the right header, the bearer nowhere upstream, keyless provider, 503 by name before a turn ::pinning and pass-through — the Anthropic shape::*, ::… — the OpenAI shape::* passes locally
The meter: streamed chunks forwarded in order, span open until the last chunk, the runner's attrs (both shapes), SSE lines across chunks and CRLF ::the meter — one model.turn span per proxied call, the runner's attrs::* passes locally
Turn budget: 403 turn_budget_exhausted, the run_note naming the counts, nothing forwarded, no span; an ended run between door and turn is 403 revoked with 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::* passes locally
Upstream 4xx/5xx forwarded verbatim with an error span and httpStatus; unreachable 502; broken stream errors both ::upstream failures::* passes locally
No log line carries the bearer, the key or any request/reply text ::what the proxy never says::* passes locally
Node adapter: refusal from headers with the body unread, streamed write after flushHeaders, client close aborts upstream, content types from the closed table with nosniff, an HTML-claiming upstream body written as plain text ::createModelProxyHandler — the node adapter::* passes locally
Probe route: 201 for a live run and a log without the bearer; 404/409; 401/403 naming the grant/503/405 from headers; 400 body src/channels/adminModelProxy.test.ts::* passes locally
The bearer through 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 nothing src/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… passes locally
The shim forwards the two paths blind and holds the keys only for the container; the route word deploy/cloudflare/modelProxyForwarding.test.ts::*, src/core/trace/workerTrace.test.ts::shimRoute::names the model proxy's two paths… passes locally (worker-bot project)
Red step: the wiring's tests fail without the wiring git stash of runEnding.ts, workerTrace.ts, dispatcher.ts alone → exactly 3 failures (the hook, the route word, the mint-through-dispatch), restored after run locally before the first push
The whole gate npm run verify exit 0 at e6843937 (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; 16 ok — check lines. Re-run at e6895d26: 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; 16 ok — check lines
Every changed source path has a covering spec; no test narrowed without its spec npm run specs:coverage -- --changed origin/main...HEAD --test-guard specs:coverage — every changed source path has a covering spec · test-guard ok — 8 test file(s) changed
The title is a changelog line npm run check:pr-title check:pr-title ok
CodeQL: no reflected-XSS sink the two alerts of the first head (a path echoed in a 404, the forwarded body written under the upstream's content type) — fixed by steps 10 and 19 the CodeQL check at this head
Live: a bearer minted for a real run buys one metered call through the deployed bot [agent] row in model-proxy.md (the how-to's curl) human-gated: after deploy, on the tracker (#1006)

Local run at this head: npx vitest run --changed origin/main → 373 test files, 6809 tests passed, 2 skipped (bot and worker-bot projects); npm run check:consistency, npm run typecheck, npm run lint all exit 0; specs:coverage --test-guard clean.

🤖 Generated with Claude Code

Comment thread src/channels/modelProxy.ts Fixed
Comment thread src/channels/modelProxy.ts Fixed

@coreplane-switchboard coreplane-switchboard Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.providers once 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:505createModelProxyHandler is handed config.config.providers once at boot. If the ConfigStore hot-reloads providers (baseUrl/apiKeyEnv changes), the proxy keeps the stale map while mintRunBearer reads the live one; consider passing a getter or the store.
  • F2 (nit) src/channels/modelProxy.ts:526 — the log's in=${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 between verify and consumeTurn, the refusal path publishes a run_note reading "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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Auto-approved: coreplane-switchboard[bot] reviewed this PR and posted an LGTM verdict (see its review). This repository opted in through its REVIEW_BOT_LOGIN and REVIEW_BOT_ID variables.

@justinhelmer

Copy link
Copy Markdown
Contributor Author

Re-review requested at e684393 — rebased over #1013 (its http-ingress.md item 9 and tracing.md items 17–18 stand; item 10 and the emitter sentence now follow them), and the round's findings addressed:

  • F1 (minor) — providers captured at boot. ModelProxyDeps.providers is now a getter; index.ts hands () => config.config.providers, so a config reload reaches the proxy exactly as it reaches the mint. Tour step 12 and 23.
  • F2 (nit) — in= counted UTF-16 units. Now Buffer.byteLength(payload). Tour step 15.
  • F3 (nit) — a run revoked between verify and consumeTurn read as a 0-turn budget. consumeTurn answers by reason (ended | budget); an ended run is 403 revoked with no note, the door's own answer. Tour steps 4 and 14; spec items 5 and its row.
  • CodeQL 84 / 85 (reflected XSS at the refusal write and the streamed write). A refusal never echoes what the caller sent (the 404 no longer repeats the path; the wrong-shape message names the route hit from the proxy's own table), and the adapter writes every content type from a closed table of three literals — the provider's JSON or event stream as such, anything else as text/plain — with X-Content-Type-Options: nosniff, so nothing this route writes is rendered as a document whatever an upstream error page claims. Tour steps 10, 19 and 25; spec items 3 and 9.

Gates at this head: npm run verify exit 0 (372 / 6844 root tests, memory Worker 23 / 292, package 3 / 26, web 7 / 107); specs:coverage --test-guard clean; check:pr-title ok. The Tour's anchors are regenerated at the new sha.

@coreplane-switchboard coreplane-switchboard Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.providers is now () => Record<string, ProviderConfig> and src/index.ts:504 passes () => 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), so in= is bytes as the comment says.
  • F3 (fixed)consumeTurn now returns a discriminated TurnVerdict (reason: "ended" | "budget"); a run revoked between the door and the turn is 403 revoked with no run_note and no span, proven by the new interleaving test (decideDoor → revoke → handleAdmitted) asserting published empty 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_shape names the route from the proxy's own PROXY_PATHS table; 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) with X-Content-Type-Options: nosniff on every response; an upstream error page claiming text/html is rewritten text/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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Auto-approved: coreplane-switchboard[bot] reviewed this PR and posted an LGTM verdict (see its review). This repository opted in through its REVIEW_BOT_LOGIN and REVIEW_BOT_ID variables.

Justin Helmer and others added 2 commits September 13, 2026 20:29
…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>
@justinhelmer

Copy link
Copy Markdown
Contributor Author

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; http-ingress.md item 9's live row and tracing.md item 17 are main's, with item 10, its row and the emitter sentence following them). No code of this PR changed: the same 25-file diff, git diff --stat origin/main..HEAD unchanged.

The round-2 verdict at e6843937 (approve, no new findings) carries over; this request is for the new head only.

Gates at this head: npx vitest run --changed origin/main → 373 files / 6809 tests passed, 2 skipped; check:consistency, typecheck, lint exit 0; specs:coverage --test-guard clean. npm run verify is running; its receipt lands in the body's Validation row when it completes. The Tour's anchors are regenerated at the new sha (three moved: the dispatcher's mint and finally, the dispatcher test).

@justinhelmer
justinhelmer merged commit 524118d into main Sep 14, 2026
29 checks passed
@justinhelmer
justinhelmer deleted the feat/u25-model-proxy branch September 14, 2026 03:33

@coreplane-switchboard coreplane-switchboard Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 (the agent:ship suite rewritten as the hand-off to the plan runner, InMemoryCoordinatorInstanceStore doubles, the makeExecutor never-called guard). The PR's own hunks in these files — mintRunBearer at provision, onFinished revoke plus the outer-finally revoke, the proxy wiring with the getter providers: () => 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 opens ship.round any 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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Auto-approved: coreplane-switchboard[bot] reviewed this PR and posted an LGTM verdict (see its review). This repository opted in through its REVIEW_BOT_LOGIN and REVIEW_BOT_ID variables.

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.

2 participants