From 493620254921f4e2b4173d64b2fa1974ba6f9354 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Wed, 9 Sep 2026 14:05:42 +0000 Subject: [PATCH 1/2] fix(responses): classify non-streaming provider input overflow --- .../content/docs/reference/proxy-formats.md | 7 +++ src/server/responses/context-overflow.ts | 11 ++++ src/server/responses/core.ts | 42 +++++++++------ structure/04_transports-and-sidecars.md | 12 +++-- .../responses-context-overflow.test.ts | 52 ++++++++++++++++--- 5 files changed, 98 insertions(+), 26 deletions(-) diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index c44f335a71..ed856803ad 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -81,6 +81,13 @@ With `stream: true`, the response is `text/event-stream`. The bridge emits Respo With `stream: false` or no `stream`, the same adapter events are collected into one Responses JSON object. Both forms preserve the selected model, output items, terminal status, and usage. +On the pending `dev` implementation for #4112, a final upstream HTTP 413 on this surface +is classified as `invalid_request_error` / `context_length_exceeded`. Non-streaming callers +retain HTTP 413 with a JSON `error`; streaming callers retain the terminal SSE failure. +Both use a fixed message instead of exposing the upstream error body. Routed synthetic +compaction propagates the classified failure; this does not shrink input or retry compaction. +Native compact passthrough and local admission-limit errors retain their separate contracts. + For native HTTP/SSE passthrough, a client cancellation without an observed upstream terminal is logged as `499` with `closeReason: "client_cancel"` and does not penalize the account pool. This applies to both tee inspection and eager relay, including Windows rewrite traffic, diff --git a/src/server/responses/context-overflow.ts b/src/server/responses/context-overflow.ts index 5b5e7f9fd3..3a61ceb326 100644 --- a/src/server/responses/context-overflow.ts +++ b/src/server/responses/context-overflow.ts @@ -5,6 +5,17 @@ import type { AdapterEvent } from "../../types"; export const PROVIDER_INPUT_TOO_LARGE_MESSAGE = "The provider rejected this turn because its input exceeds the provider size or context limit. Reduce the current input or compact the conversation before retrying."; +/** Preserve non-streaming HTTP failure semantics without exposing an upstream body. */ +export function jsonContextOverflowResponse(): Response { + return Response.json({ + error: { + message: PROVIDER_INPUT_TOO_LARGE_MESSAGE, + type: "invalid_request_error", + code: "context_length_exceeded", + }, + }, { status: 413, headers: { "Cache-Control": "no-store" } }); +} + async function* contextOverflowEvents(): AsyncGenerator { yield { type: "error", diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index c40ebaba09..bbae23157c 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -424,7 +424,7 @@ import { } from "../responses-undeclared-tool-guard"; import { createGithubCopilotResponsesBlockRewrite } from "../github-copilot-responses-repair"; import { responsesJsonToSseStream } from "../responses-json-events"; -import { streamingContextOverflowResponse } from "./context-overflow"; +import { jsonContextOverflowResponse, streamingContextOverflowResponse } from "./context-overflow"; import { guardTerminalEventStream } from "./terminal-guard"; import { emptyCompletionRetryEnabled, @@ -2986,6 +2986,11 @@ export async function handleComboResponses( const failureDecision = comboFailureDecision(failure.response.status, failure.classificationText, { code: failure.upstreamCode, }); + const wantsStream = (rawBody as { stream?: unknown } | null)?.stream === true; + // Local byte admission has its own diagnostic; do not relabel it as an upstream refusal. + const classifyOverflow = failure.response.status === 413 + && (wantsStream || (failure.upstreamCode !== "outbound_body_too_large" + && failure.upstreamCode !== "translation_buffer_limit")); if (storedPool401ReplayDispatched) { if (failureDecision === "hop" && unreadableEncryptedAgentTask && !comboPayloadReadable) { const recoveredTarget = await pickWithWait({ @@ -3010,15 +3015,19 @@ export async function handleComboResponses( // Keep the spent Pool budget sticky even after a recovered routed child: // no later failure may reopen ordinary combo/native account hopping. adoptFailedChildLog(childLog); + if (classifyOverflow && failureDecision === "stop") { + return wantsStream + ? streamingContextOverflowResponse(requestedModel, options.translatorBudget) + : jsonContextOverflowResponse(); + } return lastFailure; } if (failureDecision === "stop") { adoptFailedChildLog(childLog); - if ( - failure.response.status === 413 - && (rawBody as { stream?: unknown } | null)?.stream === true - ) { - return streamingContextOverflowResponse(requestedModel, options.translatorBudget); + if (classifyOverflow) { + return wantsStream + ? streamingContextOverflowResponse(requestedModel, options.translatorBudget) + : jsonContextOverflowResponse(); } return lastFailure; } @@ -5699,7 +5708,8 @@ async function handleResponsesInner( // Non-2xx passthrough failures must never reach Codex as an empty body — // Codex renders that as the opaque "Unknown error" (#452). Combo attempts - // keep their typed failure envelope. Non-empty bodies are relayed verbatim + // keep their typed failure envelope. Except for the classified 413 below, + // non-empty bodies are relayed verbatim // (headers included) so pool-retry Activation B/D and client diagnostics stay intact. // Manual-redirect policy (#914): a 3xx is relayed as-is (Location preserved // through sanitizePassthroughHeaders) so a redirect to a dead host can never @@ -5727,11 +5737,10 @@ async function handleResponsesInner( // The bounded reader owns the original body, deadline, abort settlement, and lock. // Unsafe partial data falls back to #452's non-empty status-only JSON. const errorText = await readDisplaySafeErrorText(upstreamResponse, upstream.signal, ""); - if (upstreamResponse.status === 413 && clientRequestedStream) { - return streamingContextOverflowResponse( - parsed._responseModelId ?? parsed.modelId, - translatorBudget, - ); + if (upstreamResponse.status === 413) { + return clientRequestedStream + ? streamingContextOverflowResponse(parsed._responseModelId ?? parsed.modelId, translatorBudget) + : jsonContextOverflowResponse(); } return formatPassthroughUpstreamError(upstreamResponse.status, errorText, { statusText: upstreamResponse.statusText, @@ -7463,11 +7472,10 @@ async function handleResponsesInner( } finally { cleanupUpstreamAbort(); } - if (upstreamResponse.status === 413 && clientRequestedStream && !options.comboAttempt) { - return streamingContextOverflowResponse( - parsed._responseModelId ?? parsed.modelId, - translatorBudget, - ); + if (upstreamResponse.status === 413) { + return clientRequestedStream + ? streamingContextOverflowResponse(parsed._responseModelId ?? parsed.modelId, translatorBudget) + : jsonContextOverflowResponse(); } if (!isFixedCodexAccount(authCtx)) { recordSubagentQuotaFailureForThreadSpawn( diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index b16fa96b5b..b55a0f2d84 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -845,7 +845,13 @@ recognizes that terminal contract, marks the context as full, and can run its ow on the next turn. Combo routing treats 413 as a stop condition and performs the conversion only at the outer client boundary, so the failed target is never recorded as a successful combo attempt. -Non-streaming callers retain the original 413 status/body contract. The proxy never silently drops +Non-streaming Responses callers retain HTTP 413 and receive a JSON `error` with +`type: invalid_request_error` and `code: context_length_exceeded`, including routed synthetic +compaction. The upstream body is replaced with the same bounded, proxy-owned message used by SSE. +Combo attempts retain their existing internal failure accounting; classification happens only at +the outer client boundary. Local admission and configured outbound-byte refusals keep their own +distinct codes. Classification does not shrink input or automatically retry compaction. +The proxy never silently drops prompts or images: it does not own the client's transcript, and deleting input would hide data that was never analyzed. The streaming error message is proxy-owned and bounded instead of relaying the upstream 413 body, which may echo request content. @@ -858,8 +864,8 @@ upstream 413 body, which may echo request content. Codex's persisted transcript safely. - 검토한 주요 대안: Relay 413 unchanged; return HTTP 400 JSON; silently remove media or old turns; synthesize a successful assistant warning. -- 선택한 방식: Preserve 413 for non-streaming clients, but map the final streaming 413 to one - redacted non-retryable Responses failure at the outer request boundary. +- 선택한 방식: Preserve HTTP 413 with typed JSON for non-streaming clients, and map the final + streaming 413 to one redacted non-retryable Responses failure at the outer request boundary. - 다른 대안 대신 이 방식을 선택한 이유: Raw 413 causes a retry loop, HTTP JSON does not enter Codex's context-window path, and silent deletion or fake success loses user intent without fixing transcript ownership. diff --git a/tests/responses/responses-context-overflow.test.ts b/tests/responses/responses-context-overflow.test.ts index d67800e04b..f580d27b69 100644 --- a/tests/responses/responses-context-overflow.test.ts +++ b/tests/responses/responses-context-overflow.test.ts @@ -117,16 +117,45 @@ describe("Responses provider input overflow", () => { } }); - test("non-streaming callers retain the upstream 413 status and body", async () => { + test.each(["openai-responses", "openai-chat", "anthropic"] as const)("non-streaming %s preserves HTTP 413 with a safe context classification", async adapter => { const upstream = upstream413(); - saveConfig(config({ target: provider("openai-responses", upstream) })); + saveConfig(config({ target: provider(adapter, upstream) })); const server = startServer(0); try { const response = await request(String(server.url), "target/kimi-k3", false); expect(response.status).toBe(413); + expect(response.headers.get("content-type")).toContain("application/json"); expect(await response.json()).toEqual({ - detail: "request body too large; echoed private request marker should-not-reach-client", + error: { + message: PROVIDER_INPUT_TOO_LARGE_MESSAGE, + type: "invalid_request_error", + code: "context_length_exceeded", + }, + }); + } finally { + await server.stop(true); + } + }); + + test.each(["openai-responses", "openai-chat"] as const)("routed %s compaction preserves the classified 413 without replay", async adapter => { + let hits = 0; + const upstream = upstream413(() => { hits += 1; }); + saveConfig(config({ target: provider(adapter, upstream) })); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/responses/compact", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "target/kimi-k3", input: [{ role: "user", content: "summarize this history" }] }), }); + expect(response.status).toBe(413); + expect(response.headers.get("content-type")).toContain("application/json"); + expect(await response.json()).toEqual({ error: { + message: PROVIDER_INPUT_TOO_LARGE_MESSAGE, + type: "invalid_request_error", + code: "context_length_exceeded", + } }); + expect(hits).toBe(1); } finally { await server.stop(true); } @@ -199,7 +228,7 @@ describe("Responses provider input overflow", () => { } }); - test("a combo stops on 413 and does not dispatch a second oversized target", async () => { + test.each([true, false])("a combo stops on 413 without dispatching a second target (stream=%s)", async stream => { let firstHits = 0; let secondHits = 0; const first = upstream413(() => { firstHits += 1; }); @@ -220,8 +249,19 @@ describe("Responses provider input overflow", () => { saveConfig(next); const server = startServer(0); try { - const failed = await responseFailed(await request(String(server.url), "combo/fallback", true)); - expect((failed.error as { code?: string }).code).toBe("context_length_exceeded"); + const response = await request(String(server.url), "combo/fallback", stream); + if (stream) { + const failed = await responseFailed(response); + expect((failed.error as { code?: string }).code).toBe("context_length_exceeded"); + } else { + expect(response.status).toBe(413); + expect(response.headers.get("content-type")).toContain("application/json"); + expect(await response.json()).toEqual({ error: { + message: PROVIDER_INPUT_TOO_LARGE_MESSAGE, + type: "invalid_request_error", + code: "context_length_exceeded", + } }); + } expect(firstHits).toBe(1); expect(secondHits).toBe(0); } finally { From 15c03e8b1058e26f71b7c2fac6c46cc76577cab5 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Wed, 9 Sep 2026 14:10:00 +0000 Subject: [PATCH 2/2] test(responses): assert classified overflow after bounded image retry --- .../adapters/anthropic/anthropic-image-retry-e2e.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/adapters/anthropic/anthropic-image-retry-e2e.test.ts b/tests/adapters/anthropic/anthropic-image-retry-e2e.test.ts index df6ab9a4d6..da69051e67 100644 --- a/tests/adapters/anthropic/anthropic-image-retry-e2e.test.ts +++ b/tests/adapters/anthropic/anthropic-image-retry-e2e.test.ts @@ -153,8 +153,13 @@ describe("anthropic 413 tightened-retry (end-to-end)", () => { const res = await postImageRequest(String(server.url), await realPngDataUrl(1500, 1000)); expect(res.status).toBe(413); expect(seen).toHaveLength(2); - const errorText = await res.text(); - expect(errorText).toContain("Provider error 413"); + expect(res.headers.get("content-type")).toContain("application/json"); + const errorBody = await res.json(); + expect(errorBody.error).toEqual({ + message: "The provider rejected this turn because its input exceeds the provider size or context limit. Reduce the current input or compact the conversation before retrying.", + type: "invalid_request_error", + code: "context_length_exceeded", + }); } finally { await server.stop(true); }