From f3f43fb942df248f62d673727f2f2cb933789cbf Mon Sep 17 00:00:00 2001 From: Fred Amartey <43480311+FredAmartey@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:57:35 -0400 Subject: [PATCH 1/3] fix(responses): answer a wrapped WebSocket rejection with its HTTP status --- .../docs/reference/configuration/providers.md | 2 +- src/server/responses/codex-ws-exchange.ts | 48 +++++++++++ tests/responses/ws-upstream.test.ts | 85 ++++++++++++++++++- 3 files changed, 133 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index cd5c9657d6..6152858c19 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -126,7 +126,7 @@ predictions. Explicit provider/model price overrides still take precedence. | `upstreamHttpVersion?` | `"auto" \| "http1.1" \| "h1" \| "http2" \| "h2"` | Pin the HTTP version used for upstream requests to this provider. Defaults to `auto`, which lets Bun negotiate. An explicit pin requires an HTTPS target and fails locally when it cannot be honored. Set `http1.1` when a provider's HTTP/2 SSE stream stalls instead of delivering events — the symptom is a long-running streaming request that produces nothing and eventually times out. For Cursor, `http1.1`/`h1` selects its `RunSSE` + `BidiAppend` compatibility transport for inference and also pins live model discovery. Management `POST`/`PATCH` accept `null` to clear it back to `auto`. | | `responsesPath?` | `string` | Relative resource path for key-auth `openai-responses` requests. It must start with `/` and contain no scheme, query, or fragment. | | `allowEncryptedV2AgentTasks?` | `boolean` | Disabled by default. Trust a direct key-auth `openai-responses` provider to consume or relay opaque encrypted V2 sub-agent tasks unchanged. Eligible routes skip `agentTaskRecovery`; all other routes keep the existing recovery or fail-closed behavior. OpenCodex does not decrypt, translate, or recover tasks sent through this opt-in. | -| `upstreamWebsocket?` | `boolean` | Opt-in upstream Responses WebSocket transport for `openai-responses` requests (default false). When the upstream supports the Responses WebSocket protocol, streaming POST requests to the configured Responses path (default `/v1/responses`) are dialed as WSS over an HTTPS base URL and re-encoded to SSE for the usual pipeline. Forward providers use `{baseUrl}/responses`; key-auth providers use `responsesPath`, or the legacy `/v1/responses` fallback. This mirrors the canonical ChatGPT backend optimization for OpenAI-compatible gateways (for example sub2api) whose WebSocket ingress is measurably faster than its SSE queue. Plain HTTP remains on SSE; non-Responses paths and `openai-chat` requests stay on HTTP. | +| `upstreamWebsocket?` | `boolean` | Opt-in upstream Responses WebSocket transport for `openai-responses` requests (default false). When the upstream supports the Responses WebSocket protocol, streaming POST requests to the configured Responses path (default `/v1/responses`) are dialed as WSS over an HTTPS base URL and re-encoded to SSE for the usual pipeline. Forward providers use `{baseUrl}/responses`; key-auth providers use `responsesPath`, or the legacy `/v1/responses` fallback. This mirrors the canonical ChatGPT backend optimization for OpenAI-compatible gateways (for example sub2api) whose WebSocket ingress is measurably faster than its SSE queue. Plain HTTP remains on SSE; non-Responses paths and `openai-chat` requests stay on HTTP. A request the upstream refuses before any output, delivered as one `error` frame carrying a 4xx status (an expired token, a usage limit), is returned as that HTTP status with the frame's headers and body, so the same refresh, quota and account-rotation handling applies as on the SSE path. | | `supportsServiceTier?` | `boolean` | Tri-state canonical Fast capability fallback. `true` publishes Fast in the catalog, satisfies service-tier routing requirements, contributes a supported fingerprint, and lets fast mode inject the provider's canonical wire value on a compatible final adapter. `false` strips the field and never injects, and exact model declarations cannot reopen it. Absent leaves the provider unclassified: fast mode does not inject or normalize a canonical caller value, and caller values obey the final wire's forwarding permission (`chatServiceTier` on Chat; passthrough on Responses). The registry classifies canonical OpenAI (`true`), DeepSeek, and Volcengine Ark (`false`); set it explicitly only for custom gateways that genuinely support tiers. | | `modelSupportsServiceTier?` | `Record` | Exact upstream model capability overrides. Exact `true` enables canonical Fast for that model; exact `false` narrows provider defaults. An explicit provider-level `supportsServiceTier: false` remains fail-closed and cannot be reopened. Exact `true` does not authorize foreign caller-tier forwarding on Chat. Undeclared models fall back to provider-wide behavior. Management `PATCH /api/providers` merges entries and accepts `null` to clear one. | | `chatServiceTier?` | `boolean` | Provider-wide Chat-wire opt-in for forwarding caller `service_tier` values. On a classified route it governs foreign values such as `flex`, not proxy-owned canonical Fast after capability validation; on an unclassified route it governs every caller value because no Fast capability has been validated. Exact model capability does not authorize foreign forwarding. Responses routes retain their capability-based caller forwarding behavior. | diff --git a/src/server/responses/codex-ws-exchange.ts b/src/server/responses/codex-ws-exchange.ts index 2f41be02fa..6eab70c543 100644 --- a/src/server/responses/codex-ws-exchange.ts +++ b/src/server/responses/codex-ws-exchange.ts @@ -16,6 +16,42 @@ interface ExchangeOptions { beforeDispatch?: (headers: Headers) => void; } +/** + * Turn a refused create frame back into the HTTP error it stands for. + * + * When the backend rejects a turn before it starts (an expired token, a usage + * limit), it does not open a response. It sends one `error` frame carrying a + * `status_code`, the error object and the response headers, then closes. Codex's + * own WebSocket client maps that frame to the HTTP error (codex-api + * `responses_websocket.rs`, `map_wrapped_websocket_error_event`). Relayed as an + * SSE `error` event it is lost twice: the relay does not count `error` as a + * terminal and appends an `adapter_eof` incomplete, and Codex's SSE parser has no + * arm for `error`, so the client sees only the `adapter_eof` (#3029) and the + * pre-stream quota, refresh and rotation handlers never see the status. + * + * 4xx only: those refusals come before generation, so answering with the status + * cannot double-generate. 5xx keeps the stream path and its no-resend rule. + */ +function wrappedRejectionResponse(payload: Record): Response | null { + const raw = payload.status_code ?? payload.status; + if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 400 || raw > 499) return null; + const headers = new Headers(); + const source = payload.headers; + if (source && typeof source === "object" && !Array.isArray(source)) { + for (const [name, value] of Object.entries(source as Record)) { + if (typeof value !== "string") continue; + const lower = name.toLowerCase(); + if (lower === "content-length" || lower === "content-type" || lower === "transfer-encoding" || lower === "connection") continue; + try { headers.set(name, value); } catch { /* an invalid upstream header name/value is not ours to relay */ } + } + } + headers.set("content-type", "application/json"); + const error = payload.error && typeof payload.error === "object" && !Array.isArray(payload.error) + ? payload.error + : { type: "upstream_error", message: "Upstream rejected the request" }; + return new Response(JSON.stringify({ error }), { status: raw, headers }); +} + /** The sole SSE exchange state machine for both one-shot and retained sockets. */ export function codexWsExchange(options: ExchangeOptions): Promise { const { session, url, init, prepared, sseFallback, onQuota, beforeDispatch } = options; @@ -171,6 +207,18 @@ export function codexWsExchange(options: ExchangeOptions): Promise { const normalized = normalizeResponsesWsRelayEvent(text); if (!normalized) return; const { type } = normalized; + // Nothing has been committed to the client yet, so a wrapped rejection + // can still become the real HTTP response instead of a 200 stream. + if (!responseCommitted && type === "error") { + const rejection = wrappedRejectionResponse(normalized.payload); + if (rejection) { + terminal = true; + cleanup(); + session.dispose(); + resolve(rejection); + return; + } + } let relayText = normalized.text; let controlFrame = false; if (metadata) { diff --git a/tests/responses/ws-upstream.test.ts b/tests/responses/ws-upstream.test.ts index cfb087a4bb..6bfee74ad0 100644 --- a/tests/responses/ws-upstream.test.ts +++ b/tests/responses/ws-upstream.test.ts @@ -637,9 +637,92 @@ describe("codexWsUpstreamFetch", () => { expect(FakeWebSocket.instances[0].closed).toBe(true); }); + // A refused create frame arrives as one `error` frame with the HTTP status and + // headers, then a close. Codex's SSE parser has no `error` arm, so relaying it + // in-band reaches the client as nothing but the synthesized adapter_eof. + test("answers a wrapped 4xx rejection with its HTTP status instead of a 200 stream", async () => { + const frame = { + type: "error", + error: { type: "usage_limit_reached", message: "The usage limit has been reached", plan_type: "plus", resets_at: 1_800_000_000 }, + status_code: 429, + headers: { + "X-Codex-Primary-Used-Percent": "100", + "X-Codex-Primary-Reset-At": "1800000000", + "Content-Length": "174", + "Content-Type": "application/json", + }, + }; + installFake(ws => { + ws.emit("open", {}); + ws.emit("message", { data: JSON.stringify(frame) }); + ws.emit("close", { code: 1000, reason: "normal" }); + }); + const response = await codexWsUpstreamFetch(CODEX_URL, streamingInit(), (() => { + throw new Error("a sent frame must not be resent over HTTP"); + }) as unknown as typeof fetch); + + expect(response.status).toBe(429); + expect(response.headers.get("content-type")).toBe("application/json"); + expect(response.headers.get("x-codex-primary-used-percent")).toBe("100"); + expect(response.headers.get("x-codex-primary-reset-at")).toBe("1800000000"); + // The body is re-encoded, so the frame's framing headers must not survive. + expect(response.headers.get("content-length")).toBeNull(); + expect(await response.json()).toEqual({ error: frame.error }); + expect(isCodexWsUpstreamResponse(response)).toBe(false); + const ws = FakeWebSocket.instances[0]!; + expect(ws.sent).toHaveLength(1); + expect(ws.closed).toBe(true); + }); + + test("honors the status field spelling Codex accepts", async () => { + installFake(ws => { + ws.emit("open", {}); + ws.emit("message", { + data: JSON.stringify({ type: "error", status: 401, error: { type: "invalid_token", message: "expired" } }), + }); + }); + const response = await codexWsUpstreamFetch(CODEX_URL, streamingInit(), (() => { + throw new Error("fallback must not run after open"); + }) as unknown as typeof fetch); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: { type: "invalid_token", message: "expired" } }); + }); + + test("keeps a wrapped error on the stream once output has been committed", async () => { + installFake(ws => { + ws.emit("open", {}); + ws.emit("message", { data: JSON.stringify({ type: "response.created", response: { id: "r1" } }) }); + ws.emit("message", { + data: JSON.stringify({ type: "error", status_code: 429, error: { type: "usage_limit_reached", message: "mid-turn" } }), + }); + }); + const response = await codexWsUpstreamFetch(CODEX_URL, streamingInit(), (() => { + throw new Error("fallback must not run after open"); + }) as unknown as typeof fetch); + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).toContain("event: response.created"); + expect(text).toContain("event: error"); + expect(text).toContain("mid-turn"); + }); + + test("leaves a wrapped 5xx on the stream path", async () => { + installFake(ws => { + ws.emit("open", {}); + ws.emit("message", { + data: JSON.stringify({ type: "error", status_code: 502, error: { type: "server_error", message: "bad gateway" } }), + }); + }); + const response = await codexWsUpstreamFetch(CODEX_URL, streamingInit(), (() => { + throw new Error("fallback must not run after open"); + }) as unknown as typeof fetch); + expect(response.status).toBe(200); + expect(await response.text()).toContain("event: error"); + }); + test.each(["error", "response.completed"])("multiline upstream %s JSON remains one valid SSE data value", async type => { const payload = type === "error" - ? { type, status: 400, error: { type: "invalid_request_error", message: "fixture refusal" } } + ? { type, error: { type: "invalid_request_error", message: "fixture refusal" } } : { type, response: { id: "pretty-response", status: "completed", output: [] } }; installFake(ws => { ws.emit("open", {}); From 87a525b45ff8b7499e12caa8a17d3d898c45604f Mon Sep 17 00:00:00 2001 From: Fred Amartey <43480311+FredAmartey@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:35:01 -0400 Subject: [PATCH 2/3] fix(responses): drop representation headers from the rebuilt rejection --- .../src/content/docs/reference/architecture.md | 5 ++++- .../docs/reference/configuration/providers.md | 2 +- src/server/responses/codex-ws-exchange.ts | 16 ++++++++++++---- tests/responses/ws-upstream.test.ts | 6 +++++- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/docs-site/src/content/docs/reference/architecture.md b/docs-site/src/content/docs/reference/architecture.md index 96a3aec6dc..95371d1f87 100644 --- a/docs-site/src/content/docs/reference/architecture.md +++ b/docs-site/src/content/docs/reference/architecture.md @@ -150,7 +150,10 @@ and derives its routing hint from the actual outgoing model and service tier. Initial upstream quota/model metadata becomes bounded HTTP response headers; later quota updates are attributed to the serving account, not retroactively added to headers already sent. A failure after a WS request was sent does not -trigger an automatic HTTP resend. These mappings do not enable the client-facing +trigger an automatic HTTP resend. A refusal the backend sends before any output, as one +`error` frame carrying a 4xx `status_code`, is returned as that HTTP status with the frame's +headers (minus framing and encoding headers) and a `{"error": ...}` JSON body, so the same +refresh, quota and account-rotation handling applies as on the HTTP path. These mappings do not enable the client-facing WebSocket setting or change other providers' transport selection. Bundled Bun 1.3.14, prereleases, and unverifiable runtime identities use HTTP/SSE. Successful upstream WS responses keep the downstream SSE contract and bypass `tee()` through a bounded eager diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 6152858c19..cd5c9657d6 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -126,7 +126,7 @@ predictions. Explicit provider/model price overrides still take precedence. | `upstreamHttpVersion?` | `"auto" \| "http1.1" \| "h1" \| "http2" \| "h2"` | Pin the HTTP version used for upstream requests to this provider. Defaults to `auto`, which lets Bun negotiate. An explicit pin requires an HTTPS target and fails locally when it cannot be honored. Set `http1.1` when a provider's HTTP/2 SSE stream stalls instead of delivering events — the symptom is a long-running streaming request that produces nothing and eventually times out. For Cursor, `http1.1`/`h1` selects its `RunSSE` + `BidiAppend` compatibility transport for inference and also pins live model discovery. Management `POST`/`PATCH` accept `null` to clear it back to `auto`. | | `responsesPath?` | `string` | Relative resource path for key-auth `openai-responses` requests. It must start with `/` and contain no scheme, query, or fragment. | | `allowEncryptedV2AgentTasks?` | `boolean` | Disabled by default. Trust a direct key-auth `openai-responses` provider to consume or relay opaque encrypted V2 sub-agent tasks unchanged. Eligible routes skip `agentTaskRecovery`; all other routes keep the existing recovery or fail-closed behavior. OpenCodex does not decrypt, translate, or recover tasks sent through this opt-in. | -| `upstreamWebsocket?` | `boolean` | Opt-in upstream Responses WebSocket transport for `openai-responses` requests (default false). When the upstream supports the Responses WebSocket protocol, streaming POST requests to the configured Responses path (default `/v1/responses`) are dialed as WSS over an HTTPS base URL and re-encoded to SSE for the usual pipeline. Forward providers use `{baseUrl}/responses`; key-auth providers use `responsesPath`, or the legacy `/v1/responses` fallback. This mirrors the canonical ChatGPT backend optimization for OpenAI-compatible gateways (for example sub2api) whose WebSocket ingress is measurably faster than its SSE queue. Plain HTTP remains on SSE; non-Responses paths and `openai-chat` requests stay on HTTP. A request the upstream refuses before any output, delivered as one `error` frame carrying a 4xx status (an expired token, a usage limit), is returned as that HTTP status with the frame's headers and body, so the same refresh, quota and account-rotation handling applies as on the SSE path. | +| `upstreamWebsocket?` | `boolean` | Opt-in upstream Responses WebSocket transport for `openai-responses` requests (default false). When the upstream supports the Responses WebSocket protocol, streaming POST requests to the configured Responses path (default `/v1/responses`) are dialed as WSS over an HTTPS base URL and re-encoded to SSE for the usual pipeline. Forward providers use `{baseUrl}/responses`; key-auth providers use `responsesPath`, or the legacy `/v1/responses` fallback. This mirrors the canonical ChatGPT backend optimization for OpenAI-compatible gateways (for example sub2api) whose WebSocket ingress is measurably faster than its SSE queue. Plain HTTP remains on SSE; non-Responses paths and `openai-chat` requests stay on HTTP. | | `supportsServiceTier?` | `boolean` | Tri-state canonical Fast capability fallback. `true` publishes Fast in the catalog, satisfies service-tier routing requirements, contributes a supported fingerprint, and lets fast mode inject the provider's canonical wire value on a compatible final adapter. `false` strips the field and never injects, and exact model declarations cannot reopen it. Absent leaves the provider unclassified: fast mode does not inject or normalize a canonical caller value, and caller values obey the final wire's forwarding permission (`chatServiceTier` on Chat; passthrough on Responses). The registry classifies canonical OpenAI (`true`), DeepSeek, and Volcengine Ark (`false`); set it explicitly only for custom gateways that genuinely support tiers. | | `modelSupportsServiceTier?` | `Record` | Exact upstream model capability overrides. Exact `true` enables canonical Fast for that model; exact `false` narrows provider defaults. An explicit provider-level `supportsServiceTier: false` remains fail-closed and cannot be reopened. Exact `true` does not authorize foreign caller-tier forwarding on Chat. Undeclared models fall back to provider-wide behavior. Management `PATCH /api/providers` merges entries and accepts `null` to clear one. | | `chatServiceTier?` | `boolean` | Provider-wide Chat-wire opt-in for forwarding caller `service_tier` values. On a classified route it governs foreign values such as `flex`, not proxy-owned canonical Fast after capability validation; on an unclassified route it governs every caller value because no Fast capability has been validated. Exact model capability does not authorize foreign forwarding. Responses routes retain their capability-based caller forwarding behavior. | diff --git a/src/server/responses/codex-ws-exchange.ts b/src/server/responses/codex-ws-exchange.ts index 6eab70c543..f8f0a86776 100644 --- a/src/server/responses/codex-ws-exchange.ts +++ b/src/server/responses/codex-ws-exchange.ts @@ -16,6 +16,12 @@ interface ExchangeOptions { beforeDispatch?: (headers: Headers) => void; } +// The frame's headers describe the upstream's HTTP representation. The body is +// re-encoded as plain JSON below, so the framing and encoding headers would lie. +const REBUILT_REJECTION_DROPPED_HEADERS = new Set([ + "content-encoding", "content-length", "content-type", "transfer-encoding", "connection", "keep-alive", +]); + /** * Turn a refused create frame back into the HTTP error it stands for. * @@ -30,7 +36,9 @@ interface ExchangeOptions { * pre-stream quota, refresh and rotation handlers never see the status. * * 4xx only: those refusals come before generation, so answering with the status - * cannot double-generate. 5xx keeps the stream path and its no-resend rule. + * cannot double-generate. 5xx keeps the stream path and its no-resend rule. This + * reaches only the canonical ChatGPT lane: an opt-in `upstreamWebsocket` provider + * commits its response on send, before any frame can be inspected. */ function wrappedRejectionResponse(payload: Record): Response | null { const raw = payload.status_code ?? payload.status; @@ -39,13 +47,13 @@ function wrappedRejectionResponse(payload: Record): Response | const source = payload.headers; if (source && typeof source === "object" && !Array.isArray(source)) { for (const [name, value] of Object.entries(source as Record)) { - if (typeof value !== "string") continue; - const lower = name.toLowerCase(); - if (lower === "content-length" || lower === "content-type" || lower === "transfer-encoding" || lower === "connection") continue; + if (typeof value !== "string" || REBUILT_REJECTION_DROPPED_HEADERS.has(name.toLowerCase())) continue; try { headers.set(name, value); } catch { /* an invalid upstream header name/value is not ours to relay */ } } } headers.set("content-type", "application/json"); + // The body is account-specific and rebuilt here, never a cacheable representation. + headers.set("cache-control", "no-store"); const error = payload.error && typeof payload.error === "object" && !Array.isArray(payload.error) ? payload.error : { type: "upstream_error", message: "Upstream rejected the request" }; diff --git a/tests/responses/ws-upstream.test.ts b/tests/responses/ws-upstream.test.ts index 6bfee74ad0..4aa4de0c14 100644 --- a/tests/responses/ws-upstream.test.ts +++ b/tests/responses/ws-upstream.test.ts @@ -650,6 +650,7 @@ describe("codexWsUpstreamFetch", () => { "X-Codex-Primary-Reset-At": "1800000000", "Content-Length": "174", "Content-Type": "application/json", + "Content-Encoding": "gzip", }, }; installFake(ws => { @@ -665,8 +666,11 @@ describe("codexWsUpstreamFetch", () => { expect(response.headers.get("content-type")).toBe("application/json"); expect(response.headers.get("x-codex-primary-used-percent")).toBe("100"); expect(response.headers.get("x-codex-primary-reset-at")).toBe("1800000000"); - // The body is re-encoded, so the frame's framing headers must not survive. + // The body is re-encoded as plain JSON, so the frame's framing and encoding + // headers must not survive, and the account-specific result must not be cached. expect(response.headers.get("content-length")).toBeNull(); + expect(response.headers.get("content-encoding")).toBeNull(); + expect(response.headers.get("cache-control")).toBe("no-store"); expect(await response.json()).toEqual({ error: frame.error }); expect(isCodexWsUpstreamResponse(response)).toBe(false); const ws = FakeWebSocket.instances[0]!; From cb7f561aa66730fbe2072b284858f027ef5984d9 Mon Sep 17 00:00:00 2001 From: Fred Amartey <43480311+FredAmartey@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:55:46 -0400 Subject: [PATCH 3/3] docs(responses): state the rebuilt rejection's headers --- docs-site/src/content/docs/reference/architecture.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/reference/architecture.md b/docs-site/src/content/docs/reference/architecture.md index 95371d1f87..2bf2ae1e37 100644 --- a/docs-site/src/content/docs/reference/architecture.md +++ b/docs-site/src/content/docs/reference/architecture.md @@ -151,10 +151,11 @@ Initial upstream quota/model metadata becomes bounded HTTP response headers; later quota updates are attributed to the serving account, not retroactively added to headers already sent. A failure after a WS request was sent does not trigger an automatic HTTP resend. A refusal the backend sends before any output, as one -`error` frame carrying a 4xx `status_code`, is returned as that HTTP status with the frame's -headers (minus framing and encoding headers) and a `{"error": ...}` JSON body, so the same -refresh, quota and account-rotation handling applies as on the HTTP path. These mappings do not enable the client-facing -WebSocket setting or change other providers' transport selection. +`error` frame carrying a 4xx `status_code`, is returned as that HTTP status: the frame's +headers are copied except framing and encoding ones, `content-type` is `application/json`, +`cache-control` is `no-store`, and the body is `{"error": ...}` JSON, so the same refresh, +quota and account-rotation handling applies as on the HTTP path. These mappings do not enable +the client-facing WebSocket setting or change other providers' transport selection. Bundled Bun 1.3.14, prereleases, and unverifiable runtime identities use HTTP/SSE. Successful upstream WS responses keep the downstream SSE contract and bypass `tee()` through a bounded eager single-reader relay (4 MiB per raw/enveloped frame and an 8 MiB producer queue). Queue overflow