Skip to content

OpenAI WebSocket Transport

A tool-calling run sends many short requests. Over HTTP each one is a separate exchange that carries the whole conversation. OpenAI's Responses API can also be reached over one WebSocket: the requests of a run share a connection, and while the conversation is only growing, each request sends just its new items and names the response it continues from.

The toolkit can run the OpenAI tool loop over that socket (0.5.2). It is opt-in. HTTP stays the default on every model and every service tier.

agent = Agent(model="gpt-6-astra", tools=[lookup], openai_transport="websocket")
result = await agent.run("Find the three invoices and total them")

Nothing else changes in your code, and nothing changes in the result: the same output, history, cost records, events, hooks and retry fields as over HTTP. The few places where the two transports can be told apart are listed under What differs from HTTP.

When it helps

It helps a run that makes several requests: an agent that calls tools in a loop. It does not help a run of one or two requests, which is slower over a socket because the socket takes one to two seconds to open and HTTP connections are already pooled.

Measured through Agent on 2026-10-02 and 2026-10-03, a tool loop of nine requests (eight tool calls, then a one-word answer), wall-clock seconds for each run:

Model, tier HTTP WebSocket
gpt-5.6-luna, Standard 15.9, 12.7, 12.0 11.9, 11.3, 11.9
gpt-5.6-luna, Standard, a 48,000-token document in context 19.1, 17.6 12.0, 18.3
gpt-6-astra, Fast (service_tier="priority") 18.1, 16.4 20.8, 11.9
gpt-6-astra, Ultrafast 17.3 11.8

Read these as a handful of noisy runs, not as a guarantee. Of the eight HTTP and WebSocket pairs run back to back, the socket was faster in five (by 11% to 37%), level in one and slower in two (by 4% and 15%). The figures measured with the OpenAI SDK alone during planning were steadier and a little better: about 30% less time on the same loop at Ultrafast (14.5 s to about 10 s), and between 15% and 45% on Fast and on both Luna rows.

What the measurements say:

  • The gain is per request. Each request saves a few tenths of a second of connection overhead, so the saving grows with the number of requests and is lost on a short run to the time the socket takes to open (1.3 to 2.0 s measured).
  • It is what makes Ultrafast worth its price in a tool loop. Ultrafast generates text several times faster, but a tool loop spends most of its time on round trips, not on generating text: over HTTP the nine-request loop took about as long on Ultrafast as on Fast.
  • Billing is identical. Input tokens, cached tokens, the served tier and the cost were the same on both transports in every pair. A request that sends only its new items is still billed for the whole conversation it continues.

Switching it on

Three ways, in order of precedence:

# 1. Per agent
agent = Agent(model="gpt-6-astra", tools=[lookup], openai_transport="websocket")
# 2. In a YAML profile, as a provider override
model: gpt-6-astra
provider_overrides:
  openai_transport: websocket
# 3. As the process default
DPT_OPENAI_TRANSPORT=websocket

The keyword wins over the environment variable, so openai_transport="http" keeps one agent on HTTP in a process that defaults to the socket. Both take http or websocket, case-insensitive. Any other value is ignored with one warning.

The socket needs the websockets package, which the openai SDK's realtime extra installs and which google-genai already depends on. The release was verified on websockets 16.0, the version google-genai brings in (the SDK extra declares <16; 0.5.2 was not run on a 15.x). If it cannot be imported, the run logs one warning and uses HTTP.

What it applies to

  • The tool loop only. That is a run with local tools (functions or MCP servers the toolkit executes), through Agent.run, Agent.stream, sessions and the sync wrapper. A run with no local tools sends one request and stays on HTTP, with or without the switch.
  • OpenAI models only. The keyword is an OpenAI provider keyword. On an agent for another provider it is reported as unrecognised and ignored.
  • One socket at a time. A socket is opened at the run's first request and the run's last one is closed when the run ends, however it ends. Within a run the socket can be replaced — one found closed is reopened, one 55 minutes old is replaced, the two resends in Failures go out on a new one, and a retried attempt opens its own once the failed attempt has closed its socket — but no two are open at once, and none is kept open between runs or between the turns of a session, so every run pays the time to open one.

What a request carries

The request body is the HTTP body. While the conversation before the new items is unchanged since the previous response, the request sends only the new items with previous_response_id. Otherwise it sends the whole conversation, as HTTP does.

"Unchanged" is checked with a fingerprint of the conversation, not by the code that changed it. So compaction, tool-result clearing, a hook that rewrites history and anything else that edits earlier items all fall back to the whole conversation by themselves, on that request, and the chain starts again from the response it gets.

Failures

A caller cannot tell the transports apart by how a failure is retried, reported or billed. Each failure on the socket is raised as the error the HTTP path raises for the same cause, so the one retry layer classifies it by the rules it already has. The SDK's own reconnect is left off.

