Skip to content

feat(stream)!: decode SSE with the sse-stream crate, aligned with eventsource-parser - #213

Open
cunninghamcard-bit wants to merge 7 commits into
masterfrom
stream/sse-eventsource
Open

cunninghamcard-bit wants to merge 7 commits into
masterfrom
stream/sse-eventsource

Conversation

@cunninghamcard-bit

Copy link
Copy Markdown
Contributor

Summary

Split out of #204 (which keeps the tool-call tracker change) so the SSE fixes can be reviewed and merged on their own. Based on master.

aimux-stream's SseStream was a hand-rolled parser with a 1 MiB per-event cap. The AI SDK's parseJsonEventStream wraps eventsource-parser; this PR makes SseStream the same thing: a thin adapter over the sse-stream crate (WHATWG event-stream parsing, memchr feature only).

  • Parsing follows the spec, as eventsource-parser does: \n, \r and \r\n line endings in any mix, a leading UTF-8 BOM is stripped, a field line without : is an empty value, an empty event: is None, an id containing U+0000 is ignored, retry must be all ASCII digits, and a block is dispatched only when it had a data line.
  • No event size limit any more (as upstream): with_max_event_size and SseError::FrameTooLarge are gone. This fixes the >1 MiB image-generation events recorded from OpenAI Responses and Gemini, which the cap truncated.
  • SseError::Stream carries the transport error as its source instead of a String; SseError::Utf8 holds a str::Utf8Error; SseError::Decode is added. Decoder errors (invalid UTF-8, transport failure) end the stream after being reported.
  • NdjsonStream / NdjsonError are removed (unused, no AI SDK counterpart); tokio and serde_json become dev-dependencies, the unused direct serde dependency is dropped.
  • The only consumer, aimux-provider-utils/src/response_handler.rs, matches SseError::Stream for transport errors and turns every other error into JsonParse.

The tracker is untouched here; it is handled in #204.

Type of change

  • Breaking change (Rust API of aimux-stream)

Notes for reviewers

Tests are ported, not written. sse_test.rs is a case-for-case port of eventsource-parser v3.1.1's parse.test.ts and stream.test.ts (MIT), driven by the same fixtures; test/multibyte.ts is checked in as JSON. What could not be ported (onComment counts, reset(), onError payloads, the maxBufferSize cases, which have no counterpart now that there is no cap) is listed in the file header. aimux-only tests: strict UTF-8 as a terminal error, byte-level chunk splitting, transport errors. sha2 (already used by aimux-providers) is a dev-dependency for the upstream SHA-256 check.

Known, intentional differences from upstream: invalid UTF-8 is a strict, terminal SseError::Utf8 (upstream decodes lossily); Rust trim() and JS trim() differ on U+FEFF / U+0085, not reachable with real wire data.

Commit order: the parser rewrite, NDJSON removal, the UTF-8 error fix, a clippy fix (collapsible_match fails the CI -D warnings gate on stable), the test port, the switch to sse-stream, then one CHANGELOG entry describing the final API.

Checklist

  • cargo fmt --all -- --check
  • cargo clippy -p aimux-stream -p aimux-provider-utils -p aimux-providers --all-targets -- -D warnings
  • cargo doc -p aimux-stream -p aimux-provider-utils --no-deps with RUSTDOCFLAGS=-D warnings
  • cargo test -p aimux-stream -p aimux-provider-utils -p aimux-providers — the existing chat/completions cassettes exercise the new parser end to end
  • CHANGELOG describes the final state only
  • CI on this head

🤖 Generated with Claude Code

https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS

claude and others added 7 commits October 3, 2026 04:49
The AI SDK's parseJsonEventStream pipes through eventsource-parser. Replace
the blank-line frame splitter with its line-based state machine:

- \n, \r and \r\n line endings in any mix; a trailing \r waits for a
  possible \n in the next chunk
- strip a UTF-8 BOM at the start of the stream
- a field line without ':' has an empty value; empty event -> None;
  id containing U+0000 ignored; retry accepted only when all ASCII digits
- exceeding the buffer limit (event data + partial line) is fatal and ends
  the stream, as maxBufferSize does upstream

Kept from aimux: strict UTF-8 decoding after reassembly (an invalid line
now drops only the event being built) and a default 1 MiB bound.

extract_lines already matched upstream; the [DONE]-skipping JSON layer in
aimux-provider-utils already matches parseJsonEventStream.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015EYDYDWYcPsDmjBjFeuDVe
Nothing in the workspace used NdjsonStream / NdjsonError and the AI SDK's
provider-utils has no counterpart. Also move tokio to dev-dependencies (only
the tests use it), drop the unused direct serde dependency, and update the
crate description in README / PROJECT-OVERVIEW.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015EYDYDWYcPsDmjBjFeuDVe
A line that failed UTF-8 decoding reset the event, but the lines after it
(up to the blank line) were then dispatched as a separate fragment event,
producing a second downstream error. Mark the event poisoned until the next
blank line so one bad event yields exactly one SseError::Utf8. Also update
the aimux-stream description in CONTRIBUTING.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015EYDYDWYcPsDmjBjFeuDVe
clippy::collapsible_match rejects the nested `if` under the CI
`-D warnings` gate (stable 1.98); behaviour is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…s suite

sse_test.rs was a behaviour list written from scratch, and its header
claimed the AI SDK has no SSE parser tests. The parser behind
parseJsonEventStream, eventsource-parser, ships its own suite. Port
test/parse.test.ts and test/stream.test.ts (v3.1.1, MIT) case for case,
driven by the same fixtures; test/multibyte.ts is checked in as JSON
and read with include_str!.

Cases with no Rust counterpart (onComment counts, reset(), onError
payloads) are listed in the file header, as are the adaptations
(retry is reported on the event, maxBufferSize maps to
with_max_event_size). Four aimux-only tests remain: strict UTF-8,
byte-level chunk splitting, transport errors, and the default limit.

The four unit tests inside sse.rs are dropped; the port covers them.
sha2 (already used by aimux-providers) is a dev-dependency for the
upstream SHA-256 check on the 4.8M-character event.

The tracker and argument-state tests were already case-for-case ports
of the pinned upstream tests; only a comment is added.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Replace aimux-stream's hand-written SSE parser with a thin adapter over
sse-stream 0.3 (WHATWG event-stream parsing), the way the AI SDK's
parseJsonEventStream wraps eventsource-parser. The adapter keeps the AI SDK
dispatch rules: only blocks with a data line are dispatched, an empty
`event:` is None, and `retry` rides on its event.

- No event size limit any more, as upstream: `with_max_event_size` and
  `SseError::FrameTooLarge` are removed. Real recordings of OpenAI Responses
  and Gemini image generation carry 2-3 MB events; the 1 MiB cap dropped
  them, and after the previous commit ended the stream on the first one.
  Both recordings now decode completely (15/15 and 5/5 events).
- `SseError::Stream` carries the transport error as its source instead of a
  String; `Utf8` holds a `str::Utf8Error`; `Decode` covers the rest. Decoder
  errors end the stream after being reported.
- The ported eventsource-parser suite runs unchanged against the adapter
  (29 cases); the 6 `maxBufferSize` cases go with the limit.
- provider-utils: the `SseError::Stream` arm formats the source error.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS
One entry for the final state of the branch: the per-commit changelog
hunks described intermediate APIs that no longer exist.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS

This branch has not been deployed

No deployments
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