Skip to content

Add Codex WebSocket support - #521

Draft
thinkter wants to merge 21 commits into
vercel-labs:mainfrom
thinkter:feat/codex-websocket-phase-2
Draft

Add Codex WebSocket support#521
thinkter wants to merge 21 commits into
vercel-labs:mainfrom
thinkter:feat/codex-websocket-phase-2

Conversation

@thinkter

@thinkter thinkter commented Aug 29, 2026

Copy link
Copy Markdown

Summary

  • isolate the Codex WebSocket lifecycle in openai_codex_websocket.zig, leaving shared Responses serialization in the base provider
  • retain healthy connections across compatible turns and send incremental input with previous_response_id only when serialized history proves an exact safe extension
  • recover once with full context when the backend reports previous_response_not_found
  • preserve strict binary, masking, UTF-8, fragmentation, close-handshake, cancellation, connect-timeout, and idle-event handling
  • use the current responses_websockets=2026-02-06 beta protocol header
  • fall back to SSE only after a definitely-unsent setup failure, then latch the process to SSE so a broken upgrade path is paid for once
  • never retry or fall back after ambiguous delivery, preventing duplicate model requests and billing
  • perform ping, connect, and close outside the global pool mutex
  • use temporary unpooled connections instead of polling when retained lanes are saturated
  • bound retained storage globally with LRU identity eviction through FX_CODEX_WEBSOCKET_MAX_SLOTS, in addition to the per-identity FX_CODEX_WEBSOCKET_MAX_LANES limit
  • keep invalid FX_CODEX_TRANSPORT values as configuration errors rather than silently coercing them to SSE
  • remove the development-only Python probe and document the verified comparison and implementation plan

Local verification

  • zig fmt --check src/
  • zig build
  • all 13 focused Zig Codex WebSocket tests
  • focused built-binary E2Es for retained reuse, reconnect-before-delivery, continuation recovery, maximum age, fallback latching, and ambiguous-delivery no-replay
  • stderr remained clean on successful built-binary interactions

Full CI is required on exact commit 18229c8f720980e836b8a873300c1d7bae733756 before readiness. GitHub currently marks the fork-origin workflow runs as action_required; a vercel-labs/fx maintainer must approve them.

@thinkter thinkter changed the title Retain Codex WebSocket sessions Add Codex WebSocket continuation Aug 29, 2026
@thinkter thinkter changed the title Add Codex WebSocket continuation Add Codex WebSocket support Aug 29, 2026
@mjlbach

mjlbach commented Aug 29, 2026

Copy link
Copy Markdown

Hey! I was working on this too. I didn't notice your PR before pushing, but if you want to take anything from my branch (I think there are a couple bugs that I'm happy to point out, but if you ask fable/sol to diff your work against mine they should find it)

#523

@thinkter
thinkter force-pushed the feat/codex-websocket-phase-2 branch from 05bf5a4 to 18229c8 Compare August 29, 2026 20:34
@thinkter

Copy link
Copy Markdown
Author

hey @mjlbach!
no worries!

I already had my agent look into your implementation.
Just pushed some more code.
Feel free to point out bugs, it's always helpful!

I want to harden this implementation as much as I can

@mjlbach mjlbach left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings from diffing this against #523, inline below. One note with no code anchor: the listed verification is loopback-only — previous_response_id over a reused socket, pings mid-stream, and the close handshake are the parts a fixture can't validate, so worth a live run against chatgpt.com before marking ready.

// deliberately outside the global pool mutex.
if (displaced) |connection| websocket_transport.close(connection, pool_alloc);
if (reusable) |connection| {
websocket_transport.ping(connection, args.cancel_flag, args.deadline, args.delivery) catch |err| {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Retained slots have an age limit but no idle TTL. After the session idles past a NAT/proxy timeout the socket is silently dead: this ping's write succeeds, the pong read blocks, and the turn stalls until the event-idle watcher fires (default 30s) before reconnecting. An idle TTL (~5 min, matching codex-rs and pi) avoids that, and with it this per-reuse ping — a blocking RTT on every reused turn — could probably go too, or at least get its own short deadline (~2s) instead of the 30s idle timeout.

slots = .empty;
pool_mutex.unlock(io_mod.getIo());
var owned = retired;
for (owned.items) |*slot| slot.deinit();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

shutdown() deinits busy slots too, closing and freeing a Connection a streaming thread may still hold. Related: the two relock sites in acquire (the ping-failure path and the post-connect commit) index slots.items[slot_index] without the length check that release/continuation/rollbackReservation do — after this swap that's an out-of-bounds index. Skipping busy slots here (release already closes the connection when index >= slots.items.len) and adding the bounds checks covers both.

prior_health = slot.health_failures;
const expired = age_limit != 0 and
io_mod.milliTimestamp() - slot.opened_at_ms > age_limit;
if (slot.connection != null and slot.health_failures < health_budget and !expired) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When can health_failures < health_budget be false here? Both increment paths (release(.failed), the ping failure below) also null slot.connection, and release(.completed) resets to 0 — so an idle slot with a live connection always has 0 failures. If it's future-proofing, fine; if it's meant to poison a flapping lane it doesn't currently do that.

return error.InvalidOpenAICodexTransport;
}

fn allowsSseFallback(err: anyerror, delivery: gateway_client.DeliveryCertainty.State) bool {

@mjlbach mjlbach Aug 30, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

acquire's entry check returns error.Timeout when the request deadline already expired, with delivery still definitely_unsent — so a turn that just ran out of time latches the whole process to SSE. Same when a short request deadline bounds the connect instead of the transport's own timeout. Worth latching only when the transport's own connect timeout fired.

) !void {
try writeFrame(writer, .close, &.{ 0x03, 0xe8 });
try connection.flush();
const frame = readFrame(alloc, reader) catch |err| {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This reads exactly one frame, so a server ping or trailing data frame between our close and its close reply returns WebSocketProtocolViolation. close() swallows it, but the one-shot stream() propagates it — failing a turn whose response was already fully consumed. Looping until the close frame arrives (answering pings, discarding data) matches RFC 6455 here.

Comment thread docs/codex-websocket-plan.md Outdated
@@ -0,0 +1,128 @@
# Codex WebSocket transport: plan to finish PR #521

@mjlbach mjlbach Aug 30, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Seems like AI coding agent leakage :) You should remove this from the PR

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants