Skip to content

feat: websocket transport and session chaining for OpenAIResponsesModel (#65) - #66

Open
acoliver wants to merge 25 commits into
janfeddersen-wq:mainfrom
acoliver:feature/issue-65-open-responses
Open

acoliver wants to merge 25 commits into
janfeddersen-wq:mainfrom
acoliver:feature/issue-65-open-responses

Conversation

@acoliver

@acoliver acoliver commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Implements the client side of #65 on the existing OpenAIResponsesModel in serdes-ai-models. It adds a WebSocket transport, wire-accurate request building, and opt-in session chaining on both transports. There is no new published crate; the protocol test rig lives under serdes-ai-models/tests/rig/.

Closes #65. Fixes #75.

Architecture and behavior

HTTP and WebSocket requests share authentication and header construction. Constructor API keys and organization/project settings reach both transports. Explicit headers override defaults case-insensitively, including Codex bearer credentials. HTTP modes share their POST construction rather than maintaining separate header paths.

Both transports use shared event decoding. Unknown event types are ignored; malformed known events error. Streaming preserves terminal events and errors under backpressure. A [DONE] sentinel without response.completed is an error. Provider envelopes retain their wire error codes, and committed streams are not replayed on failure.

Session lookup fingerprints the initial history prefix through its first non-system request, including request kind. Distinct user conversations sharing leading system prompts no longer share one session. Full sent-prefix validation remains mandatory: mutated or identical histories reset the chain, while continuations send only new input with previous_response_id. WebSocket turns send store: false; chained HTTP sends store: true.

Content-based lookup cannot distinguish independent conversations with identical initial prefixes. Those require separately constructed model instances, not clones. Idle eviction is lazy at lookup, uses completion-based timestamps, and preserves reserved conversation handles. Conversation state owns socket cleanup; best-effort clean Close on drop requires an active Tokio runtime.

Request conversion joins system instructions, emits tagged function tools and function_call_output items, and preserves encrypted reasoning. Unsupported media input errors instead of silently disappearing. The rig preserves instruction ordering and reasoning metadata, rejects unknown tool call IDs, and uses bounded, cancellable event delivery. Input queue overflow cancels stalled work rather than blocking disconnect handling.

The codex_haiku example defaults to OPENAI_API_KEY and api.openai.com; Codex remains opt-in through --codex or CODEX=1. Its cache uses private replacement files and expiry-aware reuse. Identified authentication rejection invalidates the rejected cached token without replaying output or tool effects. Argument parsing and browser launching now handle platform differences explicitly.

API and feature changes

  • WebSocket support: responses-ws in serdes-ai-models, exposed as openai-responses-ws through the facade; select with with_transport(Transport::WebSocket).
  • Chaining is opt-in with with_session_chaining(true); idle TTL is configurable through with_conversation_idle_ttl.
  • ResponsesApiRequest.input uses wire InputItems; the former ResponseInput/ResponseInputContent/ResponseInputPart types are removed.
  • The unpublished parallel serdes-ai-responses crate and facade open-responses feature are removed. No additional publish tier is needed.

Verification at 0452ce7

Check Result
Formatting Passed
Workspace Clippy, all features/targets, warnings denied Passed
Workspace tests, all features 1,804 passed, 0 failed, 128 ignored doctests
Focused Responses HTTP/WebSocket/rig suites 90 passed
Codex example tests, run separately 9 passed
Streaming crate 63 passed

Feature checks also cover HTTP-only, WebSocket, and no-provider builds. The existing no-provider unused-import warning is tracked in #74. An intermittent unchanged CLI PTY test failure is tracked in #76; the final full workspace run passed.

The findings-only OCR follow-up covered all 16 changed files without skipped or failed files. Its two retained findings were subsequently fixed and regression-tested; no further review cycle was run. Current verification uses local protocol servers, not live API credentials.

@acoliver
acoliver marked this pull request as draft August 26, 2026 16:54
@acoliver acoliver changed the title Serve models over the OpenAI Responses API: Open Responses protocol, codex endpoint, WebSocket transport, stateful sessions feat: OpenResponsesModel client for the Responses protocol (#65) Aug 26, 2026
@acoliver
acoliver marked this pull request as ready for review August 27, 2026 14:55
@acoliver acoliver changed the title feat: OpenResponsesModel client for the Responses protocol (#65) feat: OpenResponsesModel client for the Responses (WebSockets) protocol (#65) Aug 27, 2026
@acoliver
acoliver marked this pull request as draft August 27, 2026 15:53
@acoliver
acoliver marked this pull request as ready for review August 27, 2026 15:53

@janfeddersen-wq janfeddersen-wq left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks for this — the transport work here is careful (the header fix in serdes-ai-streaming, the WS/SSE parsing, the terminal-event discipline and the fake-server tests are all good). I ran it locally and everything you claim checks out: fmt clean, clippy clean under -D warnings, 48/48 tests in serdes-ai-responses, 63/63 in serdes-ai-streaming.

The reason I'm requesting changes is the overall shape rather than the code quality: conversation state stored inside the Model instance breaks the framework's stateless Model contract, and the crate duplicates a Responses API implementation we already have. Details below, with what I'd suggest instead.

Blocking

1. Duplicates OpenAIResponsesModel. serdes-ai-models/src/openai/responses.rs already ships OpenAIResponsesModel with ResponsesApiRequest, ResponseOutputItem and previous_response_id support. This PR adds a parallel crate with ~1,800 lines of Responses API types and conversion in types.rs / convert.rs. Issue #65 explicitly asked for "reuse, not duplication". The shape I'd like to see is a WebSocket transport plus an opt-in session-chaining mode added to the existing OpenAIResponsesModel, not a second model in a second crate.

2. A second conversation on the same model is silently chained onto the first, with its input dropped. Session (serdes-ai-responses/src/client/mod.rs:78-84) stores only previous_response_id and sent_requests: usize, and continuation_skip (mod.rs:263-274) skips by count without checking that the prefix matches what was actually sent. Agent::run starts every run with fresh history, so agent.run("A") followed by agent.run("B") sends B as {previous_response_id: resp_A, input: []}. I reproduced this against a scripted fake server: the second frame had previous_response_id = Some("resp_A") and zero input items. The unit test an_empty_turn_serializes_input_as_a_list (mod.rs:885-905) codifies this failure mode. Fix: fingerprint the sent prefix (e.g. hash each ModelRequest) and reset the chain on mismatch, or make chaining opt-in per conversation rather than per model instance.

3. Whole-turn session lock serialises all concurrent runs. run_ws_turn holds inner.session.lock() for the entire turn (mod.rs:283), and the task spawned by request_stream holds it until the consumer drains the channel (mod.rs:590-620). One shared model therefore means one conversation at a time, which conflicts with the orchestrator / a2a fan-out that just landed on main. Same root cause as (2).

4. Release breaks. serdes-ai/Cargo.toml adds open-responses to the full feature, but .github/workflows/publish.yml has no tier for serdes-ai-responses, so cargo publish -p serdes-ai would fail on an unpublished dependency.

5. Needs a rebase onto main. #64 just merged and this branch conflicts in Cargo.toml (workspace members / workspace deps) and Cargo.lock. Both trivial. After resolving locally, clippy is clean and the tests pass under edition 2024, but cargo fmt --check fails with ~25 hunks from the 2024 import ordering, so run cargo fmt after rebasing.

Non-blocking

  • Docs contradict the code: lib.rs:8, the crate README "Transports" section and CHANGELOG.md all describe {"type":"response.create","response":{…}}, but the code sends flat frames (mod.rs:60-67), which your PR body says was the live fix.
  • Root README.md: a ✅ in the provider table was replaced with [OK] (around line 315).
  • error.rs:1 still says "Error types for the Open Responses server", and server-flavoured status() codes ship in the default build.
  • Cargo.toml: tokio-tungstenite = "0.21" and tokio-stream = "0.1" bypass the workspace deps; reqwest is listed in both deps and dev-deps.
  • Dependency weight: the default build pulls rustls 0.22 via tokio-tungstenite 0.21 while workspace reqwest uses rustls 0.23, so serdes-ai --features full now links rustls 0.21, 0.22 and 0.23. axum / tower are correctly confined to test-server (checked with cargo tree). The rig (server.rs, store.rs, engine.rs, websocket.rs, ~1,700 lines) is excluded from the default build, but a full server inside a client crate is a lot; a tests/rig/ module would be cleaner.
  • HTTP stream: a [DONE] without a prior response.completed returns Ok(()) with no StreamComplete (mod.rs around line 810), violating the "exactly one terminal event" contract. HTTP errors map to ModelError::http while WS errors map to ModelError::provider; pick one.
  • Builder methods panic via Arc::get_mut().expect(...) (mod.rs:191-230), which is unusual for this codebase.
  • examples/codex_haiku.rs hardcodes wss://chatgpt.com/backend-api/codex/responses (:35), the OpenAI-Beta / originator headers (:230-232), a macOS-only open (:80) and a plaintext token cache (:46-51). I'd trim the published example to OPENAI_API_KEY against wss://api.openai.com/v1/responses and leave the codex path behind an env var.

Suggested path forward

Keep the serdes-ai-streaming header/TLS fix as its own small PR (that one is ready to merge on its own). Then bring the WebSocket transport and stateful-session mode into OpenAIResponsesModel in serdes-ai-models, with chaining keyed to the actual conversation rather than the model instance. Happy to review that quickly.

@acoliver

acoliver commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for running it locally, and for the detailed review. You're right on all five blocking points. I confirmed each against the trees, including your chaining repro: continuation_skip skips by count with no identity check, so a second agent.run on the same model instance chains onto the first conversation with an empty input list. That's a real correctness bug in the session design, and #3 shares its root cause (conversation state living in the Model instance).

Plan, following your suggested path:

  1. serdes-ai-streaming first. I'm splitting the upgrade-request header fix and the TLS-in-feature fix onto their own branch off main and opening that PR separately. Nothing else rides along.

  2. Consolidation into OpenAIResponsesModel. Agreed. Issue Client support for the OpenAI Responses protocol: codex endpoint, WebSocket transport, and session-stateful mode (state in the websocket session) #65 asked for reuse and the second crate drifted from that. I'll move the WebSocket transport and the session-chaining mode into serdes-ai-models/src/openai/responses.rs, collapsing types.rs/convert.rs onto the existing ResponsesApiRequest/ResponseOutputItem types, and retire the parallel crate. The rig becomes test support so the wire-accurate fake keeps covering the client, and codex_haiku becomes an example of serdes-ai-models.

  3. Conversation-keyed chaining. I'll fingerprint each ModelRequest (hash of the serialized parts) and track the sent prefix per session. On prefix mismatch the chain resets and the full input is sent. Per-conversation state keyed by that fingerprint replaces the single Session, which also removes the whole-turn lock serializing concurrent conversations. I went with fingerprinting rather than an opt-in conversation handle because it keeps the Model trait stateless-callable, so the agent loop and every existing caller stay unchanged; if you'd rather force the handle API, say so before the restructure lands.

  4. Release safety in the interim. Until the consolidation lands I'll drop open-responses from the full feature so cargo publish -p serdes-ai can't hit an unpublished dependency. The consolidated version needs no new publish tier since no new crate ships.

  5. Rebase + fmt. Rebasing onto main now (Cargo.toml/Cargo.lock conflicts as you found) and running cargo fmt for the edition 2024 import ordering, so the restructure starts from a clean base.

Non-blocking items: the flat-frame doc contradictions, the README ✅, the error.rs server wording, and the workspace-deps bypasses (tokio-tungstenite 0.21, tokio-stream, double reqwest) get fixed on this branch in the same pass. The example trim (OPENAI_API_KEY default, codex endpoint and headers behind an env var), the HTTP [DONE]-without-terminal gap, and the ModelError::http vs provider inconsistency I'll fold into the consolidation, since those files move. Happy to pull any of those forward onto this branch first if you'd rather see them earlier.

acoliver added a commit to acoliver/serdesAI that referenced this pull request Sep 7, 2026
…feddersen-wq#66)

rustfmt's edition 2024 style rules reordered imports and calls across
the crate after rebasing onto the janfeddersen-wq#64 dependency-upgrade main.
@acoliver
acoliver force-pushed the feature/issue-65-open-responses branch from b53a672 to 1386f3d Compare September 7, 2026 17:14
acoliver added a commit to acoliver/serdesAI that referenced this pull request Sep 7, 2026
The client kept one session per model instance: a single socket, one
previous_response_id, and a count of already-sent requests. A second
agent.run through the same model chained onto the first conversation's
last response, and the shared sent-count then skipped the new
conversation's input down to nothing: the turn went out carrying the
first conversation's continuation id and an empty input, so the reply
had nothing to do with the new prompt. The whole-model turn lock also
serialized every conversation, though only turns of the same
conversation need to be sequential.

Conversation state now lives in per-conversation records, keyed by the
fingerprint of the history's first request: a DefaultHasher over the
serialized request parts, in-process only (never persisted, never sent
on the wire; DefaultHasher is not stable across processes). Each
record carries its own socket, continuation id, and the fingerprints
of the requests the server already holds. A turn's history must extend
that recorded prefix exactly; a mutated or truncated history voids the
chain and forces a full replay, so a continuation id is only offered
when the server actually holds the prefix. Turns of one conversation
still serialize on the record's lock while different conversations run
concurrently, each on its own socket. The HTTP paths key and chain per
conversation the same way.

(janfeddersen-wq#66)
acoliver added a commit to acoliver/serdesAI that referenced this pull request Sep 7, 2026
…esponses from full (janfeddersen-wq#66)

tokio-tungstenite was pinned at 0.21 in serdes-ai-responses while the
workspace itself moved to 0.30 (PR janfeddersen-wq#70), keeping a second tungstenite
generation in the lockfile. Both tokio-tungstenite and tokio-stream now
come from [workspace.dependencies], and the reqwest dev-dependency that
duplicated the main one is gone. tungstenite 0.30 wraps text and ping
payloads in Utf8Bytes/Bytes, so the fake-server send sites in
tests/websocket.rs convert with .into().

Removing open-responses from the serdes-ai `full` feature list keeps
`cargo publish -p serdes-ai` working while serdes-ai-responses has no
publish tier in publish.yml yet. The standalone `open-responses` feature
stays, so the facade re-export is still reachable.
acoliver added a commit to acoliver/serdesAI that referenced this pull request Sep 7, 2026
…feddersen-wq#66)

The client sends {"type":"response.create","model":...,"input":[...]}
with the turn parameters flat on the frame root; the crate docs, the
crate README and the changelog still described a nested
{"type":"response.create","response":{...}} wrapper. The error module
doc now says "client" instead of "server", matching what the crate is,
and the macros row in the root README feature table recovers its check
mark that an earlier edit had replaced with [OK].
@acoliver

acoliver commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Update on this branch: rebased onto main (Cargo.toml/Cargo.lock conflicts resolved, edition 2024 cargo fmt applied), and the conversation-keyed session fix from your blocking #2/#3 is in (dfbc522): sessions are now keyed by a fingerprint of the first request, per-conversation state replaces the single Session, prefix mismatches reset the chain, and independent conversations run concurrently through one model instance. New tests cover two-conversation isolation (WS and HTTP), concurrent conversations, and mutated-history chain reset; workspace is at 1745 passed / 0 failed.

Also landed here per your non-blocking list: flat-frame doc corrections (lib.rs, crate README, CHANGELOG), the README ✅ restore, the error.rs client wording, workspace-dep alignment (tokio-tungstenite 0.30, tokio-stream, duplicate reqwest dev-dep removed, tungstenite 0.21 generation gone from the lock), and open-responses dropped from full until the consolidation removes the crate.

The streaming header/TLS fixes are up separately as #73. Next: folding the WebSocket transport and session mode into OpenAIResponsesModel in serdes-ai-models per your suggested shape. The status() server codes stayed ungated for now; gating them cascades cfg onto the always-on envelope constructors and default-build tests, which felt like a structural change to fold into the consolidation rather than a quick fix.

janfeddersen-wq pushed a commit that referenced this pull request Sep 7, 2026
WebSocketConfig::with_auth stored headers that never reached the wire: connect_async received only the URL, so authenticated endpoints rejected every handshake. Build the handshake request explicitly, apply the configured headers to it, and bound the connect with the configured timeout so a hanging endpoint fails fast instead of stalling the caller indefinitely.

The websocket feature also refused every wss:// URL with 'TLS support not compiled in' because no TLS backend was enabled behind it. TLS is part of the wss contract rather than an optional extra, so the feature now enables rustls with native root certificates. Verified against tokio-tungstenite 0.30 as upgraded on main. (#66)
@janfeddersen-wq

Copy link
Copy Markdown
Owner

Re-reviewed at 703c954. Thanks for the fast turnaround — the direction is right and most of the list is closed. We are not merging this PR yet: it stays blocked until the consolidation into OpenAIResponsesModel lands, which matches your own plan. #73 is merged, so the transport fix is on main now.

Status of the earlier items

Item Status
1. Parallel crate Open. Still 5,162 lines in serdes-ai-responses/src with its own types.rs / convert.rs, reusing nothing from serdes-ai-models/src/openai/responses.rs. This is the remaining reason to hold.
2. Count-based chaining Partially fixed. Fingerprint keying (client/mod.rs:276-306) and the prefix reset (:337-362) work for the agent.run case since each run gets a fresh timestamp. Identical histories still collide: sending the same Vec<ModelRequest> twice lands the second turn on the same socket with previous_response_id set and an empty input. an_empty_turn_serializes_input_as_a_list (:1013) still codifies that as expected.
3. Whole-turn lock Fixed. Per-conversation Conv, map lock held only for lookup. Five concurrent conversations verified.
4. publish.yml Fixed via dropping open-responses from full. Fine as an interim.
5. Rebase / fmt fmt clean; main moved again after your rebase (#70, #71, #73), so Cargo.lock conflicts once more.
Docs (flat frame), README ✅, error.rs wording, workspace deps, rustls generations Fixed.
HTTP [DONE] without response.completed (:908-910), http-vs-provider error mapping (:604/:615 vs :808/:894), builders panicking via Arc::get_mut().expect (:203-243), codex example headers Open, understood these move with the consolidation.

New in the fix commits

  1. Conversation map is never pruned. Inner.conversations (:112) has no remove/retain, so every conversation keeps its Conv and, on WS, its open socket for the model's lifetime. After five completed turns, all five server-side sockets were still open. An orchestrator sharing one model across many short runs will accumulate one live WebSocket per run. Needs eviction on completion, or an idle TTL, or close-on-StreamComplete.
  2. No close handshake on drop. Dropping the model resets TCP; the server sees ResetWithoutClosingHandshake on every socket.
  3. Fingerprint hashes parts only and ignores ModelRequest.kind. Minor.

What would make the consolidated version mergeable

  • WebSocket transport and session mode on the existing OpenAIResponsesModel, no second crate. Rig moves under tests/.
  • Chaining keyed to the conversation with an explicit identical-history test, and the empty-input test inverted to assert a chain reset.
  • Conversation eviction plus a clean close on drop.
  • The four deferred items above.
  • Example defaults to OPENAI_API_KEY against api.openai.com, codex behind an env var.

Verified locally at 703c954: fmt clean, clippy -D warnings clean, serdes-ai-responses 54 passed / 0 failed, serdes-ai-streaming 63 passed / 0 failed. Happy to review the consolidated PR quickly when it is up.

@acoliver
acoliver marked this pull request as draft September 7, 2026 22:00
Adds serdes-ai-responses, an Open Responses profile server that exposes
any serdesAI Model through the OpenAI Responses API, so standard clients
(including the codex CLI with wire_api="responses") can drive serdesAI
agents without new client code.

- POST /v1/responses returns JSON, or SSE when stream:true (data frames
  ending with [DONE]); POST /responses aliases it for codex base_url use.
- GET /v1/responses/{id} retrieves stored responses; GET /v1/responses
  upgrades to the WebSocket transport: response.create frames in, the
  same events out, one turn at a time, sequence_number restarting per
  turn, 60-minute connection lifetime enforced between turns.
- Stateful mode: store:true (default) persists responses in a pluggable
  ResponseStore and turns chain via previous_response_id, with chained
  instructions replacing stored ones. store:false on a WebSocket keeps
  state connection-local (the codex default): nothing persists globally,
  chaining still works on the socket, and a failed continuation evicts
  the id so the client replays full input.
- codex wire compatibility: unprefixed mid-stream event names, usage on
  response.completed, and retryable error codes
  (previous_response_not_found, websocket_connection_limit_reached).
- Hosted tools, background:true and item_reference inputs are rejected
  by design; only client-side function tools are brokered.

Exposed via the serdes-ai facade feature open-responses (part of full).
…sen-wq#65)

The crate shipped as a server; the product need is the client side. serdesAI
could already call Responses over HTTP+SSE, but had no WebSocket transport,
no session-stateful mode, and no codex-endpoint story. Agents talking to
codex/Open Responses backends resent full history every turn.

OpenResponsesModel implements Model over both transports:

- WebSocket: response.create frames, session-held previous_response_id,
  store:false + delta-only input. The assistant echo the caller appends to
  history is not re-sent on chained turns (the server already has it);
  previous_response_not_found clears the chain and replays; connection
  lifetime limits or socket death trigger reconnect + replay. Recovery
  stops once any event reached the caller, so output is never duplicated.
- HTTP: store:true chaining, SSE parsing for request_stream, codex
  endpoint shape via explicit URL + bearer + extra headers.
- serdes-ai-streaming: connect() now applies configured headers to the
  upgrade request (auth was silently dropped) and bounds the handshake.

The server stays as a wire-accurate test rig behind non-default
'test-server'; its between-turn TTL now always allows a connection's first
turn so reconnect recovery is testable. ResponseStore trait, GET
/v1/responses/{id}, and the POST /responses alias are gone.
…anfeddersen-wq#65)

Review follow-up on the OpenResponsesModel client and its test rig; each
fix closes a gap the shipped tests could not see.

- ResponsesTool::Function serialized without the required "type":"function"
  tag, so emitted tool definitions were invalid Responses wire form and
  did not deserialize back. A marker field now emits and requires the tag.
- run_http_stream read session.sent_requests directly instead of
  continuation_skip, so a chained streaming turn re-sent the assistant
  echo the server already holds. It also returned non-2xx bodies unparsed,
  which left a stale previous_response_id behind: one 404 stranded the
  session permanently. The status-line loop now mirrors the non-streaming
  path (parse the error code, clear the chain, replay once); replay stays
  impossible once SSE events have escaped, so output is never duplicated.
- Test rig: InMemoryResponseStore evicted on a check-then-act race
  (concurrent puts could oversubscribe capacity) and both stores evicted
  by wall-clock stored_at, where same-instant ties made eviction order
  nondeterministic. Eviction is now check+evict+insert under one write
  lock ordered by a monotonic insertion counter.
- Test rig: a chained turn's function_call_output could not resolve a
  tool name for a call made in an earlier turn (call_ids were learned
  only from the current input); stored history now seeds the map.

New tests cover the mid-stream no-replay invariant, chained streaming
delta-only input, and the function-tool wire round-trip.
The client was rig-verified only; the first live run against
wss://chatgpt.com/backend-api/codex/responses (OAuth via the new
codex_haiku example) found five wire divergences. All are corrected,
the rig now mirrors the real backend, and the example streamed a full
response from gpt-5.6-luna with usage before this commit.

- serdes-ai-streaming: the websocket feature now compiles TLS in
  (rustls, native roots). Without it every wss:// URL failed at
  connect time with "TLS support not compiled in".
- response.create frames are flat: model and the turn parameters sit
  on the frame root, not under a "response" wrapper. The live backend
  reads model from the root and rejected nested frames with "The
  'None' model is not supported when using Codex with a ChatGPT
  account".
- mid-stream event names are prefixed on the wire
  (response.output_text.delta, not output_text.delta); terminal names
  already matched, which masked the mismatch until a live turn
  completed with usage but empty text.
- error envelopes accept the live spelling (status, error.type,
  optional code) so real backend errors surface as Provider errors
  instead of being skipped as unparseable frames and blind-retried.
- websocket retry causes are logged per attempt and the last one is
  carried into the exhausted-retries error, so live failures are
  diagnosable. The rig strips HTTP-only keys (stream, stream_options,
  background) that codex sends instead of rejecting the frame.

The rig's forbidden_keys test became stream_keys_are_ignored and the
frame fixtures moved to the flat shape. Examples gain dev-deps on
serdes-ai-providers (PKCE login) and tracing-subscriber.
…sen-wq#65)

Text streaming was already live-proven; tool calls were not, and the
wire path has three legs no rig can vouch for: the backend accepting
our tools array, function_call events parsing with arguments intact,
and a chained turn resolving a function_call_output against the
session state.

codex_haiku gains a `tools` mode that runs the whole loop: offer
get_weather, model calls it (call_id + streamed arguments), the
example answers locally, and a chained second turn returns the final
answer. Verified against the production backend with gpt-5.6-luna:
arguments arrived non-empty, the chained turn resolved the call, and
the answer used the returned values. Empty arguments or a textless
second turn fail loudly so the mode keeps working as a regression
tripwire.
A chained turn skips the assistant reply the server already holds. When
that leaves nothing new, the input was built as a Text variant holding an
empty string, which an untagged enum serializes to "" rather than [].
The codex backend rejects it with "Input must be a list", so the turn
fails with an invalid_request_error.

The agent loop reaches this whenever it calls again with the reply as the
last message, which is why it showed up on a second turn in a
tool-carrying conversation and not on a plain one.

The empty case is now an empty list. There is nothing to add to a chained
turn that adds nothing, and the server already has the rest.
…feddersen-wq#66)

rustfmt's edition 2024 style rules reordered imports and calls across
the crate after rebasing onto the janfeddersen-wq#64 dependency-upgrade main.
Commit e675674 accidentally tracked three scratch files: a code-review
dump, its stderr companion, and a commit message draft. They are
workflow artifacts, not source. This restores the state merged to the
remote in PR #4 so the rebase force-push does not undo it, and adds a
tmp/ ignore rule to prevent a recurrence. The files remain on disk,
now untracked.
The client kept one session per model instance: a single socket, one
previous_response_id, and a count of already-sent requests. A second
agent.run through the same model chained onto the first conversation's
last response, and the shared sent-count then skipped the new
conversation's input down to nothing: the turn went out carrying the
first conversation's continuation id and an empty input, so the reply
had nothing to do with the new prompt. The whole-model turn lock also
serialized every conversation, though only turns of the same
conversation need to be sequential.

Conversation state now lives in per-conversation records, keyed by the
fingerprint of the history's first request: a DefaultHasher over the
serialized request parts, in-process only (never persisted, never sent
on the wire; DefaultHasher is not stable across processes). Each
record carries its own socket, continuation id, and the fingerprints
of the requests the server already holds. A turn's history must extend
that recorded prefix exactly; a mutated or truncated history voids the
chain and forces a full replay, so a continuation id is only offered
when the server actually holds the prefix. Turns of one conversation
still serialize on the record's lock while different conversations run
concurrently, each on its own socket. The HTTP paths key and chain per
conversation the same way.

(janfeddersen-wq#66)
…esponses from full (janfeddersen-wq#66)

tokio-tungstenite was pinned at 0.21 in serdes-ai-responses while the
workspace itself moved to 0.30 (PR janfeddersen-wq#70), keeping a second tungstenite
generation in the lockfile. Both tokio-tungstenite and tokio-stream now
come from [workspace.dependencies], and the reqwest dev-dependency that
duplicated the main one is gone. tungstenite 0.30 wraps text and ping
payloads in Utf8Bytes/Bytes, so the fake-server send sites in
tests/websocket.rs convert with .into().

Removing open-responses from the serdes-ai `full` feature list keeps
`cargo publish -p serdes-ai` working while serdes-ai-responses has no
publish tier in publish.yml yet. The standalone `open-responses` feature
stays, so the facade re-export is still reachable.
…feddersen-wq#66)

The client sends {"type":"response.create","model":...,"input":[...]}
with the turn parameters flat on the frame root; the crate docs, the
crate README and the changelog still described a nested
{"type":"response.create","response":{...}} wrapper. The error module
doc now says "client" instead of "server", matching what the crate is,
and the macros row in the root README feature table recovers its check
mark that an earlier edit had replaced with [OK].
)

responses.rs has grown to 1595 lines and is about to receive the
shared Open Responses wire and event model. Converting it to the
responses/mod.rs module layout first keeps that landing a pure
addition of sibling files, with no diff churn inside the existing
model code and no changes to the public paths
serdes_ai_models::openai::OpenAIResponsesModel or
serdes_ai_models::openai::responses::.
…anfeddersen-wq#66)

The rig-side serdes-ai-responses crate currently owns the only copy of
the Open Responses wire types, stream events, and their translation
onto serdesAI model stream events, which forces the model layer to
depend on rig-side plumbing to speak the protocol. Landing these types
in serdes_ai_models::openai::responses (wire.rs for requests, response
objects, tools, and error envelopes; events.rs for StreamEvent and the
translator) gives the model crate its own protocol vocabulary so later
stages can move the OpenAI Responses model onto it without a reverse
dependency. Types are copied verbatim from serdes-ai-responses, with
the error envelopes decoupled from ResponsesError; the rig-side copies
stay in place until stages S6/S7 remove them.
…ddersen-wq#66)

The unified OpenAI Responses model could not carry the websocket
transport without the pieces that make session turns work: client-side
history conversion, per-conversation continuation state, and the
frame/retry machinery. Move all three from serdes-ai-responses so the
websocket path can function behind a feature flag.

- convert.rs gains the client half of the protocol crate's conversions
  (history_to_wire and friends), with a single-variant ResponsesError so
  the moved code keeps its error surface and stable wire code.
- session.rs holds the Transport enum, the fingerprint-keyed
  conversation map (std::sync::Mutex, held only for lookup/insert, never
  across an await), Conv::plan chain alignment, and the event sinks.
- ws.rs (responses-ws feature) sends the flat response.create frame and
  recovers stale continuations, dead sockets, and connection-limit
  errors before any caller-visible event escapes.

request()/request_stream() branch on the transport: websocket turns run
through the moved paths, non-chaining HTTP stays byte-identical, and
chaining over HTTP fails fast until that stage lands. Selecting the
websocket transport without the feature fails at request time with a
configuration error. Errors surfaced by the unified model now carry the
"openai" provider tag instead of the crate's "open-responses".
…anfeddersen-wq#66)

The websocket transport's recovery paths decide whether a conversation
survives provider failures: a stale continuation must replay the full
input, the connection lifetime limit must reconnect without losing the
turn, ordinary provider errors must surface with their wire code, and
once a stream event has escaped to the caller no retry may replay a
frame. A live rig cannot be coaxed into these states on demand, so the
tests script fake servers that observe the exact frames the client
sends and assert each behavior against the unified model behind the
responses-ws feature.

- stale_continuation_clears_chain_and_replays_full_input: the 404
  previous_response_not_found path clears the chain and the retry
  carries every item with no continuation id.
- connection_limit_error_reconnects_on_a_fresh_socket: the 429
  websocket_connection_limit_reached path starts a fresh session on a
  new socket.
- hard_error_surfaces_as_model_error: a non-recoverable envelope
  becomes ModelError::Provider carrying the wire code.
- mid_stream_error_is_surfaced_without_replay: after a delta reaches
  the caller, a would-be-recoverable error surfaces as an error item
  instead of triggering a replay (300ms frame-silence assertion).
…feddersen-wq#66)

The responses client is validated against a wire-accurate fake server,
not hand-built stubs, and that fake lived as feature-gated product
source in serdes-ai-responses. The janfeddersen-wq#66 review asked for it to move to
test support so the client crate sheds its only non-product surface;
stage S4a lands the rig inside serdes-ai-models ahead of the client
test migration, where the wire and event model it serves already lives.

- tests/rig/{engine,server,store,websocket}.rs move over byte-for-byte;
  only import paths change: wire types now come from
  serdes_ai_models::openai::responses::{wire,events} and sibling
  modules reference each other through super::. convert.rs carries the
  server half (request-to-history, response-to-output items); the
  client half stays in serdes-ai-responses until S7 deletes it.
- rig/error.rs keeps ResponsesError and its status/code/body mapping;
  the envelope types are the wire model's own, so the inherent
  from_error constructors become a local FromResponsesError extension
  trait and call sites keep their shape.
- mod.rs hosts the spawn helpers and recording_model from the old
  tests/common; any test binary can mount the rig with `mod rig;`.
- responses_rig.rs proves the rig end-to-end with the basic JSON turn;
  the remaining rig tests port in S4b.

serdes-ai-responses is untouched; its copy of the rig keeps compiling
and its tests keep passing until the crate is retired. The moved unit
tests run twice during the transition (12 new: 5 engine, 3 store,
3 error, 1 smoke), so the workspace count grows from 1761 to 1773.
…n-wq#66)

The wire-accurate Open Responses rig now carries its full raw-protocol
suite next to the client tests that consume it: sequential websocket
turns, session-local store:false state, continuation errors, forbidden
key stripping, ping/pong, and the connection lifetime limit over the
socket; SSE framing, stateful HTTP chaining, and the error envelopes
over HTTP. The basic JSON turn was already the adopted smoke test, so
only the remaining thirteen tests move; serdes-ai-responses stays
untouched until its remaining suites land here.
…ddersen-wq#66)

The old client suite's rig end-to-end tests (delta-only chained sends,
terminal-last streaming, TTL reconnect, function-tool type tags,
conversation isolation and concurrency, chain reset on mutated history)
now run against the adopted rig here, and the scripted fake servers
share one helper module with the recovery-path tests. The four fake
recovery tests already ported in the previous stage are not duplicated.

HTTP-transport tests stay ungated and run with chaining off: chaining
over HTTP is not implemented yet, so each turn replays full input and
the tests pin the same history lens a chained turn would produce, and
HTTP streaming's buffered fallback is drained through its PartStart
content until real SSE deltas land.
…ersen-wq#66)

The responses model kept a private request mapping alongside the wire
model it already ships, so the two could drift: role:"tool" messages
where the API expects function_call_output items, last-wins system
prompts, and reasoning that lost its encrypted payload between turns.
One shared mapping now serves every transport.

- ResponsesApiRequest carries the wire forms directly: input is a list
  of wire::InputItem, tool_choice is the wire type, and stream becomes
  Option<bool> that HTTP always sends and websocket frames omit.
  parallel_tool_calls joins as an optional key.
- history_to_wire, tool_to_wire, and tool_choice_to_wire from
  convert.rs are the only path from ModelRequest history to request
  items, for HTTP and the flattened response.create frame alike.
  compose_request applies a per-transport overlay (skip, stream,
  store, continuation id, service tier, truncation) over the shared
  mapping. Unsupported media parts now fail the request with
  InvalidRequest instead of being silently dropped.
- System prompts join into one instructions string instead of the
  last prompt winning. Legacy map_messages, convert_user_*,
  convert_tool_return, convert_response_to_input, and the
  ResponseInput/ResponseInputContent/ResponseInputPart/ResponseTool
  types are deleted; a workspace-wide grep finds no remaining users.
- Completed-output mapping moves into parts_from_output: reasoning
  items keep their encrypted_content, stashed in thinking-part
  provider details and replayed on the next request as a wire
  reasoning item. Tool returns map to function_call_output items.

The two input-serialization tests move to the new wire forms, and
seven pins cover the deltas: joined system prompts, typed-item
assistant echo with encrypted reasoning replay, dropped retry
prompts, function_call_output mapping, unsupported-media rejection,
stream key presence by transport, and the wire forms over real HTTP.
Workspace suite: 1826 passed, 0 failed (baseline 1819).
…es model (janfeddersen-wq#66)

The unified responses model rejected chaining over HTTP, so opting into
session state still meant unchained turns on the default transport, and
HTTP streaming stayed a buffered fallback. The responses crate's HTTP
client now ports into the unified model, and one session-state
implementation serves both transports.

- Chained HTTP turns share the websocket conversation state: every turn
  persists with store:true (the crate's client never sent store:false
  over HTTP, so a fresh conversation's first full-replay turn persists
  too), a completed turn records its response id, and the next turn
  sends only the new input items under previous_response_id. A
  previous_response_not_found envelope before any output escapes clears
  the chain and replays the full input, bounded by MAX_ATTEMPTS.
- Chained streaming runs real SSE through the shared event translator
  and enforces the terminal-event contract: a [DONE] sentinel arriving
  without response.completed/incomplete/failed, or a body ending
  without one, surfaces as an error instead of ending silently. A
  completed/incomplete event advances the chain; a failed one does not.
- Error envelopes map onto ModelError::Provider with the wire code on
  every HTTP path, matching the websocket mapping: the non-chaining
  fallback previously degraded code-only Open Responses envelopes to
  bare status errors because the legacy parser demands a `type` field.
  The bare status error remains for bodies without any envelope.
- Non-chaining HTTP is byte-identical, including the buffered streaming
  fallback, whose comment now marks it deliberate instead of a TODO.

The four HTTP client tests flip back to chaining-on with the original
chained assertions, and new pins cover what the rig cannot observe:
the store:true/delta-only/stale-replay wire shapes, envelope and
non-envelope error mapping on both chaining paths, and the [DONE]
contract against a test-only malformed-SSE rig route.
Workspace suite: 1831 passed, 0 failed (baseline 1826).
…n-wq#66)

The responses client already lives as openai::responses in
serdes-ai-models (wire, events, http, ws, session), so the standalone
crate duplicated every type and its ~54 tests re-tested ported
equivalents. Keeping one implementation removes the drift risk between
the two copies.

- Delete serdes-ai-responses; its tests were ported to serdes-ai-models
  in earlier commits.
- Move the codex_haiku smoke example to serdes-ai-providers/examples,
  remapped onto OpenAIResponsesModel with Transport::WebSocket and
  session chaining. It defaults to wss://api.openai.com/v1/responses
  with OPENAI_API_KEY; the codex backend (PKCE OAuth, codex headers)
  activates behind --codex or CODEX=1. The example lives in providers
  because a serdes-ai-providers dev-dep on serdes-ai-models would break
  models' publish in the dependency-ordered release (models publishes
  first); providers' dev-deps resolve at its own, later turn.
- Facade: replace the open-responses feature with
  openai-responses-ws = ["serdes-ai-models/responses-ws"] (part of
  full); drop the serdes_ai::responses re-export.
- Fix test doc comments that still pointed at the removed crate.

Publish dry-runs for models/providers/facade fail only on the
pre-existing condition that the 0.3.0 suite is not yet on crates.io
(latest published is 0.2.6); no new resolution hazard introduced.

BREAKING CHANGE: crate serdes-ai-responses removed; facade feature
open-responses replaced by openai-responses-ws.
The janfeddersen-wq#65/janfeddersen-wq#66 changelog bullets still presented the responses client as a
standalone crate that S7 removed, so readers would hunt for a package
that no longer exists. The entries now describe the same work as
additions to serdes-ai-models (websocket transport behind responses-ws,
session chaining on both transports, wire-accurate building, rig as
test support, trimmed codex_haiku example) plus explicit Breaking
entries for the crate removal, the facade feature rename, and the
request-mapping behavior changes.

The models README gains a Responses API section covering the transport
feature, session chaining, and the new example home; the root README
drops the removed crate row and renames the facade feature. Also
repairs pre-existing scrambled prose in the models README where the
"Streaming fallback boundary" heading had been inserted mid-sentence,
orphaning the paragraph tail below the migration bullets.
…anly (janfeddersen-wq#66)

Four maintainer follow-ups on the consolidated responses session model.

A continuation must add material to the history the server holds.
Re-sending a history identical to the recorded chain used to chain onto
the old response with empty input; Conv::plan now treats full
prefix-equality as a replay of the same prompt and resets the chain, so
the full input goes out with no continuation id and the conversation
re-chains from the replay's response. The ported empty-turn wire test
kept its `input: []` pin under the corrected rationale (only a truly
empty history serializes an empty input now).

Request fingerprints covered only the serialized parts, so requests
differing only in ModelRequest.kind collided in the conversation map;
the kind now joins the hash, keeping the in-process-only DefaultHasher
guarantee documented.

Conversation state was immortal and each conversation's websocket socket
lived forever. Conversations now record their last use and are evicted
at the next lookup once idle past a configurable TTL (default five
minutes), their socket closed with a proper Close handshake first.
Eviction is lazy on lookup: a whole-map scan is cheap at the expected
scale of a few conversations per model and avoids a background task.

Sockets discarded on reconnect, eviction, and model drop now perform the
websocket close handshake instead of leaving the server with a
ResetWithoutClosingHandshake: reconnect and eviction await the close in
async context, and the model's Drop hands still-open sockets to a
spawned close task when a tokio runtime is available (plain drop
otherwise, documented). Drop only runs on the last model handle, so the
clone each streaming task holds cannot tear down live sockets.
@acoliver
acoliver force-pushed the feature/issue-65-open-responses branch from 703c954 to 12dc469 Compare September 7, 2026 22:43
@acoliver acoliver changed the title feat: OpenResponsesModel client for the Responses (WebSockets) protocol (#65) feat: websocket transport and session chaining for OpenAIResponsesModel (#65) Sep 7, 2026
@acoliver

acoliver commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

The consolidation you asked for is on the branch now (rebased onto d0aae19 and marked draft while we do a final internal pass). Summary against your re-review:

  • Item 1 (parallel crate): done. serdes-ai-responses is deleted. The WebSocket transport, wire/event model, session state, and HTTP chaining live in serdes-ai-models/src/openai/responses/{wire,events,session,ws,http,convert}.rs on the existing OpenAIResponsesModel (opt-in with_transport / with_session_chaining), and the rig is under serdes-ai-models/tests/rig/.
  • Item 2 residual (identical histories): done. Replaying the exact sent prefix now resets the chain and resends full input, with the rationale documented ("a continuation must add material"). Pinned by plan_resets_the_chain_when_history_repeats_the_sent_prefix and ws_identical_history_restarts_the_chain; the old empty-input-list test's stale comment is rewritten.
  • New finding 1 (map never pruned): done. Idle conversations are evicted lazily at lookup (with_conversation_idle_ttl, default 5 min) and their sockets closed cleanly; ws_idle_conversations_are_evicted_with_a_clean_close pins it.
  • New finding 2 (no close on drop): done. Eviction/reset/reconnect paths await a proper Close, and model Drop spawns best-effort close tasks when a runtime is available; dropped_model_closes_the_socket_with_a_handshake asserts the server sees a Close frame.
  • New finding 3 (fingerprint ignores kind): done, request.kind is hashed with the parts.
  • Deferred four: the [DONE]-without-terminal gap now errors (IncompleteStream) instead of returning Ok(()); envelope errors map to Provider{code} on both transports with http() reserved for non-envelope failures; the Arc::get_mut().expect builder panics did not survive the consolidation (by-value builders, verified by grep); the example defaults to OPENAI_API_KEY against api.openai.com with codex behind --codex/CODEX=1.
  • Rebase: on d0aae19, Cargo.lock regenerated; fmt and clippy -D warnings clean; workspace 1782 passed / 0 failed.

PR description rewritten to match the consolidated shape. Still draft for now; will mark ready after our internal review.

…n-wq#65)

Keep HTTP and WebSocket authentication, header overrides, and event handling consistent so transport choice does not change request behavior or lose terminal errors.

Separate session identity by the initial conversation prefix and tie socket cleanup and idle activity to the conversation lifecycle. Make the test rig cancel disconnected or saturated turns without persistence while preserving wire-correct instructions, tool calls, and reasoning.

Protect token-cache writes before exposing credentials, respect token expiry, and invalidate rejected tokens without replaying requests or discarding a replacement token.

Fixes janfeddersen-wq#75
@acoliver

acoliver commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 0452ce7 after checking your consolidation checklist against the implementation. HTTP and WebSocket now share authentication/header construction and event decoding. This also fixes #75: selecting WebSocket no longer requires duplicating the constructor API key in an explicit header.

Session lookup now includes the prefix through the first non-system request, so conversations sharing system boilerplate keep distinct sessions. Full-prefix validation and identical-history resets remain in place. Socket ownership, completion-based idle timestamps, and eviction of unused sessions are covered by regressions. The description now states the remaining identity limit: independent conversations with identical initial prefixes need separately constructed models.

The rig now cancels disconnected or input-overflowed turns without persisting them, preserves instruction order and encrypted reasoning, and rejects unknown call IDs. The example protects cached credentials and invalidates rejected tokens without replaying output or tool effects.

The findings-only OCR follow-up covered all 16 changed files. Its two retained findings were fixed with regressions. Final gates: formatting and workspace Clippy with warnings denied passed; workspace tests 1,804 passed / 0 failed, plus 9 example tests run separately. No live API credentials were used for this verification.

The PR description reflects the current architecture and verification. It remains draft pending Andrew's sign-off.

@acoliver
acoliver marked this pull request as ready for review September 8, 2026 04:19

@janfeddersen-wq janfeddersen-wq left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Reviewed the consolidated version at 0452ce7. This is the shape we asked for and it fits the framework: the parallel crate is gone, the transport and session mode sit on the existing OpenAIResponsesModel behind the opt-in responses-ws feature, default behaviour is unchanged (HTTP, chaining off, full history, no conversation state), the default build of serdes-ai-models pulls no tungstenite, and the rig's axum/tower are dev-deps only. Approving. Three small doc fixes below and then we merge.

Earlier findings, all confirmed fixed

I re-ran the probes against ws_fakes_common (temporary tests, not committed):

  • Identical history sent twice → prev=None, full input, no reconnect (session.rs:125-144, ws_identical_history_restarts_the_chain).
  • Shared system prompt, different first user message → second socket, distinct sessions, first chain continues with its own previous_response_id.
  • After the idle TTL, the next lookup closes the idle socket with a proper Close frame (session.rs:317-349, ws_idle_conversations_are_evicted_with_a_clean_close).
  • Chaining off with Transport::WebSocket → full history every turn.
  • Drop closes via Handle::try_current() and does not panic outside a runtime (session.rs:169-179).
  • Per-conversation tokio::Mutex; the map's std::sync::Mutex is never held across an await (session.rs:292-305, 318-337).
  • [DONE] without response.completed errors (http.rs:250-254); body end without a terminal errors too (:297).
  • Shared header/auth builder for both transports (mod.rs:759, used at http.rs:74 and ws.rs:81), which is the #75 fix.
  • No Arc::get_mut().expect left; the remaining expects are invariant-guarded or on the never-awaited map lock.
  • Example defaults to wss://api.openai.com/v1/responses with OPENAI_API_KEY, codex behind --codex / CODEX=1.

Verification: cargo fmt --check clean, cargo clippy --workspace --all-features -D warnings clean, cargo test --workspace --all-features 1804 passed / 0 failed, git merge-tree against main clean.

Please fix before merge (docs only)

  1. README contradicts the #75 fix. serdes-ai-models/README.md:63 still does .with_header("Authorization", format!("Bearer {api_key}")) in the WebSocket example. Drop it; the constructor key now reaches the handshake.
  2. CHANGELOG under-discloses the breaking change. The "Breaking" section lists the removal of ResponseInput / ResponseInputContent / ResponseInputPart, but ResponseTool, UserLocation, ContainerConfig, RankingOptions and ImageMask are also removed from the public openai::responses module, and ResponsesApiRequest.tools changed type to Vec<wire::ResponsesTool>. Please list those. Conversely, the entries about removing the serdes-ai-responses crate and the open-responses facade feature can go: neither ever shipped in a release, so nobody can break on them.
  3. Public wire surface. mod.rs:14 exposes CreateResponseRequest / ResponseObject, which only tests/rig uses and which duplicate ResponsesApiRequest / ResponsesApiResponse. Make them pub(crate) (and give the rig what it needs via a test-only path), or add a doc note saying they are the raw wire shapes and not the model API.

Noted, not blocking

  • Non-chained HTTP keeps the legacy status-code mapping (mod.rs:954-970, 401→auth, 429→rate limited) before trying the envelope, while chained HTTP goes straight to provider_with_status (http.rs:124-136). Preserves old behaviour, but the two HTTP modes now differ. Fine for now; worth unifying later.
  • Two independent conversations with a byte-identical initial prefix on one instance share a session. The PR documents it, and fresh UserPromptPart timestamps make it rare in practice.
  • With Transport::WebSocket and chaining off, a socket is still kept per conversation prefix (ws.rs:71) until eviction. Acceptable, eviction handles it.

Because public types in serdes-ai-models are removed, this lands in the next minor bump rather than a patch release. Thanks for turning the whole restructure around this fast.

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

Labels

None yet

Projects

None yet

2 participants