Skip to content
Draft
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
49 changes: 33 additions & 16 deletions src/server/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,19 @@ export function refusalFailedTailFrame(
);
}

/**
* A bare upstream error event: the `type: "error"` SSE event, or an untyped envelope that
* carries only an `error` object (`{"error":{...}}`, #8216). Some OpenAI-compatible gateways
* end a stream with the untyped form; without this it was dropped and EOF reported
* `adapter_eof`. A typed event of any other kind is never bare, even with an `error` field.
*/
function isBareUpstreamErrorEvent(root: JsonRecord): boolean {
return root.type === "error" || (root.type === undefined && asJsonRecord(root.error) !== null);
}

function boundedBareUpstreamErrorMessage(payload: unknown): string | undefined {
if (!payload || typeof payload !== "object" || Array.isArray(payload)
|| (payload as { type?: unknown }).type !== "error") return undefined;
const root = asJsonRecord(payload);
if (!root || !isBareUpstreamErrorEvent(root)) return undefined;
const message = upstreamErrorMessageFromPayload(payload);
return message ? redactSecretString(message).slice(0, MAX_TAIL_ERROR_MESSAGE_CHARS) : undefined;
}
Expand All @@ -256,7 +266,7 @@ function boundedBareUpstreamError(payload: unknown): {
errorCode: string | undefined;
} | undefined {
const root = asJsonRecord(payload);
if (!root || root.type !== "error") return undefined;
if (!root || !isBareUpstreamErrorEvent(root)) return undefined;
const message = boundedBareUpstreamErrorMessage(payload);
const response = asJsonRecord(root.response);
// Precedence is {@link upstreamErrorMessageFromPayload}'s, so the envelope
Expand All @@ -272,21 +282,23 @@ function boundedBareUpstreamError(payload: unknown): {
];
const code = candidates.map(candidate => stringField(candidate, "code"))
.find(candidate => candidate !== undefined)?.slice(0, 128);
// Root `type` is the SSE event discriminator (`"error"`), not an error class. Only nested
// Root `type` is the SSE event discriminator (`"error"` or absent), not an error class. Only nested
// error records can authoritatively name classes such as rate_limit_error or server_error.
const errorType = candidates.slice(0, -1).map(candidate => stringField(candidate, "type"))
.find(candidate => candidate !== undefined)?.slice(0, 128);
const refusalCode = code !== undefined
? (isTerminalRefusalCode(code) ? code : undefined)
: message === undefined ? undefined : safetyRefusalCodeFromMessage(message);
if (message !== undefined) return { message, refusalCode, errorType, errorCode: code };
if (refusalCode === undefined) return undefined;
return {
message: terminalRefusalFallbackMessage(refusalCode),
refusalCode,
errorType,
errorCode: code,
};
if (refusalCode !== undefined) {
return { message: terminalRefusalFallbackMessage(refusalCode), refusalCode, errorType, errorCode: code };
}
// A structured code or class with no message is still the upstream's verdict, so it must end in
// `response.failed` rather than `adapter_eof`. Its own bounded identifiers are the only
// diagnostic it sent; the tail redacts and masks them like any other message.
const identity = code ?? errorType;
if (identity === undefined) return undefined;
return { message: `Upstream error without a message (${identity})`, refusalCode, errorType, errorCode: code };
}

