diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index e41c219f3f2..49338f83583 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -4,6 +4,7 @@ import { authorizeResendForRecovery } from "../../lib/request-resend-gate"; import { ambiguousResendAllowanceFor, selfContainedResponsesBody } from "./reset-replay"; import { transientSendCapFor } from "./request-send-budget"; import { isNonReplayableResponse } from "../../lib/upstream-retry"; +import { sanitizeNonReplayableUpstreamError } from "./non-replayable-error"; import { isLocalUpstream } from "../../lib/local-upstream"; import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options"; import type { PreparedResponsesRequest } from "./request-prepare"; @@ -704,8 +705,9 @@ export async function prepareAdapterExchange( // Preserve the terminal verdict through adapter and combo error formatting. // This also covers a reset reached by a 401/429/413 recovery refetch. if (isNonReplayableResponse(upstreamResponse)) { - cleanupUpstreamAbort(); - return upstreamResponse; + try { + return await sanitizeNonReplayableUpstreamError(upstreamResponse, upstream.signal); + } finally { cleanupUpstreamAbort(); } } if ( upstreamResponse.status === 401 diff --git a/src/server/responses/non-replayable-error.ts b/src/server/responses/non-replayable-error.ts new file mode 100644 index 00000000000..53a21a42008 --- /dev/null +++ b/src/server/responses/non-replayable-error.ts @@ -0,0 +1,48 @@ +import { isNonReplayableResponse, isReplayRefusalResponse, markResponseNonReplayable } from "../../lib/upstream-retry"; +import { clientCancelledResponse, readDisplaySafeErrorText } from "./core-errors"; + +const UPSTREAM_ERROR_TYPES = new Set([ + "invalid_request_error", "authentication_error", "permission_error", "not_found_error", + "rate_limit_error", "request_too_large", "overloaded_error", "api_error", "server_error", "insufficient_quota", +]); +const UPSTREAM_ERROR_CODES = new Set([ + "context_length_exceeded", "rate_limit_exceeded", "insufficient_quota", "invalid_api_key", + "model_not_found", "invalid_prompt", "content_policy_violation", "server_error", + "unsupported_parameter", "invalid_value", "string_above_max_length", +]); + +/** Client projection never grants another send or turns a real upstream error into our refusal. */ +export async function sanitizeNonReplayableUpstreamError( + response: Response, + signal: AbortSignal, +): Promise { + if (response.ok || !isNonReplayableResponse(response) || isReplayRefusalResponse(response)) return response; + const text = await readDisplaySafeErrorText(response, signal, ""); + if (signal.aborted) { + const cancelled = clientCancelledResponse(); + markResponseNonReplayable(cancelled); + return cancelled; + } + let type = "upstream_error"; + let code: string | undefined; + try { + const parsed = JSON.parse(text); + const upstreamType = parsed?.error?.type; + const upstreamCode = parsed?.error?.code; + if (typeof upstreamType === "string" && UPSTREAM_ERROR_TYPES.has(upstreamType)) type = upstreamType; + if (typeof upstreamCode === "string" && UPSTREAM_ERROR_CODES.has(upstreamCode)) code = upstreamCode; + } catch { + // Incomplete or non-JSON bodies retain the generic error type. + } + // Upstream text is not forwarded here: no bounded redaction can be proven complete against a client's decoders. + const body = JSON.stringify({ error: { type, ...(code === undefined ? {} : { code }), + message: `Provider error ${response.status}: upstream diagnostic withheld for a non-replayable failure`, + } }); + const headers = new Headers({ "content-type": "application/json" }); + if (response.headers.get("x-should-retry") === "false") headers.set("x-should-retry", "false"); + const safe = new Response(response.status === 304 ? null : body, { + status: response.status, headers, + }); + markResponseNonReplayable(safe); + return safe; +} diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index f2d170bfd0f..3b4e84baa1e 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -18,7 +18,8 @@ import { relayWithAbort, } from "../relay"; import { isUsageDebugEnabled } from "../../usage/debug"; -import { isReplayRefusalResponse } from "../../lib/upstream-retry"; +import { isNonReplayableResponse, isReplayRefusalResponse } from "../../lib/upstream-retry"; +import { sanitizeNonReplayableUpstreamError } from "./non-replayable-error"; import { teeWithBoundedInspection } from "../inspection-tee"; import { codexForwardTerminalOutcomeRecorder, @@ -543,6 +544,9 @@ export async function deliverPassthroughResponse( // through sanitizePassthroughHeaders) so a redirect to a dead host can never // masquerade as a pre-connection failure after the credential was seen. // The numeric outcome above already classified it neutral — no streak. + if (isNonReplayableResponse(upstreamResponse) && !isReplayRefusalResponse(upstreamResponse)) { + return await sanitizeNonReplayableUpstreamError(upstreamResponse, upstream.signal); + } if (upstreamResponse.status >= 300 && upstreamResponse.status < 400) { return new Response(upstreamResponse.body, { status: upstreamResponse.status, diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index 11e1b0edfbe..4e6e0a55180 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -374,7 +374,7 @@ the pre-header row in `fetchWithResetRetry` and the WebSocket row alike: | --- | --- | | 2xx | Returned unchanged. | | 307, 308, 401, 402, 408, 409, 413, 429, or any 5xx | Body released; settles as the refusal. | -| Any other status | Real status and body kept, marked non-replayable. | +| Any other status | Real status and non-replayability kept; bounded client projection retains allowlisted error type and code and `x-should-retry: false` when present, withholds upstream body text and all other upstream headers, and emits a fixed generic message. | The refusal set is everything that would send again: the client retry table (408, 409, 429, every 5xx, which the Codex client retries whatever the headers say), a client following a diff --git a/structure/transports/responses.md b/structure/transports/responses.md index ce5ac093c47..84fe86bde48 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -424,7 +424,7 @@ is composed from the following owners in `src/server/responses/`; none is a gene | `request-spend.ts` | This request's entries in the durable spend ledger: one per physical send, settled from the terminal usage. | | `passthrough-execution.ts` | Native host-lease transfer and the enclosing dispatch/delivery `finally`. | | `passthrough-dispatch.ts` | Native request preparation, upstream sends and pre-commit recovery. | -| `passthrough-delivery.ts`, `terminal-error-redaction.ts` | Native HTTP/SSE/JSON delivery, rewrite/inspection, terminal accounting, terminal diagnostic redaction before client delivery, and xAI tool-envelope filtering before continuation storage. | +| `passthrough-delivery.ts`, `terminal-error-redaction.ts`, `non-replayable-error.ts` | Native HTTP/SSE/JSON delivery and terminal accounting. Marked real errors use bounded reads to retain only allowlisted error type and code, keep status and non-replayability plus `x-should-retry: false` when present, and withhold upstream body text and all other upstream headers; the client receives a fixed generic message and synthetic refusals stay distinct. SSE diagnostics and xAI tool-envelope filtering precede client delivery/continuation storage. | | `policy-refusal.ts` | Rewrites an allowlisted non-combo HTTP 403 model refusal (`isUpstreamPolicyRefusal` in `src/lib/errors.ts`) from an xAI destination only (`isXaiResponsesDestination`: api.x.ai or the Grok CLI proxy, on either wire) to an HTTP 200 Responses `incomplete` / `content_filter` payload, JSON or SSE, for both `adapter-dispatch.ts` and `passthrough-delivery.ts`. A streamed rewrite takes the turn admission lease and releases it when the body finishes, so the refusal stays inside active-turn accounting. Combo attempts keep the original 403 so failover classifies it as a hop. | | `sidecar-execution.ts` | Image/video versus web-search execution and their shared rotation hook. | | `completion-policy.ts`, `run-turn-execution.ts` | Empty-completion eligibility and adapter-owned event turns. | diff --git a/tests/helpers/responses-core-source.ts b/tests/helpers/responses-core-source.ts index a49f2dd1c70..eb2c58fee3a 100644 --- a/tests/helpers/responses-core-source.ts +++ b/tests/helpers/responses-core-source.ts @@ -49,6 +49,7 @@ export const RESPONSES_CORE_MODULES = [ "passthrough-delivery.ts", "buffered-sse-json.ts", "terminal-error-redaction.ts", + "non-replayable-error.ts", "sidecar-execution.ts", "completion-policy.ts", "run-turn-execution.ts", diff --git a/tests/responses/responses-console-go-upload-retry.test.ts b/tests/responses/responses-console-go-upload-retry.test.ts index 75805f3c64e..3151c48ce8d 100644 --- a/tests/responses/responses-console-go-upload-retry.test.ts +++ b/tests/responses/responses-console-go-upload-retry.test.ts @@ -282,7 +282,9 @@ describe("Console nonreplayable response boundary", () => { const response = await handleResponses(request(), cfg, { model: "", provider: "" }); expect(response.status).toBe(400); expect(sends).toBe(1); - expect(await response.text()).toContain("Invalid upload request."); + expect(await response.json()).toEqual({ error: { type: "invalid_request_error", + message: "Provider error 400: upstream diagnostic withheld for a non-replayable failure", + } }); }); } }); diff --git a/tests/responses/responses-translated-reset.test.ts b/tests/responses/responses-translated-reset.test.ts index d92b3deb25b..545245da5ad 100644 --- a/tests/responses/responses-translated-reset.test.ts +++ b/tests/responses/responses-translated-reset.test.ts @@ -13,6 +13,8 @@ import { handleResponses } from "../../src/server/responses"; import { createRequestExecutionBudget } from "../../src/lib/request-execution-budget"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { isNonReplayableResponse, isReplayRefusalResponse, markResponseNonReplayable, replayRefusalResponse } from "../../src/lib/upstream-retry"; +import { sanitizeNonReplayableUpstreamError } from "../../src/server/responses/non-replayable-error"; async function probe(options: { provider?: Partial; @@ -28,7 +30,7 @@ async function probe(options: { previous?: boolean; prepaid?: boolean; config?: Partial; - answer?: (ordinal: number) => Response; + answer?: (ordinal: number, authorization: string | null) => Response; executor?: typeof fetch; oauth?: boolean; }) { @@ -78,7 +80,7 @@ async function probe(options: { authorizations.push(new Headers(init?.headers).get("authorization")); if (options.executor) return options.executor(_input, init); if (options.abort) abort.abort(); - if (options.answer) return options.answer(bodies.length); + if (options.answer) return options.answer(bodies.length, authorizations.at(-1) ?? null); if (options.downgrade && bodies.length === 1) { if (options.spendOnDowngrade) budget.claimAmbiguousResend(1); return Response.json({ error: { type: "invalid_request_error", param: "reasoning_effort", @@ -107,7 +109,8 @@ async function probe(options: { expect(workflowSends).toBe(bodies.length); } expect(attemptSends).toBe(bodies.length); - return { status: response.status, text, bodies, used: budget.used, workflowSends, attemptSends, + return { status: response.status, text, headers: response.headers, + bodies, used: budget.used, workflowSends, attemptSends, grantSpent: budget.ambiguousResendSpent, reserveSpent: budget.reserveSpent, authorizations }; } finally { @@ -182,6 +185,230 @@ const effortRefusal = (): Response => Response.json({ error: { type: "invalid_re const chatSuccess = (): Response => Response.json({ id: "chat-test", choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }] }); +describe("reset replacement error confidentiality", () => { + const credential = "fixture-active-credential-v1"; + const genericBody = { error: { type: "upstream_error", + message: "Provider error 400: upstream diagnostic withheld for a non-replayable failure", + } }; + const diagnostics = [ + ...([ + ["nested JSON escape", credential, "invalid request: \\u0066ixture-active-credential-v1; path C:\\safe\\u1234.txt"], + ["percent escape", credential, "invalid request: %66ixture-active-credential-v1"], + ["nested percent escape", credential, "invalid request: %2566ixture-active-credential-v1"], + ["numeric reference", credential, "invalid request: fixture-active-credential-v1"], + ["named reference", "fixture+credential-v1", "invalid request: fixture+credential-v1"], + ["percent-encoded named reference", "fixture+credential-v1", "invalid request: fixture%26plus%3Bcredential-v1"], + ["multi-code-point named reference", "fj-credential-v1", "bad key fj-credential-v1"], + ["UTF-8 percent sequence", "éabcdeé-active-v1", "invalid request: %C3%A9%61%62%63%64%65%C3%A9-active-v1"], + ["fullwidth percent", credential, "invalid request: %66ixture-active-credential-v1"], + ["uppercase Greek lookalike", "fixture-Active-credential-v1", "invalid request: fixture-\u0391ctive-credential-v1"], + ["ligature lookalike", credential, "invalid request: \uFB01xture-active-credential-v1"], + ["fullwidth lookalike", credential, "invalid request: fixture-active-credential-v1"], + ["zero-width split", credential, "invalid request: fixture-active-\u200Bcredential-v1"], + ["nested JSON short escape", "fixture/credential-v1", "invalid request: fixture\\/credential-v1"], + ["uppercase hex numeric reference", credential, "invalid request: fixture-active-credential-v1"], + ["numeric reference without semicolon", credential, "invalid request: fixture-active-credential-v1"], + ["legacy named reference without semicolon", "fixture&credential-v1", "invalid request: fixture&credential-v1"], + ["legacy reference before equals", "fixture&credential", "invalid request: fixture&credential="], + ["legacy reference before long run", "fixture&" + "a".repeat(40), "invalid request: fixture&" + "a".repeat(40)], + ["over-nested percent encoding", credential, "invalid request: %2525252566ixture-active-credential-v1"], + ["HTML split", credential, "invalid request: fixture-active-credential-v1"], + ["query without reference start", credential, "see https://x.test/a?b=1 for details"], + ["query with reference start", credential, "see https://x.test/a?b=1&c=2"], + ["redaction marker overlap", "REDACTED", "api_key=REDACTED"], + ] as const).map(([name, apiKey, message]) => ({ name, apiKey, + text: JSON.stringify({ error: { message } }), contentType: "application/json", type: "upstream_error" })), + ...([ + ["plain echo", credential, `invalid request: ${credential}`, "text/plain"], + ["JSON unicode escape", credential, JSON.stringify({ error: { message: credential } }).replaceAll("f", "\\u0066"), "application/json"], + ["JSON short escape", "fixture/credential-v1", '{"error":{"message":"fixture\\/credential-v1"}}', "application/json"], + ["numeric credential normalized to exponent", "1000000000000000000000", '{"error":{"code":1e21}}', "application/json"], + ["numeric scalar credential", "867530912345", '{"error":{"message":"invalid request","code":867530912345,"count":17,"flag":false}}', "application/json"], + ["boolean scalar credential", "true", '{"error":{"message":"x","code":true}}', "application/json"], + ["null scalar credential", "null", '{"error":{"message":"x","code":null}}', "application/json"], + ["credential as JSON key", credential, JSON.stringify({ error: { [credential]: 1 } }), "application/json"], + ["non-ASCII JSON key", "fixture-Active", JSON.stringify({ error: { message: "x", ["fixture-\u0391ctive"]: 1 } }), "application/json"], + ["array body", credential, JSON.stringify([credential, { message: `invalid request: ${credential}` }, 17]), "application/json"], + ["non-2xx SSE body", credential, `event: response.failed\ndata: {"type":"response.failed","response":{"error":{"message":"invalid request: ${credential}"}}}\n\n`, "text/event-stream"], + ["malformed JSON", credential, `{"error":{"message":"invalid request: ${credential}"`, "application/json"], + ["over-nested JSON", credential, '{"detail":'.repeat(65) + JSON.stringify(credential) + "}".repeat(65), "application/json"], + ["escaped credential in every JSON field", credential, JSON.stringify({ error: { message: credential, type: credential, code: credential }, + [credential]: credential }).replaceAll(credential, credential.replaceAll("f", "\\u0066")), "application/json"], + ] as const).map(([name, apiKey, text, contentType]) => ({ name, apiKey, text, contentType, type: "upstream_error" })), + { name: "allowlisted type with hostile message", apiKey: credential, + text: JSON.stringify({ error: { type: "invalid_request_error", message: credential } }), + contentType: "application/json", type: "invalid_request_error" }, + ]; + test.each((["openai-chat", "openai-responses"] as const).flatMap(adapter => + diagnostics.map(diagnostic => [adapter, diagnostic.name, diagnostic] as const), + ))("%s withholds upstream diagnostics (%s)", async (adapter, _name, diagnostic) => { + const result = await probe({ provider: { adapter, apiKey: diagnostic.apiKey }, answer: ordinal => ordinal === 1 + ? reset() : new Response(diagnostic.text, { status: 400, headers: { "content-type": diagnostic.contentType } }) }); + expect(result.authorizations).toEqual([`Bearer ${diagnostic.apiKey}`, `Bearer ${diagnostic.apiKey}`]); + expect(result.status).toBe(400); + expect(result.bodies).toHaveLength(2); + expect(result.used).toBe(2); + expect(result.grantSpent).toBe(true); + expect(JSON.parse(result.text)).toEqual({ error: { type: diagnostic.type, message: genericBody.error.message } }); + expect(result.text).not.toContain(diagnostic.apiKey); + }); + + for (const adapter of ["openai-chat", "openai-responses"] as const) { + test(`${adapter} preserves an allowlisted upstream error type`, async () => { + const result = await probe({ provider: { adapter, apiKey: credential }, answer: ordinal => ordinal === 1 + ? reset() : Response.json({ error: { type: "invalid_request_error", message: credential } }, { status: 400 }) }); + expect(result.status).toBe(400); + expect(result.bodies).toHaveLength(2); + expect(result.used).toBe(2); + expect(result.grantSpent).toBe(true); + expect(JSON.parse(result.text)).toEqual({ error: { type: "invalid_request_error", message: genericBody.error.message } }); + expect(result.text).not.toContain(credential); + }); + + test(`${adapter} preserves an allowlisted upstream error code without its diagnostic`, async () => { + const result = await probe({ provider: { adapter, apiKey: credential }, answer: ordinal => ordinal === 1 + ? reset() : Response.json({ error: { type: "invalid_request_error", code: "context_length_exceeded", message: credential } }, { status: 400 }) }); + expect(result.status).toBe(400); + expect(result.bodies).toHaveLength(2); + expect(result.used).toBe(2); + expect(result.grantSpent).toBe(true); + expect(JSON.parse(result.text)).toEqual({ error: { type: "invalid_request_error", + code: "context_length_exceeded", message: genericBody.error.message } }); + expect(result.text).not.toContain(credential); + }); + + test.each(["unrecognized_error_code", credential])(`${adapter} drops an unapproved upstream error code (%s)`, async code => { + const result = await probe({ provider: { adapter, apiKey: credential }, answer: ordinal => ordinal === 1 + ? reset() : Response.json({ error: { type: "invalid_request_error", code, message: credential } }, { status: 400 }) }); + expect(result.status).toBe(400); + expect(result.bodies).toHaveLength(2); + expect(result.used).toBe(2); + expect(result.grantSpent).toBe(true); + expect(JSON.parse(result.text)).toEqual({ error: { type: "invalid_request_error", message: genericBody.error.message } }); + expect(result.text).not.toContain(code); + expect(result.text).not.toContain(credential); + }); + + test(`${adapter} rejects a credential-valued upstream error type`, async () => { + const result = await probe({ provider: { adapter, apiKey: credential }, answer: ordinal => ordinal === 1 + ? reset() : Response.json({ error: { type: credential } }, { status: 400 }) }); + expect(result.status).toBe(400); + expect(result.bodies).toHaveLength(2); + expect(result.used).toBe(2); + expect(result.grantSpent).toBe(true); + expect(JSON.parse(result.text)).toEqual(genericBody); + expect(result.text).not.toContain(credential); + }); + + test(`${adapter} drops upstream diagnostic headers and preserves retry refusal`, async () => { + const result = await probe({ provider: { adapter, apiKey: credential }, answer: ordinal => ordinal === 1 + ? reset() : Response.json({ error: { message: `invalid request: ${credential}` } }, { status: 400, headers: { + "x-debug-token": credential, "x-upstream-debug": credential, "set-cookie": `diagnostic=${credential}`, + "retry-after": "0", "x-should-retry": "false", + } }) }); + expect(result.status).toBe(400); + expect(result.bodies).toHaveLength(2); + expect(result.used).toBe(2); + expect(result.grantSpent).toBe(true); + expect(JSON.parse(result.text)).toEqual(genericBody); + expect(result.text).not.toContain(credential); + expect([...result.headers.entries()]).toEqual([["content-type", "application/json"], ["x-should-retry", "false"]]); + expect(result.headers.get("x-debug-token")).toBeNull(); + expect(result.headers.get("x-upstream-debug")).toBeNull(); + expect(result.headers.get("set-cookie")).toBeNull(); + expect(result.headers.get("retry-after")).toBeNull(); + expect(result.headers.get("x-should-retry")).toBe("false"); + }); + + test(`${adapter} withholds diagnostics after header OWS normalization`, async () => { + const result = await probe({ provider: { adapter, apiKey: `${credential} ` }, answer: (ordinal, authorization) => ordinal === 1 + ? reset() : Response.json({ error: { message: `invalid request: ${authorization!.replace(/^Bearer /, "")}` } }, { status: 400 }) }); + expect(result.authorizations).toEqual([`Bearer ${credential}`, `Bearer ${credential}`]); + expect(result.status).toBe(400); + expect(result.bodies).toHaveLength(2); + expect(JSON.parse(result.text)).toEqual(genericBody); + }); + } + + test("withholds diagnostics after an OAuth account replacement", async () => { + const result = await probe({ oauth: true, providerName: "xai", provider: { adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", authMode: "oauth", models: ["test"] }, + body: { model: "xai/test" }, answer: (ordinal, authorization) => ordinal === 1 + ? Response.json({ error: { message: "quota exhausted" } }, { status: 429 }) + : ordinal === 2 ? reset() : Response.json({ error: { message: `invalid request: ${authorization}` } }, { status: 400 }) }); + expect(result.status).toBe(400); + expect(result.authorizations[0]).not.toBe(result.authorizations[2]); + expect(result.text).not.toContain(result.authorizations[2]!.replace(/^Bearer /, "")); + expect(JSON.parse(result.text)).toEqual(genericBody); + expect(result.bodies).toHaveLength(3); + }); + + test.each(["openai-chat", "openai-responses"] as const)("%s keeps a replacement terminal through combo consumption", async adapter => { + const result = await probe({ provider: { adapter, apiKey: credential, models: ["test", "other"] }, + config: { combos: { pair: { strategy: "failover", targets: [ + { provider: "mock", model: "test" }, { provider: "mock", model: "other" }, + ] } } }, body: { model: "combo/pair" }, answer: ordinal => ordinal === 1 ? reset() + : ordinal === 2 ? Response.json({ error: { code: "context_length_exceeded", message: `context length exceeded: ${credential}` } }, { status: 400 }) + : chatSuccess() }); + expect(result.status).toBe(400); + expect(result.text).not.toContain(credential); + // The combo owner reformats the consumed failure; it can only restate the sanitized projection. + expect(JSON.parse(result.text).error.message).toContain(genericBody.error.message); + expect(result.text).not.toContain("context length exceeded"); + expect(result.bodies).toHaveLength(2); + }); + + test("the client projection preserves marker provenance before lifetime wrapping", async () => { + const upstream = Response.json({ error: { message: `invalid request: ${credential}` } }, { status: 400 }); + markResponseNonReplayable(upstream); + const safe = await sanitizeNonReplayableUpstreamError(upstream, new AbortController().signal); + expect(safe.status).toBe(400); + expect(isNonReplayableResponse(safe)).toBe(true); + expect(isReplayRefusalResponse(safe)).toBe(false); + expect(await safe.text()).not.toContain(credential); + const refusal = replayRefusalResponse(); + expect(await sanitizeNonReplayableUpstreamError(refusal, new AbortController().signal)).toBe(refusal); + expect(isReplayRefusalResponse(refusal)).toBe(true); + await refusal.text(); + }); + + test("an ordinary error keeps its existing delivery owner", async () => { + const upstream = Response.json({ error: { message: "ordinary error" } }, { status: 400 }); + expect(await sanitizeNonReplayableUpstreamError(upstream, new AbortController().signal)).toBe(upstream); + await upstream.text(); + }); + + test("bodyless terminal responses retain their real status", async () => { + const upstream = new Response(null, { status: 304, headers: { "x-upstream-debug": credential } }); + markResponseNonReplayable(upstream); + const safe = await sanitizeNonReplayableUpstreamError(upstream, new AbortController().signal); + expect(safe.status).toBe(304); + expect(safe.body).toBeNull(); + expect(safe.headers.get("x-upstream-debug")).toBeNull(); + expect(isNonReplayableResponse(safe)).toBe(true); + }); + + test("oversized or cancelled diagnostics release their reader without leaking a prefix", async () => { + for (const abortRead of [false, true]) { + const abort = new AbortController(); + let cancelled = false; + const upstream = new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(credential + (abortRead ? "" : "x".repeat(65_536)))); + if (abortRead) queueMicrotask(() => abort.abort()); + }, + cancel() { cancelled = true; }, + }), { status: 400 }); + markResponseNonReplayable(upstream); + const safe = await sanitizeNonReplayableUpstreamError(upstream, abort.signal); + expect(safe.status).toBe(abortRead ? 499 : 400); + expect(await safe.text()).not.toContain(credential); + expect(cancelled).toBe(true); + expect(isNonReplayableResponse(safe)).toBe(true); + } + }); +}); + describe("translated reset replay boundaries and accounting", () => { test("a valid stored predecessor refuses replay even after local expansion", async () => { const result = await probe({ previous: true }); @@ -210,7 +437,10 @@ describe("translated reset replay boundaries and accounting", () => { answer: ordinal => ordinal === 1 ? reset() : status === 400 ? effortRefusal() : Response.json({ error: { message: "recoverable-looking failure" } }, { status }) }); expect(result.status, result.text).toBe(status === 400 ? 400 : 429); - expect(result.text).toContain(status === 400 ? "reasoning_effort max is not supported" : "upstream_reset_replay_refused"); + if (status === 400) expect(JSON.parse(result.text)).toEqual({ error: { type: "invalid_request_error", + message: "Provider error 400: upstream diagnostic withheld for a non-replayable failure", + } }); + else expect(result.text).toContain("upstream_reset_replay_refused"); expect(result.bodies).toHaveLength(2); expect(result.bodies[0]).toBe(result.bodies[1]); expect(result.grantSpent).toBe(true); diff --git a/tests/responses/ws-ambiguous-resend.test.ts b/tests/responses/ws-ambiguous-resend.test.ts index 36e12ae4cb1..2fea2eeb0ed 100644 --- a/tests/responses/ws-ambiguous-resend.test.ts +++ b/tests/responses/ws-ambiguous-resend.test.ts @@ -375,7 +375,10 @@ describe("handleResponses replaces a dead socket's send once under retryOnReset expect(http[0]!.get("chatgpt-account-id")).toBe("acc-work"); if (status === 400) { expect(response.status).toBe(400); - expect(await response.text()).toBe(body); + // The fixture has only detail, so neither an allowlisted error type nor code survives. + expect(await response.json()).toEqual({ error: { type: "upstream_error", + message: "Provider error 400: upstream diagnostic withheld for a non-replayable failure", + } }); } else { expect(response.status).toBe(REPLAY_REFUSED_STATUS); expect(response.headers.get("x-should-retry")).toBe("false");