Skip to content

perf: route ChatGPT Codex upstream turns over responses_websockets - #1558

Merged
Wibias merged 2 commits into
devfrom
rebase/1487-ws-upstream
Aug 12, 2026
Merged

perf: route ChatGPT Codex upstream turns over responses_websockets#1558
Wibias merged 2 commits into
devfrom
rebase/1487-ws-upstream

Conversation

@Wibias

@Wibias Wibias commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Maintainer-rebased replacement for #1487 from @kargnas. The contributor fork could not accept the rebased Git object through the GitHub integration after branch synchronization, so this upstream branch preserves the exact reviewed change on current dev.

Problem

Requests proxied through opencodex to the ChatGPT Codex backend consistently showed 2–3s worse TTFT than the same requests made by Codex CLI directly — even with the same account, same payload, and strictly sequential execution.

Root cause: Codex CLI talks to chatgpt.com/backend-api/codex/responses over the responses_websockets transport, while opencodex always POSTs SSE. The backend serves the WS path from a measurably faster queue.

Measurements (2026-08-12, same account, same payload, sequential, alternating order)

transport gpt-5.6-luna TTFT p50 mean
WS (responses_websockets) 1037 ms 1212 ms
SSE (POST, stream:true) 3897 ms 3686 ms

Event timeline shows the gap is upstream scheduling, not transfer: both paths reach response.created in ~0.5s, but SSE then waits 2.4–4.0s before response.output_item.added (WS: 0.8–1.3s).

Ruled out: HTTP/2 vs 1.1 (no change), OpenAI-Beta: responses_websockets header on the SSE POST (no change), session_id/prompt_cache_key (no change), warmup effects (no decay over sequential repeats), account differences (A/B with identical account).

A second finding: the fast lane keys on WS + originator tag, not the transport alone — 60KB turns run ~1.5s with originator: codex_cli_rs vs ~4.6s without. The patch defaults the header for callers that don't send one (Codex CLI always does).

Change

  • New src/server/responses/ws-upstream.ts: for streaming POSTs to the Codex backend, dial wss:// with the same headers, send the JSON body as a single response.create frame, and re-encode returned event frames as an SSE byte stream — so the passthrough relay, adapter parsers, and usage sniffing are all unchanged.
  • providerFetch() in fetch-helpers.ts wraps the provider fetch with this transport swap. Everything that isn't a Codex-backend streaming turn keeps the exact HTTP path.
  • Fallbacks: upgrade rejected (401/403/429/5xx) → retry over plain SSE so the real HTTP status reaches the existing refresh/rotation handlers; no 101 within 10s → SSE; frame-send failure → stream error into the caller's normal transport-retry path. WS-only frames (codex.rate_limits, responsesapi.websocket_timing) are dropped so clients see exactly the stream shape they always got.

After patching (local proxy vs direct Codex CLI)

target Luna TTFT p50 Terra TTFT p50
codex CLI direct (WS) 1196 ms 1426 ms
opencodex before 4385–4705 ms 2951–4431 ms
opencodex after 1424 ms 1577 ms

Also verified: tool-call round-trips (function_call arguments relay), 630KB / 900KB / 1.26MB / 1.5MB input frames, context_length_exceeded and server_is_overloaded error relay, and ~600 live requests through the patched proxy with no new failure modes.

Known trade-off

Bun's WebSocket does not expose the 101 response headers, so x-codex-*-reset-at quota hints are not visible on this path. The periodic quota poller still covers quota tracking. If there's a preferred way to surface those, happy to adjust.

Related: #1217 (stream-stage timing) would make this kind of transport gap visible in the dashboard.

Summary by CodeRabbit

  • New Features

    • Added WebSocket-based streaming for eligible Codex responses.
    • Converts WebSocket messages into the existing streaming response format.
    • Preserves HTTP streaming when requests are ineligible or WebSocket connectivity is unavailable.
    • Supports request cancellation and clean stream termination.
  • Bug Fixes

    • Improved resilience with fallback handling for invalid requests, connection failures, and upgrade timeouts.

The ChatGPT Codex backend serves the responses_websockets (WS) path from
a measurably faster queue than the plain SSE POST path. Measured
2026-08-12 KST (same account, same payload, strictly sequential):
gpt-5.6-luna TTFT p50 ~1.0s over WS vs ~3.9s over SSE. Codex CLI itself
defaults to WS, so requests through opencodex carried an extra 2-3s of
TTFT that direct Codex CLI usage did not.

Wrap providerFetch() so that streaming POSTs to
chatgpt.com/backend-api/codex/responses dial wss:// instead: the JSON
body goes out as a single response.create frame and returned event
frames are re-encoded as an SSE byte stream, leaving every downstream
consumer (passthrough relay, adapter parsers, usage sniffing) unchanged.

