stream_struct 1.3.2
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 callsseJsonandstreamPartialJson, 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
sseDataFromLinesandsseJsonFromDatacalled directly. Both are exported with their own contract, and every SSE test reached them throughsseData, which decodes bytes and splits lines first. Seven cases the byte-level tests could not reach, including a baredataline 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.openAiDeltareadschoices[0].delta.content, which is right when you ask for JSON withresponse_format. The other way to get a schema honoured istools+tool_choice, and then the JSON arrives astool_calls[n].function.argumentswhilecontentstays null for the whole answer. Read that stream withopenAiDeltaand 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 onetool_callslist, each entry tagged with its ownindex, and those fragments are different JSON values; it locks onto the first call it sees, takesindexto follow another, and remembers that choice, so create one per stream. Servers that answer OpenAI's shape without theindexfield are announcing a single call and are followed as one.Kept separate from
openAiDeltarather 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, asanthropicDeltaandanthropicTextDelta.openAiDeltais 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.dartfrom a real token stream. Docs and tooling only.
1.1.0 #
-
Add
anthropicToolDelta, for a stream carrying more than one tool call.anthropicDeltareturns everypartial_jsonfragment 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; passindexto follow another. It keeps the block it chose; create one per stream. Reading a second call means running the stream again with its index. OnestreamPartialJsoncarries one JSON value, and two calls are two values.anthropicDeltais 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 returnednull, 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 tonull; 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.
streamPartialJsonre-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 withdart:convertwhen you only need the final value. Docs only; an incremental parser is on the roadmap.
0.3.6 #
geminiDeltano longer returns thinking as if it were the answer. With Gemini thinking enabled, a chunk carries the model's reasoning as a part flaggedthought: true, ahead of the answer part. The adapter readparts[0].textunconditionally 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 alwaysparts[0].- Clarify the
parsePartialJsonnull contract in the README: a fully decoded top-levelnullalso returnsnull, soparsePartialJsonalone cannot tell it from "nothing decodable yet";parsePartialJsonResult.hasValueis what distinguishes them.
0.3.5 #
-
Fix
anthropicDeltafor tool-based structured output. It returned both a content block'sdelta.textand a tool block'sdelta.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.anthropicDeltanow follows only the tool block'spartial_json, which is the way to get structured output from Anthropic; the same stream now resolves to{"name": "Ada"}. -
Add
anthropicTextDeltafor the other Anthropic shape (a model asked for raw JSON as plain text, with no tool), which readsdelta.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
anthropicDeltaagainst a no-tool prose-JSON stream; switch that call toanthropicTextDelta.
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-nullframe on purpose, which made a model answeringnullthrow_TypeErrorin the documented example. Reproduced with the README lines verbatim over the deltasnuthenll. 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
parsePartialJsonsays it returns. The README and the doc comment both listed "a lone{with only a partial key" among the cases that returnnull, and that has never been what the code does:'{"titl'decodes to{}, not tonull, 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:
nullmeans an empty buffer or a value still resolving into a scalar, such astron its way totrue. 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(andstreamPartialJsonFrom, which wraps it) silently dropping every frame of a stream that resolves to a top-level JSONnull.parsePartialJsonreturnsnullboth for "nothing decodable yet" and for a buffer that decodes to the literalnull, andstreamPartialJsontreated the two the same: skip the frame. A model answering barenull(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.streamPartialJsonnow resolves that ambiguity internally: a genuine top-levelnullis emitted once, like any other value.parsePartialJson's ownObject?contract is unchanged: called directly, it still cannot tell the two cases apart.
0.3.1 #
- Declare the recording in
pubspec.yamlso pub.dev renders it on the package page. It was already in the repository and the README, but pub.dev shows only what thescreenshots:field points at.
0.3.0 #
streamPartialFromcompletes the four ways in. There were entry points for text fragments toObject?frames, for text fragments to your own type, and for a provider's chunks toObject?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 thatstreamPartialJsonFromalready does. Now it isstreamPartialFrom(sseJson(response), openAiDelta, Recipe.fromPartial).example/openai_end_to_end.dartruns 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.mdcovers 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:
sseJsonturns a provider's raw response stream into one JSON event per message, andsseDatagives 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, severaldata:lines in one event are joined with newlines, one leading space after the colon is stripped,:comments and theevent:/id:/retry:fields are ignored, CRLF bodies work, and the[DONE]sentinel ends the stream instead of being parsed.sseDataFromLinesandsseJsonFromDatatake 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,anthropicDeltaandgeminiDeltaship 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.