export type SseTerminalOutputBoundary = {
Expand Down Expand Up @@ -670,8 +682,9 @@ function parseSsePayload(payload: string): unknown | undefined {
}

function isPolicyRewriteType(parsed: unknown): boolean {
const type = asJsonRecord(parsed)?.type;
return type === "response.failed" || type === "response.incomplete" || type === "error";
const root = asJsonRecord(parsed);
if (!root) return false;
return root.type === "response.failed" || root.type === "response.incomplete" || isBareUpstreamErrorEvent(root);
}

/**
Expand Down Expand Up @@ -802,7 +815,9 @@ function createFirstOutputReporter(onFirstOutput?: () => void): {
}

export function terminalStatusFromParsed(parsed: unknown): ResponsesTerminalStatus | null {
const type = asJsonRecord(parsed)?.type;
const root = asJsonRecord(parsed);
// An untyped bare error envelope (#8216) is classified like the `error` event it stands for.
const type = root && isBareUpstreamErrorEvent(root) ? "error" : root?.type;
switch (type) {
case "response.completed":
return "completed";
Expand Down Expand Up @@ -1719,8 +1734,10 @@ export function consumeForInspection(
logCtx,
onCompletedResponse,
onParsedPayload: payload => {
const message = boundedBareUpstreamErrorMessage(payload);
if (message !== undefined) bareUpstreamError = message;
// Same extraction as the client boundary, so a refusal or code sent without a message is
// still a failure here and cannot be recorded as an incomplete (non-failed) turn.
const bare = boundedBareUpstreamError(payload);
if (bare !== undefined) bareUpstreamError = bare.message;
options?.onParsedPayload?.(payload);
},
onFirstOutput,
Expand Down
18 changes: 18 additions & 0 deletions src/server/ws-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,23 @@ function payloadType(payload: string): string | null {
}
}

/**
* An untyped `{"error":{...}}` envelope (#8216): not a Responses event, but not a malformed frame
* either. The SSE relay boundary keeps the error and reports it as `response.failed` at EOF unless a
* real terminal arrives first, so the pump skips the frame and lets that terminal reach the client.
* Mirrors the untyped half of `isBareUpstreamErrorEvent` in `relay.ts`.
*/
function isUntypedErrorEnvelope(payload: string): boolean {
try {
const json = JSON.parse(payload) as unknown;
if (!json || typeof json !== "object" || Array.isArray(json)) return false;
const root = json as { type?: unknown; error?: unknown };
return root.type === undefined && !!root.error && typeof root.error === "object" && !Array.isArray(root.error);
} catch {
return false;
}
}

function terminalStatusFromType(type: string): ResponsesTerminalStatus | null {
switch (type) {
case "response.completed":
Expand Down Expand Up @@ -290,6 +307,7 @@ export async function pumpResponsesSseToWebSocket(
/* payload observation must not affect WebSocket delivery */
}
const type = payloadType(payload);
if (!type && isUntypedErrorEnvelope(payload)) return false;
if (!type) {
reportTerminal("incomplete");
sendProtocolError(ws, 502, "Invalid JSON payload in upstream SSE frame");
Expand Down
6 changes: 4 additions & 2 deletions structure/transports/responses-combo-boundaries.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,10 @@ is not a prelude: it commits.
the ordinary bounded combo-failure classifier and may advance to the next declared target;
- a zero-output `response.incomplete` with no reason, `adapter_eof`, `missing_terminal_event`, or
`upstream_stall_timeout` follows the same failure path; explicit semantic or unknown reasons stay terminal;
- a top-level `error` arrives before output, in which case unknown, rate-limit, and server failures
may advance while errors explicitly classified as non-retryable 4xx remain committed;
- a top-level `error` (`type: "error"`) arrives before output, in which case unknown, rate-limit, and server failures
may advance while errors explicitly classified as non-retryable 4xx remain committed. An untyped
`{"error":{...}}` envelope is not a control event, so it commits the target; the relay then reports it
as `response.failed` rather than `adapter_eof`;
- another completed/incomplete terminal or the aggregate preflight byte or retained-chunk cap is reached,
in which case the current target is committed conservatively.

Expand Down
10 changes: 5 additions & 5 deletions structure/transports/responses-wire-shapes.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,14 +397,14 @@ Native passthrough SSE has TWO shapes, selected per request in
The shared `createSseInspector` factory in `relay.ts` retains the complete inspection side effects,
including the #44 late-terminal semantics.

Both client readers retain a bounded, redacted message and the first structured refusal code from a bare upstream `error`. EOF synthesis also retains narrowly recognized rate-limit codes (`rate_limit_exceeded`, `rate_limit_error`) overload codes (`server_is_overloaded`, `overloaded_error`), and the client-refusal classes `invalid_request_error`, `authentication_error`, `permission_error`, `not_found_error` (400/401/403/404, the code repeating the class); recognized types apply only without an explicit code.
At EOF without a real terminal they synthesize `response.failed` rather than `adapter_eof`; a code without a message still
produces a terminal. Codex retries codes outside its fatal set, so code and message follow the same candidate precedence;
Both client readers retain a bounded, redacted message and the first structured refusal code from a bare upstream `error`: a `type: "error"` event, or an envelope with no root `type` that carries an `error` object (#8216). A typed event of any other kind is never bare, even when it has an `error` field. The SSE-to-WebSocket pump in `src/server/ws-bridge.ts` skips the untyped envelope rather than failing it as an invalid frame, so the boundary's terminal still reaches a WebSocket client; any other frame without a string `type` stays a protocol error. EOF synthesis also retains narrowly recognized rate-limit codes (`rate_limit_exceeded`, `rate_limit_error`) overload codes (`server_is_overloaded`, `overloaded_error`), and the client-refusal classes `invalid_request_error`, `authentication_error`, `permission_error`, `not_found_error` (400/401/403/404, the code repeating the class); recognized types apply only without an explicit code.
At EOF without a real terminal they synthesize `response.failed` rather than `adapter_eof`; a code or error class without a
message still produces a terminal (a refusal code gets refusal copy, any other identifier is named in the message). Codex retries codes outside its fatal set, so code and message follow the same candidate precedence;
recognized refusal copy is used only when the event has no code. A read failure after refusal reports that refusal (#5176).
The shared outbound rewrite masks diagnostics on real failed and incomplete terminals before SSE or buffered JSON delivery,
while preserving status and output; failed turns are not retained as continuation state. Buffered JSON masks selected credentials in synthetic bare-error fields before log inspection or client formatting; request logs keep transport status.
The delivering reader owns refusal evidence before EOF; asynchronous tee inspection cannot reliably supply it. The client ingress in `src/server/index/websocket-handler.ts` preserves the same in-process replay-refusal marker as the HTTP deferred logger before finalization: a synthetic 429 retains `upstream_reset_replay_refused` and `transport-ambiguous` on the row and serving attempt, with `terminalSource: synthetic` on the row. `src/server/responses/core-combo-failure.ts` carries that marker through child failure rebuilds, and the combo loop stamps the failed child attempt before adopting its log. Synthetic refusals stop before combo cooldown/account rotation and supply no quota reset or cooldown metadata. Genuine upstream 429s keep rate-limit attribution and health handling. This is independent of the upstream transport choice; `headers-only` does not prove upstream execution or quota exhaustion. `tests/responses/ws-replay-refusal-attribution.test.ts` checks direct routes; `tests/responses/combo-replay-refusal-attribution.test.ts` checks both combo ingresses, gateway and pool children, health/cooldown state and genuine 429 controls, with one send and no recovery.
Inspection still applies the bare-error rule at EOF for account health. Real terminals and caller cancellation take precedence. Unknown codes retain `upstream_error` / `upstream_server_error` in the client tail; on the streaming path the log status takes the frame's Codex WebSocket wrapper 5xx when stated (buffered JSON callers keep their own presentation status). Refusal precedence is unchanged and classification adds no replay or retry. Bare-error log status is provisional until a genuine terminal supersedes it through `src/server/request-log-terminal-status.ts`.
Inspection still applies the same bare-error extraction at EOF for account health, so a refusal or code sent without a message is reported `failed` there too. Real terminals and caller cancellation take precedence. Unknown codes retain `upstream_error` / `upstream_server_error` in the client tail; on the streaming path the log status takes the frame's Codex WebSocket wrapper 5xx when stated (buffered JSON callers keep their own presentation status). Refusal precedence is unchanged and classification adds no replay or retry. Bare-error log status is provisional until a genuine terminal supersedes it through `src/server/request-log-terminal-status.ts`.
Native recovery preflight keeps the rejected body reader and bounded prefix for normal mid-stream failure, without decrypt retry.

Native Responses may rebuild once when encrypted function/custom-tool output or
Expand Down Expand Up @@ -517,7 +517,7 @@ byte-identical payloads unchanged.

The client-facing boundary treats the first Responses terminal as authoritative in both relay
shapes. High-confidence policy errors carried as `response.incomplete`, `response.failed`, or a
top-level `error` are normalized to one `response.failed / cyber_policy` event without changing the
bare `error` (typed or untyped) are normalized to one `response.failed / cyber_policy` event without changing the
refusal outcome; later bytes cannot create a second terminal. A clean HTTP 200 EOF with no terminal
instead emits one `response.incomplete` with `adapter_eof`, followed by one `[DONE]`. Delimiter-less
EOF candidates follow the owning repair policy: the native boundary accepts a structurally valid
Expand Down
Loading
Loading