Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/server/responses/adapter-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions src/server/responses/non-replayable-error.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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;
}
6 changes: 5 additions & 1 deletion src/server/responses/passthrough-delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion structure/transports/responses-failover.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion structure/transports/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
1 change: 1 addition & 0 deletions tests/helpers/responses-core-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion tests/responses/responses-console-go-upload-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
} });
});
}
});
Loading
Loading