Failure What happens Retried? Billed
The socket cannot be opened (network error, timeout) Raised as the connection error an HTTP connect failure raises Yes Nothing
The handshake is refused with an HTTP status Raised as the SDK's error for that status, with the server's message 401 and 403 fail at once; 429 and 5xx are retried Nothing
The request is rejected: an error event with a status, before the response starts Raised as the SDK's error for that status, as HTTP raises it 400 and 404 fail at once; 429 and 5xx are retried. Headers the event carries (retry-after, x-should-retry) are honoured as on HTTP; the 400 and 404 events seen live carried none, and without one the wait is the exponential one Nothing
The server no longer holds the previous response (previous_response_not_found) The same request is sent once more with the whole conversation, on a new socket. With emit_request_start=True a second request_start event fires before the resend (same iteration, purpose and attempt, the next request_index) No: it happens inside the run and is not counted as a retry Nothing for the rejected send
response.failed, or an error event after the response started Classified as on HTTP (incomplete and failed streams) server_error, rate limits and timeouts: yes. A fault in the request: no The partial reply by estimate
The reply is cut off at max_tokens (response.incomplete) The loop raises the limit and asks again on the same socket, up to three times, as on HTTP Inside the run Exact usage, each request
The socket drops or is closed while a response is in flight, or no event arrives within the idle limit One retryable error Yes: a new attempt on a new socket, from the whole conversation. Tools the attempt had run are run again, exactly as after an HTTP failure mid-stream Completed requests at their real cost; the one in flight by estimate
The socket is found closed when the next request is due (after a slow tool, or closed by the server) Reopened once, and the request goes out with the whole conversation. Nothing was in flight, so nothing is repeated No Nothing extra
The socket is 55 minutes old (OpenAI ends it at 60) Replaced before the next request, as in the row above No Nothing extra
An error event that states no status, before the response starts Classified as the same event is over HTTP: by its code, then its type, then its wording (incomplete and failed streams) A transient failure: yes. A fault in the request: no As over HTTP
OpenAI reports the 60-minute limit (websocket_connection_limit_reached, an error event with status 400) before the response starts The request goes out once more with the whole conversation, on a new socket, announced by a second request_start event as in the previous_response_not_found row. It is not treated as a rejected request. A new socket that reports the limit again is one retryable connection error No (the second case: yes) Nothing
The same limit reported while a response is in flight (a request sent near minute 55 that runs past minute 60) One retryable error, as for a socket that dropped Yes, as in the row for a dropped socket As for a dropped socket
Retries are exhausted The same failed result as on HTTP The same retry_count, retry_cost and retry_details
The stream is closed early, or a hook aborts the run The socket is closed. What was spent is reported as on HTTP As on HTTP

Three rules behind the table:

  • After an error event the socket is not used again. The server closes the socket after some rejected requests and not after others, so the toolkit does not guess.
  • The idle limit is the client's read timeout, the same value HTTP uses (7,200 seconds by default), so a slow model is not cut off on a socket where HTTP would wait. A dead peer is found much sooner by the socket's own keepalive: a ping every 20 seconds, 20 seconds to answer. The other side of that: a network stall of more than about 40 seconds ends the response in flight and the run is retried, where an HTTP stream would have gone on waiting.
  • A lost socket is reported at once. Dropped ten times in a row while a response was in flight, the loss surfaced within a millisecond every time; a clean close surfaced in about 0.2 seconds. A socket left idle for 75 seconds between two requests was still usable.

What differs from HTTP

The results are the same. These details are not:

  • The text of a status error. It has the HTTP form (Error code: 400 - {...}), built from the error event, so the quoted body can differ in key order and in the keys it lists. request_id is set only when the event or the refused handshake carries an x-request-id header.
  • A socket that cannot be opened in time is a connection error, where an HTTP connect timeout is a timeout error. Both are retried and neither is billed.
  • A stalled network ends a response in flight after about 40 seconds (the keepalive above).
  • The 60-minute limit exists only on a socket; the two rows of the table above cover it.

The response the loop reads is put through the same SDK parsing as the HTTP stream's, so the history a run returns, and a session stores, does not depend on the transport. A session can move between transports from one turn to the next.

What the logs say

At INFO, on the agent's logger:

OpenAI transport for this run: WebSocket (gpt-6-astra)
OpenAI WebSocket opened in 1.45s (socket #1 of this attempt)
Closing the OpenAI WebSocket: 1 socket(s) opened, 1 request(s) with the full history, 8 with only the new items.

Four more lines appear when something is replaced without a retry:

OpenAI WebSocket was closed before the next request (...); reopening it and sending the request with the full history.
OpenAI WebSocket is older than 55 minutes (the server closes it at 60); replacing it before the next request, which carries the full history.
OpenAI no longer holds the previous response; sending the request again with the full history (not a retry).
OpenAI WebSocket reached its 60-minute limit before the response started; reopening it and sending the request again with the full history (not a retry).

A retried attempt logs what a retried HTTP attempt logs, followed by a new opened line: every attempt has its own socket. More than one socket in the closing line of an attempt that was not retried means one of the four lines above was logged.

Limits

  • One socket at a time, opened in one to two seconds (a run opens another only when the first is closed, replaced or lost). Runs of one or two requests are faster on HTTP.
  • A socket lives at most 60 minutes; the toolkit replaces one at 55.
  • The largest single event accepted is 64 MB.
  • The tool loop of OpenAI models only. The single-request path (no local tools), the other providers and the realtime sessions are not affected by the switch.
  • A socket is not shared between runs, session turns or parallel agents. Keeping one open across the turns of a session is future work.