feat(stream)!: align SseStream and StreamingToolCallTracker with the AI SDK - #201
Closed
cunninghamcard-bit wants to merge 9 commits into
Closed
cunninghamcard-bit wants to merge 9 commits into
cunninghamcard-bit wants to merge 9 commits into
Conversation
No crate in the workspace referenced the tracker; providers accumulate streamed tool-call deltas themselves. Drops the module, its 787-line test file and the re-exported companion types. Breaking for aimux-stream's public Rust API; noted in CHANGELOG under Unreleased. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015EYDYDWYcPsDmjBjFeuDVe
Re-adds the tracker as a port of the current @ai-sdk/provider-utils streaming-tool-call-tracker.ts instead of the older index-only version removed in the previous commit. - Correlate deltas by wire id, index and function name; drop ambiguous ones - Unique tool-call ids with bounded suffixes; blank generator output falls back to "tool-call" - Ignore unmatched blank function names; retain blank-name continuations - Flush in index order only when every call has an index - New StreamingToolCallArgumentState / starts_with_structured_value port - process_delta and flush return the emitted parts instead of buffering Tests are ported case for case from the upstream suites (45 tracker cases, 9 argument-state cases). No caller in the workspace uses the tracker yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015EYDYDWYcPsDmjBjFeuDVe
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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS
…nd wire the OpenAI stream to it The AI SDK keeps StreamingToolCallTracker in @ai-sdk/provider-utils, where every provider that speaks the OpenAI chat-completions wire format imports it. aimux had the tracker in aimux-stream (the eventsource-parser role), with its own ToolCallStreamPart event type and no callers, while openai/model.rs kept an index-only accumulator. This commit puts the pieces where the AI SDK has them. aimux-stream - StreamingToolCallTracker, StreamingToolCallArgumentState, starts_with_structured_value and ToolCallStreamPart move out; the crate is SSE decoding only. serde_json becomes a dev-dependency (test fixture); the `tool-calls` keyword is dropped. aimux-provider-utils - streaming_tool_call_tracker.rs / streaming_tool_call_argument_state.rs land here, logic unchanged. The tracker emits aimux_core::StreamPart ToolInputStart / ToolInputDelta / ToolInputEnd / ToolCall directly, so there is no second event type (RFC-0036: ToolCallStreamPart must not be a second public protocol). The generic metadata parameter becomes serde_json::Value / ProviderMetadata; TrackerError converts into AiMuxError::InvalidResponseData. The ported upstream tests (45 + 9) move with it, projecting StreamPart back to the upstream event shape. aimux-providers - openai/model.rs replaces its HashMap<usize, ToolCallAccumulator> with the tracker: tool_calls deltas are correlated by wire id, index and function name instead of index alone; id-less continuations follow their call; indices reused across parallel calls stay distinct; ambiguous deltas are dropped; a call without a wire id gets a generated `tool-call` / `tool-call-N` id instead of an empty string; a new call without a function name ends the stream with InvalidResponseData (AI SDK behaviour) instead of starting a call with an empty name. - DeltaToolCall.index is Option<usize> (the AI SDK schema is `index: z.number().nullish()`). Every registry-backed provider goes through openai/model.rs, so this is the one plug point until S4-2 folds the mistral and xai chat copies into the same implementation. Verification: cargo test -p aimux-stream -p aimux-provider-utils; cargo test -p aimux-providers (129 suites, 3041 passed, 0 failed, including the cassette replays); cargo clippy -p aimux-stream -p aimux-provider-utils -p aimux-providers --all-targets -- -D warnings; cargo fmt --all -- --check. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS
7 of 12 tasks
Contributor
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Aligns
aimux-streamwith the AI SDK's current implementations. The old code was a port of an older, index-only tracker and a hand-rolled SSE parser; nothing in the workspace used the tracker orNdjsonStream, and the only consumer ofSseStreamisaimux-provider-utils/src/response_handler.rs.StreamingToolCallTrackeris rewritten to match@ai-sdk/provider-utils(5.0.51): deltas are correlated by wireid,indexand function name, ambiguous deltas are dropped, ids are de-duplicated with bounded suffixes, blank function names are ignored, andflushorders calls by index only when every call has one. AddsStreamingToolCallArgumentState/starts_with_structured_value.SseStreamis rewritten as a line-based parser matchingeventsource-parser(3.1.1), the parser behindparseJsonEventStream:\n/\r/\r\nin any mix, BOM stripping, field-parsing rules,idwith U+0000 ignored,retrydigits only. Exceedingmax_event_sizenow yieldsSseError::FrameTooLargeand ends the stream instead of skipping the frame.NdjsonStream/NdjsonErrorare removed (unused, no AI SDK counterpart).tokiobecomes a dev-dependency and the unused directserdedependency is dropped.Full API changes are in the
[Unreleased]section ofCHANGELOG.md.Type of change
Checklist
cargo fmt --all -- --checkpassescargo clippy --workspace --all-targets -- -D warningspassescargo test --workspacepasses (171 test binaries, 3856 tests, 0 failed)CHANGELOG.md,README.md,CONTRIBUTING.md,docs/PROJECT-OVERVIEW.md)Notes for reviewers
Tests are ported, not written.
streaming_tool_call_tracker_test.rsand the argument-state test are case-for-case ports of the upstream tests at the pinned tag (@ai-sdk/provider-utils@5.0.51).sse_test.rsis a port ofeventsource-parserv3.1.1'sparse.test.tsandstream.test.ts(MIT), driven by the same fixtures;test/multibyte.tsis checked in as JSON. Test names follow the upstream titles. What could not be ported (onCommentcounts,reset(),onErrorpayloads) and the adaptations (retryis reported on the dispatched event;maxBufferSizemaps towith_max_event_size) are listed in the header ofsse_test.rs. Four aimux-only tests remain: strict UTF-8, byte-level chunk splitting, transport errors, and the default limit.sha2(already used byaimux-providers) is a dev-dependency for the upstream SHA-256 check.Parity was checked by running upstream, not by reading it. The same delta sequences were fed to the upstream JS tracker and the Rust tracker (identical output for the cases tried), and a few SSE inputs to
eventsource-parser. Some behaviours that look odd are upstream's own, for example an id-less continuation with a reused index and name is appended to the existing call. They are kept on purpose.Known, intentional differences from upstream
SseError::Utf8that discards the rest of that event; upstream decodes lossily.max_event_sizeis 1 MiB; upstream's default is unbounded.trim()and JStrim()differ onU+FEFFandU+0085; not reachable with real wire data, left as is.tool-call(as before this change), not a random id.Consumer impact:
response_handler.rsmatches onSseError::Streamand turns every other error intoJsonParse. AFrameTooLargenow ends the stream after that one error, where it used to skip the frame and continue.The first 5 commits are the rewrite; the last two are a clippy fix (
collapsible_matchfails the CI-D warningsgate on stable) and the test port.🤖 Generated with Claude Code