Transport selection parses the body and requires a root-level
stream === true, so nested {"metadata":{"stream":true}} or formatted
JSON cannot misroute. Failure handling: upgrade rejection, a missing
101 within 10s, and a synchronous frame-send failure all fall back to
the existing SSE path (in every case no upstream turn has started, so
the resend cannot double-generate); a socket drop after open but before
a Responses terminal event errors the stream so relaySseWithFailedTail
synthesizes a response.failed terminal instead of a terminal-less clean
EOF. Caller metadata is preserved verbatim — no originator is invented
for callers that did not send one.

After patching, local benchmarks put opencodex within ~0.2s of direct
Codex CLI (Luna TTFT p50 4364ms -> 1424ms; Terra 3322ms -> 1417ms).
Verified tool-call round-trips, 630KB-1.5MB frames, context_length
error relay, and ~600 live requests with no new failure modes.

Known trade-off: Bun's WebSocket does not expose the 101 response
headers, so x-codex-*-reset-at quota hints are not visible on this
path; the periodic quota poller still covers quota tracking.

Regression tests cover root-level stream detection, providerFetch()
routing, frame relay (including WS-only frame dropping), SSE fallback
on upgrade rejection and send failure, mid-stream drop through the
passthrough relay (synthesized failed terminal), header preservation
without originator fabrication, and pre-open abort.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 510786b4-8baa-459d-a1e3-3e8c20f977f6

📥 Commits

Reviewing files that changed from the base of the PR and between 6f2be35 and bf58000.

📒 Files selected for processing (1)
  • tests/ws-upstream.test.ts

📝 Walkthrough

Walkthrough

Changes

Codex WebSocket transport

Layer / File(s) Summary
Routing and request frame preparation
src/server/responses/ws-upstream.ts, src/server/responses/fetch-helpers.ts, tests/ws-upstream.test.ts
Eligible Codex streaming POST requests use the WebSocket adapter. The adapter builds a response.create frame, filters headers, and preserves caller metadata.
Connection setup and HTTP fallback
src/server/responses/ws-upstream.ts, tests/ws-upstream.test.ts
The adapter handles connection setup, aborts, timeouts, send failures, and fallback to HTTP SSE.
SSE relay and stream termination
src/server/responses/ws-upstream.ts, tests/ws-upstream.test.ts
WebSocket events are translated to SSE. Unsupported events are filtered, terminal events close the stream, and premature closure reports failure.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant providerFetch
  participant codexWsUpstreamFetch
  participant WebSocket
  participant SSEClient
  providerFetch->>codexWsUpstreamFetch: Route eligible Codex streaming request
  codexWsUpstreamFetch->>WebSocket: Open and send response.create
  WebSocket-->>codexWsUpstreamFetch: Emit Responses event
  codexWsUpstreamFetch->>SSEClient: Return SSE event
  WebSocket-->>codexWsUpstreamFetch: Emit terminal event
  codexWsUpstreamFetch->>SSEClient: Close SSE stream
Loading

Possibly related PRs

Suggested reviewers: ingwannu, lidge-jun

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: routing ChatGPT Codex upstream turns through the responses_websockets transport.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch rebase/1487-ws-upstream

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the enhancement New feature or request label Aug 12, 2026
@Wibias
Wibias requested review from Ingwannu and lidge-jun August 12, 2026 20:26
@Wibias
Wibias marked this pull request as ready for review August 12, 2026 20:26

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/ws-upstream.test.ts`:
- Around line 178-190: Add a focused test beside the existing rejected-upgrade
test for a WebSocket that emits neither open nor close. Use fake timers, invoke
codexWsUpstreamFetch with the existing streamingInit and fallback setup, advance
timers by UPGRADE_DEADLINE_MS, then assert the fallback is called exactly once
and the socket is closed.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6cf56eae-7841-4015-b97a-6be5269e7209

📥 Commits

Reviewing files that changed from the base of the PR and between cfc61c1 and 6f2be35.

📒 Files selected for processing (3)
  • src/server/responses/fetch-helpers.ts
  • src/server/responses/ws-upstream.ts
  • tests/ws-upstream.test.ts

Comment thread tests/ws-upstream.test.ts
@Wibias
Wibias merged commit 4f4a72a into dev Aug 12, 2026
43 of 45 checks passed

Wibias commented Aug 12, 2026

Copy link
Copy Markdown
Owner Author

Merged. Big thanks to @kargnas for the original investigation and implementation in #1487, including the transport measurements that found the real source of the proxy latency gap.

This is a meaningful performance win: eligible ChatGPT Codex streaming turns can now use responses_websockets, bringing OpenCodex TTFT much closer to direct Codex CLI instead of paying the previous multi-second SSE scheduling penalty. Just as importantly, the existing downstream SSE shape stays intact, with explicit fallback and failure handling, so we get the latency improvement without forcing adapters or clients onto a new protocol surface.

The added regression coverage around routing, upgrade timeout, send failure, and premature socket close makes the faster path much safer to keep as the default transport optimisation. Thanks again @kargnas for the groundwork that made this possible.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant