stream_struct 1.3.2 copy "stream_struct: ^1.3.2" to clipboard
stream_struct: ^1.3.2 copied to clipboard

Stream an LLM's structured output as it arrives: tolerant partial JSON that fills the typed object token by token. Decodes SSE, with OpenAI, Anthropic and Gemini extractors.

1.3.2 #

  • New example/two_providers.dart. The package's headline is "OpenAI, Anthropic and Gemini extractors" and no example used one — the other two call sseJson and streamPartialJson, which is the middle of the pipeline. This fills the same object from both providers' forced-tool-call streams, where the only difference in the code is which extractor is passed. Canned chunks, so it runs offline and prints the same thing every time.

1.3.1 #

  • Tests for sseDataFromLines and sseJsonFromData called directly. Both are exported with their own contract, and every SSE test reached them through sseData, which decodes bytes and splits lines first. Seven cases the byte-level tests could not reach, including a bare data line with no colon -- which the SSE grammar makes a data field with an empty value, and which an implementation that skipped colonless lines would have got wrong while passing everything else. No behaviour changed.

1.3.0 #

  • Add openAiToolDelta, for a forced tool call on an OpenAI stream. openAiDelta reads choices[0].delta.content, which is right when you ask for JSON with response_format. The other way to get a schema honoured is tools + tool_choice, and then the JSON arrives as tool_calls[n].function.arguments while content stays null for the whole answer. Read that stream with openAiDelta and every chunk yields nothing: the stream ends having emitted no frames, no exception, nothing to notice. It looked the same as a model that had said nothing.

    openAiToolDelta() reads the arguments fragments. It is a factory because parallel calls interleave in one tool_calls list, each entry tagged with its own index, and those fragments are different JSON values; it locks onto the first call it sees, takes index to follow another, and remembers that choice, so create one per stream. Servers that answer OpenAI's shape without the index field are announcing a single call and are followed as one.

    Kept separate from openAiDelta rather than folded in, because one answer can carry both: a model that narrates before calling the tool would put its prose in front of the JSON buffer, and the buffer would stop parsing. This is the same split, and for the same reason, as anthropicDelta and anthropicTextDelta.

    openAiDelta is unchanged; its documentation now states the limit and points here. Nothing else in the API moves.

1.2.0 #

  • The README now answers, in its first screen, why to reach for this rather than the zero-dependency route or the package that already owns the category. Both answers carry the file and line, or the issue number, that a reader can check. A "reach for it when" list and a sentence on when to skip it follow, because a page that only argues for itself is not useful for deciding.

1.1.1 #

  • The README shows the object filling in as tokens arrive, drawn by tool/growth_figure.dart from a real token stream. Docs and tooling only.

1.1.0 #

  • Add anthropicToolDelta, for a stream carrying more than one tool call. anthropicDelta returns every partial_json fragment whichever content block it came from. That is right while the answer holds a single tool call, but parallel tool use opens a block per call: the fragments interleave into one buffer, the concatenation stops parsing, and every call after the first disappears: no exception, no empty frame, nothing to notice. An agent written against Anthropic's parallel tool use simply never saw its second tool's arguments.

    anthropicToolDelta() locks onto one content block and ignores the rest. By default it takes the first tool call that opens, stepping over the leading text block a model usually emits; pass index to follow another. It keeps the block it chose; create one per stream. Reading a second call means running the stream again with its index. One streamPartialJson carries one JSON value, and two calls are two values.

    anthropicDelta is unchanged and still right for the single-call case; its documentation now states the limit and points here.

1.0.0 #

The API is stable. No behaviour changes since 0.3.9, which fixed the last two parser bugs; this freezes the surface after checking the parser the hardest way I could think of rather than waiting for the fixes to age.

Finding two bugs at the moment of freezing is a reason to look harder, so: every prefix of ten real documents (objects, nested arrays, escapes, unicode, exponents, empty containers, strings containing braces) was parsed and checked, 384 prefixes in all. None threw, none lost structure that had already appeared, and every complete document decoded exactly. That property is now a test. It sits on top of the 35 hostile inputs (truncated escapes, dangling keys, garbage, runaway nesting) that already parse without crashing, and the character-by- character streaming check that catches an object blinking out mid-stream.

