From 834e86ab89b8689a4f7a85aa387c9f6bd42c1846 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 16 Sep 2026 11:05:12 +0900 Subject: [PATCH 1/6] fix(combos): hop on a definite zero-output context overflow (#4659) [skip ci] A heterogeneous combo mixes context windows, so a refusal that says "this turn does not fit THIS model" is not evidence the turn is impossible. The chain stopped at the first undersized target anyway, and a native transport made it worse by reporting a zero-output overflow as a generic upstream_server_error carrying precise context-window prose, which never looked like a context verdict at all. Classify that case from the innermost provider message. classifyError remaps any occurrence of "context window", "context length", "maximum context" or "too many tokens" found anywhere in the blob; inheriting that looseness would let a context_length_exceeded token sitting in a code field beside "Unsupported parameter: user" authorize a replay. The new classifier unwraps only the exact proxy wrapper, within four envelopes and 16,384 characters, and reads the leaf. Three bounds keep the widening honest: - A JSON-shaped body that does not parse fails closed. normalizeUpstreamErrorText caps classificationText at 500 characters, so a long envelope reaches the classifier as a prefix, and reading that prefix as prose would let whichever field landed in the first 500 bytes authorize a hop. - Only statuses that speak about the request are admitted: 400, 413, 422 and 5xx. A 401/403 body that merely quotes context prose keeps its provider-wide cooldown instead of being rescored as request-shaped. - Structured origin_rejected now stops explicitly. The existing test only matched that token in the message, so an origin reporting it out of band could have been overridden by context prose. Cooldown treats a definite overflow as request-shaped, so an oversized turn no longer cools a healthy target. This cannot duplicate visible output. A streaming child reaches combo classification only through preflightComboStreamResponse, which commits the child on any text, tool call or unknown event and synthesizes a failure envelope only for a zero-output terminal, so a turn whose text the client already saw is never reclassified as a hop. tests/helpers/combo-context-overflow-cases.ts pins that directly. Closes #4659 Co-authored-by: RHODIZ IT --- src/combos/failover.ts | 85 ++++++++++++ structure/runtime.md | 8 +- tests/codex-integration/combos.test.ts | 13 +- tests/helpers/combo-context-overflow-cases.ts | 123 ++++++++++++++++++ ...uter-combo-failover-classification.test.ts | 54 ++++++++ tests/routing/routing-policy-fallback.test.ts | 22 ++-- .../server/server-combo-failover-e2e.test.ts | 34 +---- 7 files changed, 293 insertions(+), 46 deletions(-) create mode 100644 tests/helpers/combo-context-overflow-cases.ts diff --git a/src/combos/failover.ts b/src/combos/failover.ts index e60fe83deef..f5715da9baf 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -401,11 +401,15 @@ export function comboFailureCooldownScope( ): ComboFailureCooldownScope { const code = normalizedFailureCode(options?.code); // Request-shape refusals first: an oversized request must not cool a healthy target. + // A native transport can surface a zero-output model overflow as a generic + // upstream_server_error carrying precise context-window prose, so consult the bounded + // message classifier too: that target is healthy, the turn was simply too large for it. if ( status === 413 || REQUEST_SHAPE_FAILURE_CODES.has(code) || isRequestLocalFreePromptCap(status, message, options?.code) || isProviderTargetContextOverflow(status, message, options?.code) + || isDefiniteContextOverflow(status, message) || isRequestLocalTargetIncompatibility(status, message, options?.code) ) return "none"; if (isProviderScopedQuotaCap(status, message, options?.code)) return "provider"; @@ -451,6 +455,74 @@ function isProviderTargetContextOverflow( && /\bprompt\s+\d+\s*>\s*\d+\s+maximum context length\b/i.test(message); } +/** A status can carry a verdict about the REQUEST; 401/403/429 speak about the credential. */ +const CONTEXT_VERDICT_STATUSES: ReadonlySet = new Set([400, 413, 422]); + +/** + * Phrases a provider emits when the INPUT does not fit this model's context window. Matched + * against the innermost provider message only, so an unrelated refusal that merely quotes one + * of these tokens in a code field cannot authorize a replay. + */ +const DEFINITE_CONTEXT_OVERFLOW_PHRASES = [ + "exceeds the context window", + "exceed the context window", + "context window exceeded", + "context length exceeded", + "maximum context length", + "maximum context window", + "too many tokens", +]; + +/** Wrapper envelopes unwrapped before the leaf message is read. */ +const MAX_CONTEXT_OVERFLOW_ENVELOPES = 4; + +function isDefiniteContextOverflowMessage(text: string): boolean { + const normalized = text.toLowerCase(); + return normalized === "context_length_exceeded" + || DEFINITE_CONTEXT_OVERFLOW_PHRASES.some(phrase => normalized.includes(phrase)); +} + +/** + * Confirm a context overflow from the provider MESSAGE rather than from a code token that + * merely appears somewhere in the envelope. An upstream controls both fields and can emit a + * contradictory pair -- `context_length_exceeded` beside `Unsupported parameter: user` -- and + * that is not evidence the turn is too large for this model. `classifyError` reads the whole + * blob, which is exactly the looseness this must not inherit. + * + * A JSON-shaped body that fails to parse is truncated or corrupt, not prose: `classificationText` + * is capped at 500 characters by `normalizeUpstreamErrorText` before it reaches this function, so + * a long envelope arrives here as a JSON prefix. Reading that prefix as plain text would let an + * arbitrary field that happens to sit in the first 500 bytes authorize a hop, so it fails closed. + * + * Only the exact proxy wrapper is unwrapped, within a fixed envelope budget and 16,384 characters. + */ +function isDefiniteContextOverflow(status: number, message: string): boolean { + if (!CONTEXT_VERDICT_STATUSES.has(status) && status < 500) return false; + if (message.length > 16_384) return false; + let text = message.trim(); + // One pass per unwrapped envelope, plus one for the leaf the last envelope yields. + for (let unwrapped = 0; unwrapped <= MAX_CONTEXT_OVERFLOW_ENVELOPES; unwrapped += 1) { + const providerPrefix = /^Provider error \d{3}:\s*/.exec(text); + if (providerPrefix) text = text.slice(providerPrefix[0].length).trim(); + if (!text.startsWith("{")) return isDefiniteContextOverflowMessage(text); + if (unwrapped === MAX_CONTEXT_OVERFLOW_ENVELOPES) return false; + let payload: unknown; + try { payload = JSON.parse(text); } catch { return false; } + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; + const record = payload as Record; + const response = record.response && typeof record.response === "object" && !Array.isArray(record.response) + ? record.response as Record + : undefined; + const source = [record.error, response?.error, response?.last_error, record.last_error, record] + .find((candidate): candidate is Record => + !!candidate && typeof candidate === "object" && !Array.isArray(candidate) + && typeof (candidate as Record).message === "string"); + if (!source) return false; + text = (source.message as string).trim(); + } + return false; +} + export function comboFailureDecision( status: number, message: string, @@ -458,6 +530,10 @@ export function comboFailureDecision( ): ComboFailureDecision { if (status === 499) return "stop"; if (message.toLowerCase().includes("origin_rejected")) return "stop"; + // Structured form of the same hard refusal. The prose test above misses it when the origin + // reports the code out of band, and every hop rule below -- including the context-overflow + // one -- must stay subordinate to it. + if (normalizedFailureCode(options?.code) === "origin_rejected") return "stop"; // The origin may already be executing this turn (the Codex WebSocket relay sent the create // frame and never saw a response event). Hopping would send the same request to a second // target while the first may still be generating; the honest status goes to the client. @@ -476,6 +552,15 @@ export function comboFailureDecision( // (for example 5059 + invalid_request_prompt_too_long). That is evidence that this // target is too small, not that every later combo target is incapable of serving it. if (isProviderTargetContextOverflow(status, message, options?.code)) return "hop"; + // A definite context-window refusal is target-local inside a heterogeneous combo: this model + // cannot hold the turn, but a later target may have a larger window. Two boundaries keep this + // safe. It is reached only after cancellation, structured origin/cyber refusals and + // non-replayable post-send codes have already stopped. And it only ever classifies a failure + // the combo stream preflight already proved emitted no output: `comboStreamPayloadCommitsOutput` + // commits the child on any text, tool call or unknown event, and only a zero-output terminal + // becomes a failure response at all, so a turn whose text the client already saw is never + // reclassified here. + if (isDefiniteContextOverflow(status, message)) return "hop"; // A local input-admission refusal (#1524) says "this candidate cannot fit the request", // not "the request is impossible": the next candidate may have a larger context window. // diff --git a/structure/runtime.md b/structure/runtime.md index 248c3011c64..61ae0f711aa 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -427,9 +427,13 @@ Translated audio/file admission follows the [final-adapter input contract](adapt `src/combos/failover.ts` treats three intact HTTP 400 invalid-request envelopes as request-local incompatibilities: exactly `Unsupported parameter: user`; `unsupported_value` naming `reasoning.effort` or `reasoning_effort` with an explicit unsupported-value message; and `param: input` with a bounded model-scoped `does not support image inputs` message. A null provider code is accepted only for that observed image envelope. Only the exact proxy wrapper is unwrapped, within three envelopes and 16,384 characters; conflicting codes, malformed/truncated envelopes and reflected JSON do not gain hop permission. -The combo may advance to its next eligible unattempted target before output commitment. It records no target/provider cooldown for these request-local mismatches and does not silently drop reasoning controls or raise `none` to a supported rung. Cancellation, origin/cyber-policy rejection, non-replayable post-send errors and the existing streaming commit boundary stay authoritative. Other invalid requests remain terminal. +The combo may advance to its next eligible unattempted target before output commitment. It records no target/provider cooldown for these request-local mismatches and does not silently drop reasoning controls or raise `none` to a supported rung. Cancellation, origin/cyber-policy rejection, non-replayable post-send errors and the existing streaming commit boundary stay authoritative. Apart from the definite context overflow below, other invalid requests remain terminal. -Regression coverage: `tests/responses/responses-forward-prompt-envelope.test.ts`, `tests/routing/router-combo-failover-classification.test.ts`, and `tests/server/server-combo-failover-e2e.test.ts`. +A definite context-window overflow is the fourth request-local verdict. A heterogeneous combo mixes windows, so "this turn does not fit THIS model" is not "this turn is impossible", and stopping at the first undersized target burned the ladder on turns a later target could hold. Evidence must come from the innermost provider message: `classifyError` remaps any occurrence of `context window`, `context length`, `maximum context` or `too many tokens` anywhere in the blob, and inheriting that looseness would let a `context_length_exceeded` token sitting in a `code` field beside `Unsupported parameter: user` authorize a replay. `src/combos/failover.ts` therefore unwraps only the exact proxy wrapper, within four envelopes and 16,384 characters, and reads the leaf message. A JSON-shaped body that does not parse fails closed, because `normalizeUpstreamErrorText` caps `classificationText` at 500 characters and a long envelope arrives here as a prefix. The verdict is admitted only for statuses that speak about the request — 400, 413, 422 and 5xx — so a 401/403 body that merely quotes context prose keeps its provider-wide cooldown instead of being rescored as request-shaped. Structured `origin_rejected`, cyber policy and the non-replayable post-send codes are all tested before it. + +This is also why the classifier cannot duplicate visible output. A streaming child reaches combo classification only through `preflightComboStreamResponse`, which commits the child on any text, tool call or unknown event and synthesizes a failure envelope only for a zero-output terminal, so a turn whose text or tool call the client already saw is never reclassified as a hop. + +Regression coverage: `tests/responses/responses-forward-prompt-envelope.test.ts`, `tests/routing/router-combo-failover-classification.test.ts`, `tests/routing/routing-policy-fallback.test.ts`, `tests/helpers/combo-context-overflow-cases.ts`, and `tests/server/server-combo-failover-e2e.test.ts`. ## Combo default effort precedence diff --git a/tests/codex-integration/combos.test.ts b/tests/codex-integration/combos.test.ts index 2f7efb91d0f..b2fc6d3876f 100644 --- a/tests/codex-integration/combos.test.ts +++ b/tests/codex-integration/combos.test.ts @@ -900,7 +900,7 @@ describe("combo failure policy and advancement", () => { for (const status of [401, 403, 404, 408, 429, 500, 503]) { expect(comboFailureDecision(status, "provider failure")).toBe("hop"); } - expect(comboFailureDecision(400, "context_length_exceeded")).toBe("stop"); + expect(comboFailureDecision(400, "context_length_exceeded")).toBe("hop"); expect(comboFailureDecision(403, '{"code":"origin_rejected"}')).toBe("stop"); expect(comboFailureDecision(413, "request too large")).toBe("stop"); expect(comboFailureDecision(409, "conflict")).toBe("stop"); @@ -919,9 +919,14 @@ describe("combo failure policy and advancement", () => { // verdict by echoing the token, so that shape must NOT hop. expect(comboFailureDecision(413, 'refused', { code: 'input_admission_refused' })).toBe('hop'); expect(comboFailureDecision(400, 'upstream mentions input_admission_refused in prose')).toBe('stop'); - // An UPSTREAM context verdict still stops: retrying that elsewhere is guesswork, and a - // generic 413 with no structured code keeps its existing conservative handling. - expect(comboFailureDecision(400, "context_length_exceeded")).toBe("stop"); + // An UPSTREAM context verdict is target-local in a heterogeneous combo: this model cannot + // hold the turn, but a later one may have a larger window. The whole message being the bare + // token is unambiguous evidence; a generic 413 with no context signal stays conservative. + expect(comboFailureDecision(400, "context_length_exceeded")).toBe("hop"); + // Evidence has to come from the MESSAGE. A context code beside an unrelated refusal is a + // contradictory envelope, and a hard structured refusal outranks the context verdict. + expect(comboFailureDecision(400, "ordinary invalid request", { code: "context_length_exceeded" })).toBe("stop"); + expect(comboFailureDecision(502, "context window exceeded", { code: "origin_rejected" })).toBe("stop"); const providerHardCap = JSON.stringify({ error: { message: "Prompt 346030 > 262144 maximum context length", type: "invalid_request_prompt_too_long", diff --git a/tests/helpers/combo-context-overflow-cases.ts b/tests/helpers/combo-context-overflow-cases.ts new file mode 100644 index 00000000000..c490717dfe3 --- /dev/null +++ b/tests/helpers/combo-context-overflow-cases.ts @@ -0,0 +1,123 @@ +import { expect, test } from "bun:test"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; + +interface ComboHarness { + serve(handler: () => Response | Promise): Server; + baseUrl(server: Server): string; + chatSuccess(text: string, model?: string): Response; + chatStream(text: string): Response; + provider(adapter: string, url: string, apiKey: string, extra?: Partial): OcxProviderConfig; + comboConfig(providers: OcxConfig["providers"]): OcxConfig; + post(config: OcxConfig, raw?: Record): Promise; + collectSse(response: Response): Promise; +} + +const OVERFLOW_PROSE = + "Your input exceeds the context window of this model. Please adjust your input and try again."; + +function sse(frames: Array<[string, Record]>): Response { + const body = frames + .map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) + .join(""); + return new Response(body, { headers: { "content-type": "text/event-stream" } }); +} + +/** Register under the caller's isolated homes, mock state and server cleanup hooks. */ +export function registerComboContextOverflowCases({ + serve, baseUrl, chatSuccess, chatStream, provider, comboConfig, post, collectSse, +}: ComboHarness): void { + test("context overflow advances while exhausted retryable targets return the sanitized last status", async () => { + let backupHits = 0; + const context = serve(() => Response.json( + { error: { code: "context_length_exceeded", message: "too many tokens" } }, + { status: 400 }, + )); + const larger = serve(() => { + backupHits += 1; + return chatSuccess("larger context fallback"); + }); + const advanced = await post(comboConfig({ + a: provider("openai-chat", baseUrl(context), "key-a"), + b: provider("openai-chat", baseUrl(larger), "key-b"), + })); + expect(advanced.status).toBe(200); + expect(backupHits).toBe(1); + expect(await advanced.text()).toContain("larger context fallback"); + + const order: string[] = []; + const first = serve(() => { + order.push("a"); + return new Response("secret sk-a-should-redact", { status: 503 }); + }); + const last = serve(() => { + order.push("b"); + return Response.json({ error: { message: "missing model" } }, { status: 404 }); + }); + const exhausted = await post(comboConfig({ + a: provider("openai-chat", baseUrl(first), "key-a"), + b: provider("openai-chat", baseUrl(last), "key-b"), + })); + expect(exhausted.status).toBe(404); + expect(order).toEqual(["a", "b"]); + expect(await exhausted.text()).not.toContain("sk-a-should-redact"); + }); + + test("zero-output context overflow 502 hops to a healthy combo target", async () => { + let backupHits = 0; + const capped = serve(() => sse([ + ["response.created", { type: "response.created", response: { id: "resp_context", status: "in_progress" } }], + ["response.failed", { type: "response.failed", response: { + id: "resp_context", + status: "failed", + error: { type: "server_error", code: "upstream_server_error", message: OVERFLOW_PROSE }, + } }], + ])); + const backup = serve(() => { + backupHits += 1; + return chatStream("larger context backup"); + }); + const response = await post(comboConfig({ + a: provider("openai-responses", baseUrl(capped), "key-a"), + b: provider("openai-chat", baseUrl(backup), "key-b"), + }), { stream: true }); + expect(response.status).toBe(200); + expect(backupHits).toBe(1); + expect(JSON.stringify(await collectSse(response))).toContain("larger context backup"); + }); + + test("context overflow after committed output never replays on another target", async () => { + // The boundary the hop verdict depends on. Once any text or tool call has reached the + // client, the stream preflight commits the child and the failure never becomes a combo + // classification at all -- so the same context prose that hops above must not hop here. + let backupHits = 0; + const committed = serve(() => sse([ + ["response.created", { type: "response.created", response: { id: "resp_committed", status: "in_progress" } }], + ["response.output_item.added", { type: "response.output_item.added", output_index: 0, item: { + id: "msg_committed", type: "message", role: "assistant", status: "in_progress", content: [], + } }], + ["response.output_text.delta", { + type: "response.output_text.delta", item_id: "msg_committed", output_index: 0, content_index: 0, + delta: "already visible", + }], + ["response.failed", { type: "response.failed", response: { + id: "resp_committed", + status: "failed", + error: { type: "server_error", code: "upstream_server_error", message: OVERFLOW_PROSE }, + } }], + ])); + const backup = serve(() => { + backupHits += 1; + return chatStream("must not run"); + }); + const response = await post(comboConfig({ + a: provider("openai-responses", baseUrl(committed), "key-a"), + b: provider("openai-chat", baseUrl(backup), "key-b"), + }), { stream: true }); + expect(response.status).toBe(200); + const frames = JSON.stringify(await collectSse(response)); + expect(backupHits).toBe(0); + expect(frames).toContain("already visible"); + expect(frames).not.toContain("must not run"); + }); +} + diff --git a/tests/routing/router-combo-failover-classification.test.ts b/tests/routing/router-combo-failover-classification.test.ts index cb5d765459c..46616ca5ac7 100644 --- a/tests/routing/router-combo-failover-classification.test.ts +++ b/tests/routing/router-combo-failover-classification.test.ts @@ -62,6 +62,13 @@ describe("combo failure cooldown scope", () => { } // Hyphenated spellings normalize to the same codes. expect(comboFailureCooldownScope(400, "refused", { code: "input-admission-refused" })).toBe("none"); + // A native transport reports a zero-output model overflow as a generic upstream error with + // precise context prose. That target is healthy; only the turn was too large for it. + expect(comboFailureCooldownScope(502, + "Your input exceeds the context window of this model. Please adjust your input and try again.", + { code: "upstream_server_error" })).toBe("none"); + // A credential verdict keeps its provider scope even when the body quotes context prose. + expect(comboFailureCooldownScope(401, "invalid key for the 200k context window tier")).toBe("provider"); // A provider's own per-target hard cap (vendor code 5059) is equally request-shaped. expect(comboFailureCooldownScope( 400, @@ -256,6 +263,53 @@ describe("request-local optional control incompatibility", () => { }); }); +describe("definite upstream context overflow", () => { + const prose = "Your input exceeds the context window of this model. Please adjust your input and try again."; + const failedTerminal = (message: string) => JSON.stringify({ + error: { type: "server_error", code: "upstream_server_error", message }, + response: { error: { type: "server_error", code: "upstream_server_error", message } }, + }); + + test("a zero-output context overflow is target-local and may hop", () => { + // The combo stream preflight only synthesizes this envelope for a terminal that committed + // no output, so the hop can never duplicate text the client already saw. + expect(comboFailureDecision(502, failedTerminal(prose), { code: "upstream_server_error" })).toBe("hop"); + expect(comboFailureDecision(400, "context length exceeded", { code: "context_length_exceeded" })).toBe("hop"); + expect(comboFailureDecision(400, `Provider error 400: ${prose}`)).toBe("hop"); + }); + + test("evidence must come from the innermost message, not a stray code token", () => { + const unrelated = JSON.stringify({ error: { ...unsupportedUser, code: "context_length_exceeded" } }); + expect(comboFailureDecision(400, unrelated)).toBe("stop"); + expect(comboFailureDecision(400, "ordinary invalid request", { code: "context_length_exceeded" })).toBe("stop"); + // Reflected prose inside an unrelated body is not the provider's own verdict. + expect(comboFailureDecision(400, JSON.stringify({ error: { ...unsupportedUser, param: "tools", note: prose } }))) + .toBe("stop"); + }); + + test("truncated envelopes and hard refusals do not acquire hop permission", () => { + // classificationText is capped at 500 characters upstream, so a long envelope reaches the + // classifier as a JSON prefix. Reading that prefix as prose would let any field authorize + // a replay, so a JSON-shaped body that does not parse fails closed. + expect(comboFailureDecision(400, failedTerminal(prose).slice(0, -1))).toBe("stop"); + expect(comboFailureDecision(502, prose, { code: "origin_rejected" })).toBe("stop"); + expect(comboFailureDecision(502, prose, { code: "upstream_no_response" })).toBe("stop"); + expect(comboFailureDecision(499, prose)).toBe("stop"); + // A status that speaks about the CREDENTIAL keeps its own verdict and its provider-wide + // cooldown, even when the body quotes context prose. Without that gate this envelope would + // be reclassified as request-shaped and a rejected key would stop cooling its provider. + expect(comboFailureDecision(403, prose)).toBe("stop"); + expect(comboFailureCooldownScope(403, prose)).toBe("provider"); + }); + + test("the envelope budget is bounded and oversized bodies stay terminal", () => { + const wrap = (inner: string) => JSON.stringify({ error: { type: "server_error", message: inner } }); + expect(comboFailureDecision(400, wrap(wrap(wrap(prose))))).toBe("hop"); + expect(comboFailureDecision(400, wrap(wrap(wrap(wrap(wrap(prose))))))).toBe("stop"); + expect(comboFailureDecision(400, `${prose} ${"x".repeat(16_384)}`)).toBe("stop"); + }); +}); + describe("bounded optional-control error envelopes", () => { const wrapped = (message: string, code = "invalid_request_error") => JSON.stringify({ error: { type: "invalid_request_error", code, message: `Provider error 400: ${message}` }, diff --git a/tests/routing/routing-policy-fallback.test.ts b/tests/routing/routing-policy-fallback.test.ts index 1883c1277d8..bb52481b3f6 100644 --- a/tests/routing/routing-policy-fallback.test.ts +++ b/tests/routing/routing-policy-fallback.test.ts @@ -142,9 +142,10 @@ describe("policy candidate fallback", () => { expect(response.status).toBe(400); expect(seenModels).toEqual(["policy/daily"]); }); - test("an upstream context_length_exceeded still stops the chain (#1524)", async () => { - // The mirror-image contract. An upstream verdict is about the REQUEST, so retrying it - // elsewhere is guesswork -- and hopping would burn every candidate on a doomed request. + test("an upstream context_length_exceeded advances to the next policy candidate", async () => { + // A context verdict is about THIS model's window, not about the request in the abstract: + // the next candidate may be able to hold the same turn. Traversal stays finite because + // `tried` admits each candidate once, and nothing has been sent to the client yet. const trace = policyTrace(); const logCtx = { requestedModel: "policy/daily", routeDecision: trace, attempts: [] } as unknown as RequestLogContext; const seenModels: string[] = []; @@ -153,16 +154,19 @@ describe("policy candidate fallback", () => { seenModels.push(String(body.model)); ctx.routeDecision = trace; seedAttempt(ctx, "provider", String(body.model)); - return Response.json( - { error: { message: "context length exceeded", type: "invalid_request_error", code: "context_length_exceeded" } }, - { status: 400 }, - ); + if (seenModels.length === 1) { + return Response.json( + { error: { message: "context length exceeded", type: "invalid_request_error", code: "context_length_exceeded" } }, + { status: 400 }, + ); + } + return Response.json({ id: "resp", object: "response", status: "completed", output: [] }); }; const response = await handleResponsesWithPolicyFallback(request(), {} as OcxConfig, logCtx, {}, { runCore }); - expect(response.status).toBe(400); - expect(seenModels).toEqual(["policy/daily"]); + expect(response.status).toBe(200); + expect(seenModels).toEqual(["policy/daily", "provider-b/model-b"]); }); test("retries the next policy candidate and keeps distinct physical attempts", async () => { const trace = policyTrace(); diff --git a/tests/server/server-combo-failover-e2e.test.ts b/tests/server/server-combo-failover-e2e.test.ts index bea04074a41..78d288f0699 100644 --- a/tests/server/server-combo-failover-e2e.test.ts +++ b/tests/server/server-combo-failover-e2e.test.ts @@ -1,4 +1,5 @@ import { registerComboForcedEffortCases } from "../helpers/combo-forced-effort-cases"; +import { registerComboContextOverflowCases } from "../helpers/combo-context-overflow-cases"; import { sessionLaneIdFromRequest } from "../../src/server/request-log-conversation"; import { afterEach, beforeEach, describe, expect, mock, setDefaultTimeout, test } from "bun:test"; import { logsFromApiBody } from "../helpers/logs-api"; @@ -2086,37 +2087,8 @@ describe("server combo failover 030 activation matrix", () => { expect(primaryHits.every(hit => hit.webTool === valid)).toBe(true); }); - test("context 400 stops while exhausted retryable targets return the sanitized last status", async () => { - let stopBackupHits = 0; - const context = serve(() => Response.json({ error: { code: "context_length_exceeded", message: "too many tokens" } }, { status: 400 })); - const unused = serve(() => { - stopBackupHits += 1; - return chatSuccess("must not run"); - }); - const stopConfig = comboConfig({ - a: provider("openai-chat", baseUrl(context), "key-a"), - b: provider("openai-chat", baseUrl(unused), "key-b"), - }); - const stopped = await post(stopConfig); - expect(stopped.status).toBe(400); - expect(stopBackupHits).toBe(0); - - const order: string[] = []; - const first = serve(() => { - order.push("a"); - return new Response("secret sk-a-should-redact", { status: 503 }); - }); - const last = serve(() => { - order.push("b"); - return Response.json({ error: { message: "missing model" } }, { status: 404 }); - }); - const exhausted = await post(comboConfig({ - a: provider("openai-chat", baseUrl(first), "key-a"), - b: provider("openai-chat", baseUrl(last), "key-b"), - })); - expect(exhausted.status).toBe(404); - expect(order).toEqual(["a", "b"]); - expect(await exhausted.text()).not.toContain("sk-a-should-redact"); + registerComboContextOverflowCases({ + serve, baseUrl, chatSuccess, chatStream, provider, comboConfig, post, collectSse, }); test("provider-specific prompt-too-long 400 hops to a larger-context combo target", async () => { From af708f462801a2428c05be6e71f054b711ef9728 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 16 Sep 2026 11:09:46 +0900 Subject: [PATCH 2/6] test(combos): pin the upstream context_length_exceeded terminal shape [skip ci] Upstream Codex classifies a streamed context overflow on one exact token: `is_context_window_error` matches `error.code == "context_length_exceeded"` on a `response.failed` event, and its own fixture pairs that code with the message the other assertions here already use. The proxy relays the nested terminal error verbatim, so the same overflow reaches the classifier either with that structured code or, when a transport rewrites the envelope, as a generic upstream_server_error. Pin both to the same verdict so a future narrowing cannot quietly drop the shape the real upstream sends. Co-authored-by: RHODIZ IT --- tests/routing/router-combo-failover-classification.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/routing/router-combo-failover-classification.test.ts b/tests/routing/router-combo-failover-classification.test.ts index 46616ca5ac7..0704dc30f22 100644 --- a/tests/routing/router-combo-failover-classification.test.ts +++ b/tests/routing/router-combo-failover-classification.test.ts @@ -274,6 +274,10 @@ describe("definite upstream context overflow", () => { // The combo stream preflight only synthesizes this envelope for a terminal that committed // no output, so the hop can never duplicate text the client already saw. expect(comboFailureDecision(502, failedTerminal(prose), { code: "upstream_server_error" })).toBe("hop"); + // The shape upstream Codex actually emits: a `response.failed` whose error carries the exact + // `context_length_exceeded` code alongside this message. The proxy relays the nested error + // verbatim, so both the structured and the generic-wrapper form must reach the same verdict. + expect(comboFailureDecision(502, failedTerminal(prose), { code: "context_length_exceeded" })).toBe("hop"); expect(comboFailureDecision(400, "context length exceeded", { code: "context_length_exceeded" })).toBe("hop"); expect(comboFailureDecision(400, `Provider error 400: ${prose}`)).toBe("hop"); }); From bc648a2f848ec9d6a55e5f8b16d5d3e36b1e0b35 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 16 Sep 2026 11:14:02 +0900 Subject: [PATCH 3/6] fix(combos): reserve output headroom before a combo fallback (#4664) [skip ci] A combo could route a large turn onto a fallback whose total context window cannot hold the input plus the output allowance the caller asked for. That target answers 200, emits a few hundred tokens and stops on finish_reason: length, which the Anthropic surface renders as "response exceeded the output token maximum" naming a limit the model never approached. Raising CLAUDE_CODE_MAX_OUTPUT_TOKENS only changes the number in that message. By the time it happens, output has committed and no later target may be tried. Admit a combo child against both budgets before dispatch. When the caller declared max_output_tokens, require estimated input <= input ceiling AND estimated input + min(declared output, target output ceiling) <= context window, and refuse locally with 413 input_admission_refused before any upstream bytes are sent. Combo policy already treats that local code as a safe hop, so the ladder selects a larger-context target without replaying committed output. The two budgets are checked separately on purpose. resolveInputCeiling already answers "how much input may this target take", and modelMaxInputTokens can tighten it below the window; charging the output reserve against that tightened number would count the reserve twice and skip a target that fits. The window is what input and output actually share, so the reserve belongs there. Reserving min(declared, target ceiling) rather than a fixed slice is what makes this catch the reported case: the common industry reservation of min(max_output, 20k) leaves 100k + 20k inside a 128k window, so the turn is admitted and fails upstream anyway. Canonical native slugs that the narrower pinned table does not carry now resolve their window from the generated in-tree bundle. That table gap is why the gate was completely inert on the route where this was observed. The bundle is compiled in, not a catalog read, so this adds no I/O, and explicit provider and operator caps may only narrow the result. It deliberately covers slugs retired from the picker, because a retired slug is still dispatchable when an operator names it explicitly in a combo target, which is exactly that configuration. Scope stays narrow. Direct and single-target requests keep the deliberately loose 2.5x pathological-input gate, because they have nowhere to hop. Compaction turns stay exempt. Unknown context and a caller that declared no output allowance both remain fail-open, so no limits are invented for custom providers. Closes #4664 Co-authored-by: RHODIZ IT --- src/server/responses/input-admission.ts | 122 +++++++++++++++++- src/server/responses/request-prepare.ts | 19 ++- structure/transports/responses.md | 27 ++++ tests/helpers/combo-context-headroom-cases.ts | 91 +++++++++++++ tests/server/input-admission.test.ts | 74 +++++++++++ .../server/server-combo-failover-e2e.test.ts | 22 +--- 6 files changed, 324 insertions(+), 31 deletions(-) create mode 100644 tests/helpers/combo-context-headroom-cases.ts diff --git a/src/server/responses/input-admission.ts b/src/server/responses/input-admission.ts index ef8b81265e3..d3baa2124df 100644 --- a/src/server/responses/input-admission.ts +++ b/src/server/responses/input-admission.ts @@ -10,7 +10,13 @@ * catches the pathological case and stays out of the way otherwise. Every uncertainty * resolves toward admitting. */ -import { nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, type NativeContextLimitsInput } from "../../codex/catalog/metadata"; +import { + nativeOpenAiContextWindow, + nativeOpenAiMaxInputTokens, + nativeOpenAiMaxOutputTokens, + type NativeContextLimitsInput, +} from "../../codex/catalog/metadata"; +import { getModelMetadata } from "../../generated/model-metadata"; import { estimateTokens } from "../../lib/token-estimate"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { modelRecordValue } from "../../reasoning-effort"; @@ -54,6 +60,8 @@ export interface InputAdmissionResult { estimatedTokens: number; /** Resolved ceiling, or null when nothing could be resolved (=> always admitted). */ ceiling: number | null; + /** Output space reserved by the combo preflight; absent on the loose direct gate. */ + requiredOutputHeadroom?: number; } function positive(value: unknown): number | null { @@ -135,14 +143,19 @@ export function estimateInputTokens(parsed: OcxParsedRequest, modelId: string): * reject a user-defined provider that merely shares a built-in name using limits that * belong to a different service. */ -export function resolveInputCeiling( +interface ResolvedContextLimits { + /** The target's total context window: input and output share it. */ + window: number | null; + /** Largest admissible input, which input-only caps may tighten below the window. */ + ceiling: number | null; +} + +function resolveContextLimits( provider: OcxProviderConfig, providerName: string, modelId: string, - // Operator cap for the canonical native provider. Passed in rather than read from a - // config here so this stays pure: no filesystem, no catalog, no registry scan. nativeContextCap?: NativeContextLimitsInput, -): number | null { +): ResolvedContextLimits { // `modelRecordValue`, not a bare lookup: the catalog resolves these same two maps that // way, so a `gpt-oss` entry covers `gpt-oss:120b`. Reading raw here made the gate fall // back to the provider-wide window and refuse turns the model can plainly hold. @@ -168,13 +181,110 @@ export function resolveInputCeiling( : null; const nativeMaxInput = canonicalNativeBare ? positive(nativeOpenAiMaxInputTokens(modelId, nativeLimits)) : null; - const window = canonicalNativeBare ? native : configured; + const window = canonicalNativeBare ? (native ?? generatedNativeWindow(modelId, configured, nativeContextCap)) : configured; // modelMaxInputTokens is an input-only cap, so it can only tighten the window. const configuredMaxInput = positive(modelRecordValue(provider.modelMaxInputTokens, modelId)); const limits = [window, configuredMaxInput, nativeMaxInput].filter((v): v is number => v !== null); + return { window, ceiling: limits.length === 0 ? null : Math.min(...limits) }; +} + +/** + * Static in-tree metadata for a canonical native slug the narrower override and pinned-native + * tables do not carry. Falling through to null made input admission completely blind for + * exactly those models, which is how a 128k target accepted a turn it could not finish. + * + * This deliberately covers slugs that are no longer offered in the picker: a retired slug is + * still dispatchable when an operator names it explicitly in a combo target, and that is the + * configuration where the gate was inert. This is a generated bundle compiled into the binary, + * not a live catalog read, so it adds no I/O. Explicit provider and operator caps may only + * narrow the result, never widen it. + */ +function generatedNativeWindow( + modelId: string, + configured: number | null, + nativeContextCap: NativeContextLimitsInput | undefined, +): number | null { + const generated = positive(getModelMetadata(OPENAI_CODEX_PROVIDER_ID, modelId)?.contextWindow) + ?? positive(getModelMetadata("openai", modelId)?.contextWindow); + if (generated === null) return null; + const cap = typeof nativeContextCap === "number" + ? positive(nativeContextCap) + : positive(nativeContextCap?.cap); + return Math.min(generated, configured ?? generated, cap ?? generated); +} + +export function resolveInputCeiling( + provider: OcxProviderConfig, + providerName: string, + modelId: string, + // Operator cap for the canonical native provider. Passed in rather than read from a + // config here so this stays pure: no filesystem, no catalog, no registry scan. + nativeContextCap?: NativeContextLimitsInput, +): number | null { + return resolveContextLimits(provider, providerName, modelId, nativeContextCap).ceiling; +} + +/** + * Largest output the concrete target can emit. Used only to avoid reserving MORE than the + * target could ever produce when a client asks for a bigger allowance than the model has. + * Unknown stays unknown rather than inventing a capability. + */ +export function resolveOutputCeiling( + provider: OcxProviderConfig, + providerName: string, + modelId: string, +): number | null { + const configured = positive(modelRecordValue(provider.modelMaxOutputTokens, modelId)) + ?? positive(provider.defaultMaxOutputTokens); + const canonicalNativeBare = providerName === OPENAI_CODEX_PROVIDER_ID + && isCanonicalOpenAiForwardProvider(provider) + && !modelId.includes("/"); + const native = canonicalNativeBare ? positive(nativeOpenAiMaxOutputTokens(modelId)) : null; + const limits = [configured, native].filter((v): v is number => v !== null); return limits.length === 0 ? null : Math.min(...limits); } +/** + * Combo-only admission. A fallback must be able to satisfy the caller's declared output + * allowance inside its OWN context window. Otherwise it returns 200, emits a few hundred + * tokens, and terminates on `finish_reason: length` — which the Anthropic surface renders as + * "response exceeded the output token maximum" even though the real cause was the total + * window. By then the next target cannot be tried, because output has already committed. + * + * Two budgets are checked separately so the reserve is counted exactly once. `ceiling` is an + * input-only budget once `modelMaxInputTokens` tightens it below the window, so the output + * reserve belongs against `window`, not against `ceiling`. + * + * Direct and single-target requests keep the deliberately loose 2.5x pathological-input gate. + * This stricter rule applies only to synthetic combo children, where skipping one known-small + * target is safe and the ladder continues before any upstream bytes are sent. Unknown context + * stays fail-open, and a caller that declared no output allowance is unaffected. + */ +export function checkComboTargetInputAdmission( + parsed: OcxParsedRequest, + provider: OcxProviderConfig, + providerName: string, + modelId: string, + nativeContextCap?: NativeContextLimitsInput, +): InputAdmissionResult { + const { window, ceiling } = resolveContextLimits(provider, providerName, modelId, nativeContextCap); + const requestedOutput = positive(parsed.options.maxOutputTokens); + if (window === null || ceiling === null || requestedOutput === null) { + return checkInputAdmission(parsed, provider, providerName, modelId, nativeContextCap); + } + const targetOutput = resolveOutputCeiling(provider, providerName, modelId); + const requiredOutputHeadroom = targetOutput === null + ? requestedOutput + : Math.min(requestedOutput, targetOutput); + const estimatedTokens = estimateInputTokens(parsed, modelId); + return { + admitted: estimatedTokens <= ceiling && estimatedTokens + requiredOutputHeadroom <= window, + estimatedTokens, + ceiling, + requiredOutputHeadroom, + }; +} + /** * Fail-open when no ceiling is known; refuse only past `ceiling * ADMISSION_TOLERANCE`. * diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 81ffeb013f8..b655d8f16dd 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -101,7 +101,7 @@ import { isCodexReserveHelperUnsupported, CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, } from "../../codex/loopback-target"; -import { checkInputAdmission } from "./input-admission"; +import { checkComboTargetInputAdmission, checkInputAdmission } from "./input-admission"; import { nativeContextLimits } from "../../codex/catalog"; import { streamingContextOverflowResponse } from "./context-overflow"; import { @@ -860,7 +860,12 @@ export async function prepareResponsesRequest( // refusing the turn that shrinks the context would deadlock the client against the very // limit this gate reports — it would be told to compact and then denied the compaction. if (parsed._compactionRequest !== true) { - const inputAdmission = checkInputAdmission(parsed, route.provider, route.providerName, parsed.modelId, nativeContextLimits(config)); + // A combo child is the one caller that can afford a strict gate: skipping a target it + // cannot fit is safe before any upstream bytes are sent, and the ladder continues. A + // direct request has nowhere to go, so it keeps the loose pathological-input gate. + const inputAdmission = options.comboAttempt + ? checkComboTargetInputAdmission(parsed, route.provider, route.providerName, parsed.modelId, nativeContextLimits(config)) + : checkInputAdmission(parsed, route.provider, route.providerName, parsed.modelId, nativeContextLimits(config)); if (!inputAdmission.admitted) { // #1524: this is a LOCAL preflight refusal, not an upstream verdict. A policy or combo // fallback must be able to skip this candidate and try one whose context window fits, @@ -876,9 +881,13 @@ export async function prepareResponsesRequest( return formatErrorResponse( 413, "input_admission_refused", - `Estimated input (~${inputAdmission.estimatedTokens} tokens) is far past the context window ` - + `of ${parsed.modelId} (${inputAdmission.ceiling} tokens). Start a new session or choose a ` - + `model with a larger context window.`, + inputAdmission.requiredOutputHeadroom !== undefined + ? `Estimated input (~${inputAdmission.estimatedTokens} tokens) plus ${inputAdmission.requiredOutputHeadroom} ` + + `tokens of requested output headroom cannot fit the context window of ${parsed.modelId} ` + + `(${inputAdmission.ceiling} tokens).` + : `Estimated input (~${inputAdmission.estimatedTokens} tokens) is far past the context window ` + + `of ${parsed.modelId} (${inputAdmission.ceiling} tokens). Start a new session or choose a ` + + `model with a larger context window.`, ); } } diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 9af5e63100f..54ece4ec41d 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -702,3 +702,30 @@ What must not happen is a ladder that charges and then returns through a path th nor releases. That is not a lost send; it is a send the request never made, spending an allowance a later recovery in the same request then cannot have. `tests/lib/execution-budget-permits.test.ts` pins both ladder shapes against exactly that. + +## Combo output headroom + +A combo child is admitted against two budgets, not one. `resolveInputCeiling` in +`src/server/responses/input-admission.ts` answers "how much input may this target take", which +`modelMaxInputTokens` can tighten below the window. The context window itself is what input and +output actually share. When the caller declared `max_output_tokens`, +`checkComboTargetInputAdmission` requires both `estimated input <= ceiling` and +`estimated input + min(declared output, target output ceiling) <= window`, so the output reserve +is counted once rather than charged twice against an already-tightened input budget. + +The refusal is local: HTTP 413 `input_admission_refused` before any upstream bytes are sent, which +existing combo policy already treats as a safe hop. That ordering is the whole point. A target whose +total window cannot hold the turn plus the caller's allowance answers 200, emits a few hundred +tokens and stops on `finish_reason: length`, which the Anthropic surface renders as an output-token +error naming a limit the model never approached — and by then output has committed and no later +target may be tried. + +Scope is deliberately narrow. Direct and single-target requests keep the loose 2.5x +pathological-input gate, because they have nowhere to hop. Compaction turns stay exempt. Unknown +context and a caller that declared no output allowance both remain fail-open, so this invents no +limits for custom providers. Canonical native slugs that the narrower pinned table does not carry +resolve their window from the generated in-tree bundle, which is what made the gate inert on the +route where this was first observed; explicit provider and operator caps may only narrow it. + +Regression coverage: `tests/server/input-admission.test.ts` and +`tests/helpers/combo-context-headroom-cases.ts`. diff --git a/tests/helpers/combo-context-headroom-cases.ts b/tests/helpers/combo-context-headroom-cases.ts new file mode 100644 index 00000000000..2d8769bee70 --- /dev/null +++ b/tests/helpers/combo-context-headroom-cases.ts @@ -0,0 +1,91 @@ +import { expect, test } from "bun:test"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; + +interface ComboHarness { + serve(handler: () => Response | Promise): Server; + baseUrl(server: Server): string; + chatSuccess(text: string, model?: string): Response; + provider(adapter: string, url: string, apiKey: string, extra?: Partial): OcxProviderConfig; + comboConfig(providers: OcxConfig["providers"]): OcxConfig; + post(config: OcxConfig, raw?: Record): Promise; +} + +/** Roughly `tokens` worth of plain ASCII at the default 4 chars/token ratio. */ +function asciiTokens(tokens: number): string { + return "a".repeat(tokens * 4); +} + +/** Register under the caller's isolated homes, mock state and server cleanup hooks. */ +export function registerComboContextHeadroomCases({ + serve, baseUrl, chatSuccess, provider, comboConfig, post, +}: ComboHarness): void { + test("a target that cannot hold input plus requested output is skipped before any bytes commit", async () => { + let smallHits = 0; + let largeHits = 0; + const small = serve(() => { + smallHits += 1; + return chatSuccess("MUST NOT RUN", "m1"); + }); + const large = serve(() => { + largeHits += 1; + return chatSuccess("large context target", "m2"); + }); + // ~10k input, and m1 can reach 3,200 output inside a 12,800 window, so the turn cannot + // finish there. m2 holds the same turn with the caller's full 6,400 allowance. + const response = await post(comboConfig({ + a: provider("openai-chat", baseUrl(small), "key-a", { + modelContextWindows: { m1: 12_800 }, + modelMaxOutputTokens: { m1: 3_200 }, + }), + b: provider("openai-chat", baseUrl(large), "key-b", { + modelContextWindows: { m2: 100_000 }, + modelMaxOutputTokens: { m2: 32_000 }, + }), + }), { input: asciiTokens(10_000), max_output_tokens: 6_400 }); + expect(response.status).toBe(200); + expect(smallHits).toBe(0); + expect(largeHits).toBe(1); + expect(await response.text()).toContain("large context target"); + }); + + test("the same undersized target still serves a turn that declares no output allowance", async () => { + // The strict reserve is opt-in on the caller's declared allowance. Without one, the + // deliberately loose pathological-input gate still applies and nothing is skipped. + let smallHits = 0; + const small = serve(() => { + smallHits += 1; + return chatSuccess("small context target", "m1"); + }); + const response = await post(comboConfig({ + a: provider("openai-chat", baseUrl(small), "key-a", { + modelContextWindows: { m1: 12_800 }, + modelMaxOutputTokens: { m1: 3_200 }, + }), + }), { input: asciiTokens(10_000) }); + expect(response.status).toBe(200); + expect(smallHits).toBe(1); + expect(await response.text()).toContain("small context target"); + }); + + test("provider-specific prompt-too-long 400 hops to a larger-context combo target", async () => { + let backupHits = 0; + const capped = serve(() => Response.json({ error: { + message: "Prompt 346030 > 262144 maximum context length", + type: "invalid_request_prompt_too_long", + code: "5059", + raw_status_code: 400, + } }, { status: 400 })); + const backup = serve(() => { + backupHits += 1; + return chatSuccess("larger context backup", "m2"); + }); + const response = await post(comboConfig({ + a: provider("openai-chat", baseUrl(capped), "key-a"), + b: provider("openai-chat", baseUrl(backup), "key-b"), + })); + expect(response.status).toBe(200); + expect(backupHits).toBe(1); + expect(await response.text()).toContain("larger context backup"); + }); +} + diff --git a/tests/server/input-admission.test.ts b/tests/server/input-admission.test.ts index 9b8ac9c978d..e96f15e6b67 100644 --- a/tests/server/input-admission.test.ts +++ b/tests/server/input-admission.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from "bun:test"; import { ADMISSION_TOLERANCE, + checkComboTargetInputAdmission, checkInputAdmission, estimateInputTokens, resolveInputCeiling, + resolveOutputCeiling, } from "../../src/server/responses/input-admission"; import { modelRecordValue } from "../../src/reasoning-effort"; import type { OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTool } from "../../src/types"; @@ -257,3 +259,75 @@ describe("checkInputAdmission", () => { expect(calls).toBe(0); }); }); + +describe("combo target input admission", () => { + const capped: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + modelContextWindows: { m: 128_000 }, + modelMaxOutputTokens: { m: 32_000 }, + }; + + const withMaxOutput = (inputTokens: number, maxOutputTokens: number | undefined = 64_000): OcxParsedRequest => ({ + ...request([userText(asciiTokens(inputTokens))]), + modelId: "m", + options: maxOutputTokens === undefined ? {} : { maxOutputTokens }, + }); + + test("skips a target that cannot hold the turn plus its own output ceiling", () => { + // 100k input + 32k of reachable output does not fit 128k, so this target would have + // answered 200, emitted a few hundred tokens and stopped on finish_reason: length. + const result = checkComboTargetInputAdmission(withMaxOutput(100_000), capped, "custom", "m"); + expect(result.admitted).toBe(false); + expect(result.ceiling).toBe(128_000); + expect(result.requiredOutputHeadroom).toBe(32_000); + }); + + test("reserves no more than the target can actually emit", () => { + // The caller asked for 64k, but this model tops out at 32k, so reserving the caller's + // number would skip a target that fits. + const result = checkComboTargetInputAdmission(withMaxOutput(90_000), capped, "custom", "m"); + expect(result.admitted).toBe(true); + expect(result.requiredOutputHeadroom).toBe(32_000); + }); + + test("an input-only cap is not charged the output reserve twice", () => { + // modelMaxInputTokens tightens the admissible INPUT; the output reserve belongs against + // the window. Charging both against the tightened number would refuse a turn that fits. + const inputCapped: OcxProviderConfig = { ...capped, modelMaxInputTokens: { m: 90_000 } }; + const fits = checkComboTargetInputAdmission(withMaxOutput(85_000), inputCapped, "custom", "m"); + expect(fits.admitted).toBe(true); + expect(fits.ceiling).toBe(90_000); + // The input cap itself still refuses on its own terms. + expect(checkComboTargetInputAdmission(withMaxOutput(95_000), inputCapped, "custom", "m").admitted).toBe(false); + }); + + test("unknown context stays fail-open", () => { + const unknown: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://example.test/v1" }; + const result = checkComboTargetInputAdmission(withMaxOutput(2_000_000), unknown, "custom", "m"); + expect(result.admitted).toBe(true); + expect(result.ceiling).toBeNull(); + }); + + test("no declared output allowance keeps the loose direct contract", () => { + const result = checkComboTargetInputAdmission(withMaxOutput(150_000, undefined), capped, "custom", "m"); + expect(result.admitted).toBe(true); // still inside the existing 2.5x pathological gate + expect(result.requiredOutputHeadroom).toBeUndefined(); + }); + + test("a canonical native slug missing from the override table resolves from generated metadata", () => { + // Spark carries 128k/32k in the generated bundle but is absent from the narrower pinned + // native table, which left the gate completely blind on exactly this route. It is retired + // from the picker and still dispatchable when an operator names it in a combo target. + expect(resolveInputCeiling(CANONICAL_NATIVE, "openai", "gpt-5.3-codex-spark")).toBe(128_000); + expect(resolveOutputCeiling(CANONICAL_NATIVE, "openai", "gpt-5.3-codex-spark")).toBe(32_000); + // A slug the override table does know keeps its own pinned window. + expect(resolveInputCeiling(CANONICAL_NATIVE, "openai", "gpt-5.6-sol")).toBe(272_000); + // An operator cap may only narrow the generated value, never widen it. + expect(resolveInputCeiling(CANONICAL_NATIVE, "openai", "gpt-5.3-codex-spark", 64_000)).toBe(64_000); + // A provider merely named openai still inherits nothing. + const impostor: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://impostor.test/v1", authMode: "key" }; + expect(resolveInputCeiling(impostor, "openai", "gpt-5.3-codex-spark")).toBeNull(); + expect(resolveOutputCeiling(impostor, "openai", "gpt-5.3-codex-spark")).toBeNull(); + }); +}); diff --git a/tests/server/server-combo-failover-e2e.test.ts b/tests/server/server-combo-failover-e2e.test.ts index 78d288f0699..f636e3480f8 100644 --- a/tests/server/server-combo-failover-e2e.test.ts +++ b/tests/server/server-combo-failover-e2e.test.ts @@ -1,5 +1,6 @@ import { registerComboForcedEffortCases } from "../helpers/combo-forced-effort-cases"; import { registerComboContextOverflowCases } from "../helpers/combo-context-overflow-cases"; +import { registerComboContextHeadroomCases } from "../helpers/combo-context-headroom-cases"; import { sessionLaneIdFromRequest } from "../../src/server/request-log-conversation"; import { afterEach, beforeEach, describe, expect, mock, setDefaultTimeout, test } from "bun:test"; import { logsFromApiBody } from "../helpers/logs-api"; @@ -2091,26 +2092,7 @@ describe("server combo failover 030 activation matrix", () => { serve, baseUrl, chatSuccess, chatStream, provider, comboConfig, post, collectSse, }); - test("provider-specific prompt-too-long 400 hops to a larger-context combo target", async () => { - let backupHits = 0; - const capped = serve(() => Response.json({ error: { - message: "Prompt 346030 > 262144 maximum context length", - type: "invalid_request_prompt_too_long", - code: "5059", - raw_status_code: 400, - } }, { status: 400 })); - const backup = serve(() => { - backupHits += 1; - return chatSuccess("larger context backup", "m2"); - }); - const response = await post(comboConfig({ - a: provider("openai-chat", baseUrl(capped), "key-a"), - b: provider("openai-chat", baseUrl(backup), "key-b"), - })); - expect(response.status).toBe(200); - expect(backupHits).toBe(1); - expect(await response.text()).toContain("larger context backup"); - }); + registerComboContextHeadroomCases({ serve, baseUrl, chatSuccess, provider, comboConfig, post }); test("429 Retry-After 120 keeps A cooling at 60 seconds and restores it at 120", async () => { const t0 = Date.parse("2026-07-18T00:00:00.000Z"); From 861988ef91c18ba241b2802c7757e4fa6ca829ff Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 16 Sep 2026 11:28:59 +0900 Subject: [PATCH 4/6] fix(responses): read the native Codex catalog key for generated windows [skip ci] OPENAI_CODEX_PROVIDER_ID is the routing provider name, and its value is the string "openai". Using it to index the generated bundle therefore skipped the native Codex rows entirely and read the public API rows instead. The two agree on Spark's 128k window, so the case that motivated the fallback still resolved, but any slug where they differ would have taken the wrong window -- and gpt-5-codex-mini exists only in the native catalog, so it resolved nothing at all. Name the catalog keys explicitly and say in a comment why the provider id is not one of them. Co-authored-by: RHODIZ IT --- src/server/responses/input-admission.ts | 14 ++++++++++++-- tests/server/input-admission.test.ts | 4 ++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/server/responses/input-admission.ts b/src/server/responses/input-admission.ts index d3baa2124df..ca11fd60eb5 100644 --- a/src/server/responses/input-admission.ts +++ b/src/server/responses/input-admission.ts @@ -188,6 +188,13 @@ function resolveContextLimits( return { window, ceiling: limits.length === 0 ? null : Math.min(...limits) }; } +/** + * Generated-catalog keys, not routing provider names. `OPENAI_CODEX_PROVIDER_ID` is the string + * `"openai"` -- the canonical Codex forward route -- so using it to index the generated bundle + * would silently skip the native Codex rows and read the public API rows instead. + */ +const NATIVE_METADATA_CATALOGS = ["openai-codex", "openai"] as const; + /** * Static in-tree metadata for a canonical native slug the narrower override and pinned-native * tables do not carry. Falling through to null made input admission completely blind for @@ -204,8 +211,11 @@ function generatedNativeWindow( configured: number | null, nativeContextCap: NativeContextLimitsInput | undefined, ): number | null { - const generated = positive(getModelMetadata(OPENAI_CODEX_PROVIDER_ID, modelId)?.contextWindow) - ?? positive(getModelMetadata("openai", modelId)?.contextWindow); + let generated: number | null = null; + for (const catalog of NATIVE_METADATA_CATALOGS) { + generated = positive(getModelMetadata(catalog, modelId)?.contextWindow); + if (generated !== null) break; + } if (generated === null) return null; const cap = typeof nativeContextCap === "number" ? positive(nativeContextCap) diff --git a/tests/server/input-admission.test.ts b/tests/server/input-admission.test.ts index e96f15e6b67..6a15ffc22ee 100644 --- a/tests/server/input-admission.test.ts +++ b/tests/server/input-admission.test.ts @@ -321,6 +321,10 @@ describe("combo target input admission", () => { // from the picker and still dispatchable when an operator names it in a combo target. expect(resolveInputCeiling(CANONICAL_NATIVE, "openai", "gpt-5.3-codex-spark")).toBe(128_000); expect(resolveOutputCeiling(CANONICAL_NATIVE, "openai", "gpt-5.3-codex-spark")).toBe(32_000); + // The native Codex catalog is consulted first, and it is keyed "openai-codex" — which is NOT + // the routing provider id, because that one is the string "openai". `gpt-5-codex-mini` exists + // only in the native catalog, so resolving it proves the right key is being read. + expect(resolveInputCeiling(CANONICAL_NATIVE, "openai", "gpt-5-codex-mini")).toBe(272_000); // A slug the override table does know keeps its own pinned window. expect(resolveInputCeiling(CANONICAL_NATIVE, "openai", "gpt-5.6-sol")).toBe(272_000); // An operator cap may only narrow the generated value, never widen it. From 74a23c4cc72065ccc83373ee23875d48202e6cd0 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 16 Sep 2026 11:51:04 +0900 Subject: [PATCH 5/6] test(responses): stop an explicit undefined from taking the default allowance [skip ci] The no-declared-allowance row passed `undefined` as the second argument of a builder whose parameter has a default. A default parameter applies to an explicit `undefined`, so the row built a request carrying 64,000 max output tokens and then asserted that no output reserve was applied. It would have asserted the opposite of what it covers, and it would have done so by passing. Split the builder in two so the no-allowance case cannot silently acquire one. --- tests/server/input-admission.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/server/input-admission.test.ts b/tests/server/input-admission.test.ts index 6a15ffc22ee..90afdbe92b1 100644 --- a/tests/server/input-admission.test.ts +++ b/tests/server/input-admission.test.ts @@ -268,10 +268,17 @@ describe("combo target input admission", () => { modelMaxOutputTokens: { m: 32_000 }, }; - const withMaxOutput = (inputTokens: number, maxOutputTokens: number | undefined = 64_000): OcxParsedRequest => ({ + const withMaxOutput = (inputTokens: number, maxOutputTokens = 64_000): OcxParsedRequest => ({ ...request([userText(asciiTokens(inputTokens))]), modelId: "m", - options: maxOutputTokens === undefined ? {} : { maxOutputTokens }, + options: { maxOutputTokens }, + }); + // A separate builder, because passing `undefined` to the one above would silently take its + // default and the row below would assert the opposite of what it claims to cover. + const withoutMaxOutput = (inputTokens: number): OcxParsedRequest => ({ + ...request([userText(asciiTokens(inputTokens))]), + modelId: "m", + options: {}, }); test("skips a target that cannot hold the turn plus its own output ceiling", () => { @@ -310,7 +317,7 @@ describe("combo target input admission", () => { }); test("no declared output allowance keeps the loose direct contract", () => { - const result = checkComboTargetInputAdmission(withMaxOutput(150_000, undefined), capped, "custom", "m"); + const result = checkComboTargetInputAdmission(withoutMaxOutput(150_000), capped, "custom", "m"); expect(result.admitted).toBe(true); // still inside the existing 2.5x pathological gate expect(result.requiredOutputHeadroom).toBeUndefined(); }); From 246d703aec275b3c7267ebde9a004334614b905e Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 16 Sep 2026 11:17:42 +0900 Subject: [PATCH 6/6] fix(responses): bound combo recall model retention (#4525) The remembered model id is provider-reported and arrives on the response, so nothing upstream of the recall store bounds its length. Lane keys are already SHA-256 digests, which means the 256-lane cap bounded the number of entries but not the bytes those entries held. A long-running process could accumulate arbitrarily large remembered strings. Bound retention on two more axes: 1 KiB per remembered model id and 64 KiB in aggregate. The size test runs on code units before encoding, because a UTF-8 encoding is never smaller than its code-unit count, so the bound never pays the allocation it exists to prevent. Aggregate eviction drops the least recently written lane, which is the front of the map because every write re-inserts its own lane at the back. A single entry is capped far below the aggregate budget, so a write can never evict itself. Every removal now goes through one helper that releases the entry's bytes, so the counter cannot drift from the map through the read-time invalidation path, the reconciliation path, or a lane rewrite. An unretainable model id DECLINES the write rather than clearing the lane. That is the ordering-sensitive part. This callback carries a config generation, not a request order, so two accepted completions on one lane under the same generation can arrive out of order; a clearing branch would let the older one erase the newer selection. Declining matches how every other rejection in rememberComboForLane already returns, and leaves the established contract intact: an older response never overwrites or clears a newer one. Register the store for periodic expiry as well. The TTL was previously evaluated only on read or on a generation change, so a lane that is never read again held its entry until the process exited. Closes #4525 Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- docs-site/src/content/docs/guides/combos.md | 5 +- .../src/content/docs/ko/guides/combos.md | 2 +- src/lib/state-store-registrations.ts | 8 +- src/server/responses/combo-session-recall.ts | 76 +++++++++++++++++-- structure/transports/responses.md | 14 ++++ tests/oauth/state-store-sweeper.test.ts | 66 ++++++++++++++++ 6 files changed, 159 insertions(+), 12 deletions(-) diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 6f51870ad39..b3bfd8f65bb 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -83,7 +83,10 @@ The request then follows normal combo selection and failover. Explicit provider/combo selectors and configured combo aliases take precedence over this recall. Failed, incomplete, or cancelled responses do not replace the last successful selection. Recall is -process-local and bounded to 256 lanes for 30 minutes; it does not store account credentials. +process-local and bounded to 256 conversations for 30 minutes, and to 1 KiB per remembered model +name and 64 KiB in total; expired entries are also cleaned up in the background. A response whose +model name is too large to retain leaves the previous selection untouched rather than clearing it. +Recall does not store account credentials. Without usable conversation identity or valid remembered state, normal compaction routing applies. A restart clears the remembered state. diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md index b8d40874318..238127c1cee 100644 --- a/docs-site/src/content/docs/ko/guides/combos.md +++ b/docs-site/src/content/docs/ko/guides/combos.md @@ -66,7 +66,7 @@ alias는 클라이언트가 요청하는 공개 이름만 바꿉니다. 콤보 클라이언트가 콤보를 바꾼 뒤 공급자 접두사 없는 모델 이름으로 압축을 요청하면, opencodex는 같은 대화에서 가장 최근에 응답을 성공적으로 마친 콤보를 기억해 사용할 수 있습니다. 모델 이름이 완료된 응답과 일치하고, 현재 설정에 해당 콤보와 대상이 남아 있어야 합니다. 압축 요청도 일반 콤보 선택과 페일오버를 따릅니다. -명시한 공급자·콤보 선택자와 설정된 콤보 별칭이 기억한 값보다 우선합니다. 실패·미완료·취소된 응답은 마지막 성공 기록을 덮어쓰지 않습니다. 기록은 프로세스 안에서 최대 256개 대화, 30분 동안 유지하며 계정 자격증명을 저장하지 않습니다. 유효한 대화 식별자나 기록이 없으면 일반 압축 라우팅을 사용합니다. 재시작하면 기록은 사라집니다. +명시한 공급자·콤보 선택자와 설정된 콤보 별칭이 기억한 값보다 우선합니다. 실패·미완료·취소된 응답은 마지막 성공 기록을 덮어쓰지 않습니다. 기록은 프로세스 안에서 최대 256개 대화, 30분 동안 유지하고, 모델 이름 하나당 1 KiB·전체 64 KiB로 제한하며, 만료된 기록은 배경에서도 정리합니다. 모델 이름이 너무 커서 보관할 수 없는 응답은 이전 선택을 지우지 않고 그대로 둡니다. 기록은 계정 자격증명을 저장하지 않습니다. 유효한 대화 식별자나 기록이 없으면 일반 압축 라우팅을 사용합니다. 재시작하면 기록은 사라집니다. ## 전략 선택 diff --git a/src/lib/state-store-registrations.ts b/src/lib/state-store-registrations.ts index 13a22bfce08..849f145848c 100644 --- a/src/lib/state-store-registrations.ts +++ b/src/lib/state-store-registrations.ts @@ -21,7 +21,7 @@ import { } from "../combos/failover"; import { reconcileComboWarningMemos } from "../combos/request"; import { reconcileComboRotationState } from "../combos/resolve"; -import { reconcileComboRecall } from "../server/responses/combo-session-recall"; +import { reconcileComboRecall, sweepExpiredComboRecall } from "../server/responses/combo-session-recall"; import { listLiveComboTargetKeys } from "../combos/types"; import { listLiveConfigOwnershipRoots, @@ -112,7 +112,11 @@ export const STATE_STORE_REGISTRATIONS = [ { name: "model-cache-history", reconcileGeneration: reconcileModelCacheGeneration }, { name: "pool-rotation", reconcileGeneration: reconcilePoolRotationState }, { name: "combo-rotation", reconcileGeneration: reconcileComboRotationState }, - { name: "combo-session-recall", reconcileGeneration: reconcileComboRecall }, + { + name: "combo-session-recall", + sweepExpired: sweepExpiredComboRecall, + reconcileGeneration: reconcileComboRecall, + }, { name: "guardian-backoff", reconcileGeneration: reconcileGuardianBackoff }, { name: "codex-reauth", reconcileGeneration: reconcileCodexReauthState }, { name: "oauth-reauth", reconcileGeneration: reconcileOAuthReauthState }, diff --git a/src/server/responses/combo-session-recall.ts b/src/server/responses/combo-session-recall.ts index 84dfd8d4669..b3af31f1c44 100644 --- a/src/server/responses/combo-session-recall.ts +++ b/src/server/responses/combo-session-recall.ts @@ -8,14 +8,46 @@ interface ComboRecallEntry { target: Pick; responseModel: string; at: number; + /** UTF-8 size of `responseModel`, the only client-influenced field of unbounded length. */ + bytes: number; } const RECALL_CAPACITY = 256; const RECALL_TTL_MS = 30 * 60 * 1000; +/** + * A model id is provider-reported and arrives on the response, so nothing upstream of here + * bounds its length. Lane keys are already SHA-256 digests, so the model string is the only + * field that can grow, and 256 lanes alone do not bound the bytes they hold. + */ +const RECALL_MODEL_BYTES_MAX = 1024; +const RECALL_TOTAL_BYTES_MAX = 64 * 1024; const recall = new Map(); +let recallBytes = 0; let lastReconciledGeneration = 0; let liveOwners: Pick | undefined; +/** Every removal path goes through here so the byte counter can never drift from the map. */ +function deleteEntry(lane: string): boolean { + const entry = recall.get(lane); + if (!entry) return false; + recall.delete(lane); + recallBytes -= entry.bytes; + return true; +} + +/** + * UTF-8 size of a remembered model id, or null when it is too large to retain. + * + * The code-unit test runs first and is the part that matters: a UTF-8 encoding is never smaller + * than the code-unit count, so an oversized string is rejected without encoding it, and the + * bound cannot be defeated by paying the allocation it exists to prevent. + */ +function boundedModelBytes(responseModel: string): number | null { + if (responseModel.length > RECALL_MODEL_BYTES_MAX) return null; + const bytes = Buffer.byteLength(responseModel, "utf8"); + return bytes > RECALL_MODEL_BYTES_MAX ? null : bytes; +} + function ownsEntry(context: Pick, entry: ComboRecallEntry): boolean { return context.comboIds.has(entry.comboId) && context.providerNames.has(entry.target.provider) @@ -32,14 +64,29 @@ export function rememberComboForLane( if (!lane || !comboId || !responseModel.trim()) return; // Reject even a same-named recreated owner: its previous in-flight turn is obsolete. if (writerGeneration < Math.max(lastReconciledGeneration, captureConfigGeneration())) return; - const entry = { comboId, target: { provider: target.provider, model: target.model }, responseModel, at: Date.now() }; + // An unretainable model id DECLINES the write; it must not clear the lane. Every other + // rejection above returns the same way, and clearing here would let a late completion erase + // a newer selection that this function has no ordering information to compare against. + const bytes = boundedModelBytes(responseModel); + if (bytes === null) return; + const entry = { + comboId, + target: { provider: target.provider, model: target.model }, + responseModel, + at: Date.now(), + bytes, + }; if (liveOwners && !ownsEntry(liveOwners, entry)) return; - recall.delete(lane); + deleteEntry(lane); recall.set(lane, entry); - while (recall.size > RECALL_CAPACITY) { + recallBytes += bytes; + // Insertion order is recency order, because every write re-inserts its lane at the back. + // Evicting from the front therefore drops the least recently written lane, never this one: + // a single entry is capped well below the aggregate budget, so it always fits. + while (recall.size > RECALL_CAPACITY || recallBytes > RECALL_TOTAL_BYTES_MAX) { const oldest = recall.keys().next().value; - if (oldest === undefined) break; - recall.delete(oldest); + if (oldest === undefined || oldest === lane) break; + deleteEntry(oldest); } } @@ -57,12 +104,25 @@ export function recallComboForLane( || !Object.hasOwn(config.providers, entry.target.provider) || !provider || provider.disabled === true || !combo?.targets.some(target => targetKey(target) === targetKey(entry.target))) { - recall.delete(lane); + deleteEntry(lane); return undefined; } return entry.responseModel === model ? entry.comboId : undefined; } +/** + * Periodic expiry. Without it a lane that is never read again and never touched by a config + * reconciliation holds its entry for the life of the process: the existing TTL is only + * evaluated on read or on generation change. + */ +export function sweepExpiredComboRecall(now: number): number { + let removed = 0; + for (const [lane, entry] of recall) { + if (now - entry.at >= RECALL_TTL_MS && deleteEntry(lane)) removed += 1; + } + return removed; +} + export function reconcileComboRecall(context: GenerationContext): number { if (context.generation <= lastReconciledGeneration) return 0; lastReconciledGeneration = context.generation; @@ -74,8 +134,7 @@ export function reconcileComboRecall(context: GenerationContext): number { let removed = 0; for (const [lane, entry] of recall) { if (!ownsEntry(context, entry) || Date.now() - entry.at >= RECALL_TTL_MS) { - recall.delete(lane); - removed += 1; + if (deleteEntry(lane)) removed += 1; } } return removed; @@ -84,6 +143,7 @@ export function reconcileComboRecall(context: GenerationContext): number { /** Test-only reset, alongside the combo rotation/cooldown resets. */ export function clearComboRecallForTests(): void { recall.clear(); + recallBytes = 0; lastReconciledGeneration = 0; liveOwners = undefined; } diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 54ece4ec41d..98f518946e0 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -178,6 +178,20 @@ explicit configured selectors before consulting bounded lane state. The existing reconciliation owns removal of obsolete targets and generation fencing; core imports no registration composition root or Lab code. Recall retains routing identity only, never account credentials. +Retention is bounded on four axes: 256 lanes, 30 minutes, 1 KiB per remembered model id, and 64 KiB +in aggregate. The model id is the only field of unbounded length — lane keys are already SHA-256 +digests — so the lane cap alone does not bound the bytes those lanes hold. The size test runs on code +units before encoding, since a UTF-8 encoding is never smaller than its code-unit count and the bound +must not pay the allocation it exists to prevent. Aggregate eviction drops the least recently written +lane, which is the front of the map because every write re-inserts its own lane at the back. + +An unretainable model id declines the write rather than clearing the lane, matching how every other +rejection in `rememberComboForLane` returns. Clearing would let a late completion erase a newer +selection, and the publication path carries a config generation, not a request order, so it has no +basis on which to decide that its own result is the newer one. The store is also swept periodically +now: the TTL was previously evaluated only on read or on a generation change, so a lane never read +again held its entry for the life of the process. + > Decision record: [ADR-0038](../decisions/ADR-0038-responses-http-sse.md) A replayed compaction item carries an `encrypted_content` blob only its minting backend can decode, diff --git a/tests/oauth/state-store-sweeper.test.ts b/tests/oauth/state-store-sweeper.test.ts index 5b691d28d6d..4cfb17ee846 100644 --- a/tests/oauth/state-store-sweeper.test.ts +++ b/tests/oauth/state-store-sweeper.test.ts @@ -215,6 +215,72 @@ describe("state-store sweeper", () => { } }); + describe("bounded combo recall retention", () => { + const config: OcxConfig = { + port: 0, defaultProvider: "a", + providers: { a: { adapter: "openai-chat", baseUrl: "https://a.example/v1" } }, + combos: { first: { targets: [{ provider: "a", model: "m1" }] } }, + }; + const remember = (lane: string, responseModel: string) => + rememberComboForLane(lane, "first", { provider: "a", model: "m1" }, responseModel, captureConfigGeneration()); + /** A distinct model id of exactly 1 KiB, the largest this store will retain. */ + const fullModel = (index: number) => `${index}-`.padEnd(1024, "m"); + + test("an unretainable model id declines the write instead of clearing the lane", () => { + remember("lane", "kept-model"); + // A model id is provider-reported and arrives on the response, so its length is not + // bounded upstream of here. Refusing to retain it must not also destroy what is there: + // this callback carries a config generation, not a request order, so it cannot know its + // own result is newer than the entry it would be erasing. + remember("lane", "x".repeat(1025)); + expect(recallComboForLane(config, "lane", "kept-model")).toBe("first"); + + // Measured in UTF-8 bytes, not code units: 600 three-byte characters is 1,800 bytes. + remember("lane", "가".repeat(600)); + expect(recallComboForLane(config, "lane", "kept-model")).toBe("first"); + + // And an oversized id never establishes a lane of its own. + remember("fresh", "x".repeat(4096)); + expect(recallComboForLane(config, "fresh", "x".repeat(4096))).toBeUndefined(); + }); + + test("the aggregate byte budget evicts the least recently written lane", () => { + // 64 KiB holds exactly 64 maximum-size entries, well inside the 256-lane cap, so this + // isolates the byte budget from the lane count. + for (let i = 0; i < 64; i += 1) remember(`lane-${i}`, fullModel(i)); + expect(recallComboForLane(config, "lane-0", fullModel(0))).toBe("first"); + + remember("lane-64", fullModel(64)); + expect(recallComboForLane(config, "lane-0", fullModel(0))).toBeUndefined(); + expect(recallComboForLane(config, "lane-1", fullModel(1))).toBe("first"); + expect(recallComboForLane(config, "lane-64", fullModel(64))).toBe("first"); + }); + + test("a rewritten lane is charged once, not once per write", () => { + // Replacing a lane must release the old entry's bytes. If it did not, 64 rewrites of one + // lane would exhaust the whole budget and start evicting unrelated lanes. + remember("stable", "stable-model"); + for (let i = 0; i < 64; i += 1) remember("churn", fullModel(i)); + expect(recallComboForLane(config, "stable", "stable-model")).toBe("first"); + expect(recallComboForLane(config, "churn", fullModel(63))).toBe("first"); + }); + + test("a periodic tick expires a lane that is never read again and releases its bytes", () => { + registerStateStore(STATE_STORE_REGISTRATIONS.find(row => row.name === "combo-session-recall")!); + for (let i = 0; i < 64; i += 1) remember(`stale-${i}`, fullModel(i)); + + // Before this the TTL was only evaluated on read or on a generation change, so a lane + // nobody reads again held its entry for the life of the process. + expect(sweepExpired(Date.now() + 30 * 60 * 1_000)).toEqual({ storesVisited: 1, rowsRemoved: 64 }); + expect(recallComboForLane(config, "stale-0", fullModel(0))).toBeUndefined(); + + // The budget is genuinely free again: a full refill keeps its own oldest lane, which + // could not happen if the swept entries had left their bytes behind. + for (let i = 0; i < 64; i += 1) remember(`fresh-${i}`, fullModel(i)); + expect(recallComboForLane(config, "fresh-0", fullModel(0))).toBe("first"); + }); + }); + test("a sweeper tick expires continuation and Antigravity rows without store traffic", () => { rememberResponseState({ input: "old" }, { id: "resp_sweeper_ttl", output: [], status: "completed" }); observeAntigravityReplay("gemini-3-pro", "session-old", [{