Two limits are documented rather than fixed, because both are inherent: streamPartialJson re-parses the buffer each delta, so cost is quadratic in the response length (fine for a model streaming a UI-sized object, wrong for a multi-megabyte payload you only want the end of), and an in-band provider error frame is skipped like any non-content event, which lets a truncated answer look finished. Both are called out in the README with what to do instead.

Every public type is final; meta is the only dependency.

0.3.9 #

  • Fix objects that vanished mid-stream while a value or key was resolving. Two cases left invalid JSON in the completed buffer, which failed the decode of the whole object rather than of the one unfinished field: a started-but-unresolved scalar value ({"a":1,"n":12. or {"a":1,"n":tr), and a complete object key with no colon yet ({"name":"Ada","score"). Both returned null, so during streaming an object would disappear the instant a number grew a decimal point or the next key finished, taking its already resolved fields with it. They now drop just the unfinished entry and keep the object, the same as a dangling key or colon, matching the documented rule that structure which has arrived is returned even when a field is incomplete. Found by streaming values one character at a time and watching for the object to drop to null; a property test now does exactly that across several shapes.

0.3.8 #

  • Document that an in-band provider error event is not surfaced. A provider that signals a mid-stream failure with a data frame (an OpenAI {"error": ...} after a rate limit) has it skipped like any non-content event. The stream ends normally with the partial value and no error: a truncated answer can look finished. Verified: a genuine stream error (dropped socket) still propagates; only the in-band error event is skipped. The README now says to check the provider's error frame yourself. Docs only.

0.3.7 #

  • Document the cost. streamPartialJson re-parses the whole buffer on each delta, so it is quadratic in the response length: a stream twice as long costs about four times as much. Measured on a growing array: ~1s at 1,000 elements, ~14s at 4,000. Invisible for the interactive case this is for, real for a large machine-to-machine payload; the README now says so and points at decoding once with dart:convert when you only need the final value. Docs only; an incremental parser is on the roadmap.

0.3.6 #

  • geminiDelta no longer returns thinking as if it were the answer. With Gemini thinking enabled, a chunk carries the model's reasoning as a part flagged thought: true, ahead of the answer part. The adapter read parts[0].text unconditionally and streamed the reasoning into the JSON buffer. It now returns the first part that is not a thought and has text: a [{thought}, {answer}] chunk yields the answer, and it no longer assumes the answer is always parts[0].
  • Clarify the parsePartialJson null contract in the README: a fully decoded top-level null also returns null, so parsePartialJson alone cannot tell it from "nothing decodable yet"; parsePartialJsonResult.hasValue is what distinguishes them.

0.3.5 #

  • Fix anthropicDelta for tool-based structured output. It returned both a content block's delta.text and a tool block's delta.partial_json, and a real Anthropic answer emits a prose text block ("Let me look that up.") before the tool call. The prose was spliced onto the front of the JSON buffer and the whole thing stopped parsing. Reproduced end to end: a tool stream with a leading text block yielded zero frames. anthropicDelta now follows only the tool block's partial_json, which is the way to get structured output from Anthropic; the same stream now resolves to {"name": "Ada"}.

  • Add anthropicTextDelta for the other Anthropic shape (a model asked for raw JSON as plain text, with no tool), which reads delta.text. The two modes never both apply to one answer, and they are separate functions rather than one that guesses and occasionally concatenates.

    Breaking only if you were using anthropicDelta against a no-tool prose-JSON stream; switch that call to anthropicTextDelta.

0.3.4 #

  • Fix the README's own streaming example. It cast every frame with partial as Map<String, dynamic>, and 0.3.2 added the top-level-null frame on purpose, which made a model answering null throw _TypeError in the documented example. Reproduced with the README lines verbatim over the deltas nu then ll. The example now checks the shape before using it, which is what a caller has to do anyway once the answer can be a scalar or an array.
  • Stop shipping doc/blog/ in the published archive. It held a diagram used by a write-up, not by the package. 249 KB compressed before, 112 KB after.

0.3.3 #

  • Correct what parsePartialJson says it returns. The README and the doc comment both listed "a lone { with only a partial key" among the cases that return null, and that has never been what the code does: '{"titl' decodes to {}, not to null, and the package's own test has asserted exactly that since the function was written. So the documentation contradicted both the behaviour and the test suite.
  • The real rule, now written down in both places: null means an empty buffer or a value still resolving into a scalar, such as tr on its way to true. Structure that has already arrived comes back even when it is still empty, because the buffer has established what the value is: an object whose first key is half-written reads as {}, and an array whose newest element has only just opened reads with an empty element at the end, [{a: 1}, {}]. That second case is now covered by a test as well, since it is the one that shows up as an unexpected frame in a stream.
  • No behaviour change: this release only makes the documentation describe the code. It lands before 1.0.0 because 1.0.0 would freeze the documented contract, and freezing a false one leaves no good way out.

0.3.2 #

  • Fix streamPartialJson (and streamPartialJsonFrom, which wraps it) silently dropping every frame of a stream that resolves to a top-level JSON null. parsePartialJson returns null both for "nothing decodable yet" and for a buffer that decodes to the literal null, and streamPartialJson treated the two the same: skip the frame. A model answering bare null (a common "nothing matched" schema) produced no frames and no error, on every delta, for the rest of the stream, which reads as a stalled connection rather than a real answer. streamPartialJson now resolves that ambiguity internally: a genuine top-level null is emitted once, like any other value. parsePartialJson's own Object? contract is unchanged: called directly, it still cannot tell the two cases apart.

0.3.1 #

  • Declare the recording in pubspec.yaml so pub.dev renders it on the package page. It was already in the repository and the README, but pub.dev shows only what the screenshots: field points at.

0.3.0 #

  • streamPartialFrom completes the four ways in. There were entry points for text fragments to Object? frames, for text fragments to your own type, and for a provider's chunks to Object? frames, but not for a provider's chunks to your own type, which is the combination an app actually wants: a typed value rendered as an SSE response arrives. Reaching it meant rebuilding by hand the map/where/cast that streamPartialJsonFrom already does. Now it is streamPartialFrom(sseJson(response), openAiDelta, Recipe.fromPartial).
  • example/openai_end_to_end.dart runs that path end to end with no API key and no network, on a canned OpenAI body fed in at arbitrary byte boundaries so the cuts land mid-line and mid-token, which is what a socket does and the only part of SSE that is hard.
  • example/README.md covers writing a builder that tolerates a half-filled object, choosing among the four entry points, and the thing the output makes visible: a partial number reads 2 before it reads 20. A partial value is not merely incomplete, it can be provisionally wrong. Render it, don't act on it.

0.2.1 #

  • Shorten the pub.dev description back under the 180-character limit. The previous release grew it past that, which costs the "valid pubspec" points and truncates the text search engines show.

0.2.0 #

  • Add Server-Sent Events decoding: sseJson turns a provider's raw response stream into one JSON event per message, and sseData gives the payloads undecoded. The package started after that step, so every caller wrote the same line handling; now a response body goes end to end, streamPartialJsonFrom(sseJson(response), openAiDelta). Chunk boundaries inside a line or an event are handled, several data: lines in one event are joined with newlines, one leading space after the colon is stripped, : comments and the event:/id:/retry: fields are ignored, CRLF bodies work, and the [DONE] sentinel ends the stream instead of being parsed. sseDataFromLines and sseJsonFromData take over if your transport already split the stream.
  • Docs: correct the pub.dev description. It said "provider-agnostic, you bring the token stream", which undersold the package: openAiDelta, anthropicDelta and geminiDelta ship with it and always have.

0.1.2 #

  • Docs: sharpen the pub.dev description to lead with the value and the terms people search.

0.1.1 #

  • Shorten the pubspec description so it fits pub.dev's 180-character guideline; no API or behaviour change.

0.1.0 #

  • Initial release.
  • parsePartialJson: tolerant decode of a truncated JSON buffer into the value it holds so far (closes open string values and containers, drops dangling keys, colons, and commas).
  • streamPartialJson / streamPartialJsonFrom: turn a delta stream into a stream of the growing value, skipping unparseable and unchanged frames.
  • streamPartial<T>: map growing objects into a typed value with a hand-written builder.
  • Provider delta adapters: openAiDelta, anthropicDelta, geminiDelta.

Planned for a later release: generated builders so streamPartial<T> needs no hand-written mapping.

0
likes
150
points
1.33k
downloads
screenshot

Documentation

API reference

Publisher

verified publisherdeveloperyusuf.com

Weekly Downloads

Stream an LLM's structured output as it arrives: tolerant partial JSON that fills the typed object token by token. Decodes SSE, with OpenAI, Anthropic and Gemini extractors.

Repository (GitHub)
View/report issues

Topics

#llm #ai #json #streaming #structured-output

License

MIT (license)

Dependencies

meta

More

Packages that depend on stream_struct