Skip to content
Salyro
Tool

SSE stream reconstructor

Paste a raw LLM stream and get the whole answer back — text, tool calls assembled from their fragments, usage and finish reason — with broken frames found.

What it does

Paste the body of a streamed response — what curl -N prints, or what your client logged — and the reconstructor puts it back together:

  • the answer's text, per choice;
  • its tool calls, assembled from their fragments, with their arguments checked as JSON;
  • its usage and why it stopped, with what that finish reason means;
  • the equivalent non-streamed response, in the same format, ready to copy;
  • every frame, in a table with its lines, its event and what it carried — and, when the lines are timestamped, when it arrived.

It reads five streams: Chat Completions chunks (OpenAI's, xAI's and Salyro's), Responses events (OpenAI's and Salyro's /v1/responses), Anthropic Messages events, Gemini streamGenerateContent, and Salyro Behavior Run events. It tells them apart from what the frames carry; you can also pick one.

Everything runs in your browser: nothing you paste is sent anywhere.

Try it: a Salyro Chat stream, a tool call in fragments, Anthropic, text then a tool call, or a stream cut off inside a frame.

How it reads a stream

The frames are cut by Salyro's own decoder. It is the same code the gateway reads a provider's stream with: a frame ends at a blank line (\n\n), a \r at the end of a line is dropped, several data: lines in one frame are joined with newlines, comment lines (:) and id:/retry: are ignored, and it stops a stream that makes it hold more than 1,048,576 characters without a blank line.

Each format is assembled the way its documentation says a client assembles it:

  • Chat Completions: text is appended per choice, and tool-call fragments are joined by their index — the id and name from whichever fragment carries them, the arguments concatenated. This is the assembly Salyro's own request log uses. The stream is complete when it ends with data: [DONE].
  • Responses: output items are built from response.output_item.added, their text and arguments from the deltas, and replaced by the …done events — and when the stream's final response object carries an output, that is the answer shown, since it is what a client that waited for the end holds; the stream ends with response.completed, response.incomplete, response.failed or error, and a failed response's own error is shown as the error the stream ended with. A gap in sequence_number is reported.
  • Anthropic: content blocks are built at their index, and each has to stop; input_json_delta fragments are partial JSON, joined and parsed when the block stops; message_delta sets the stop reason, and its usage — which is cumulative — replaces message_start's. The stream ends with message_stop.
  • Gemini: each frame is a whole response; consecutive text parts of a candidate are joined, and the last finishReason and usageMetadata are kept. There is no end sentinel: a stream is finished when every candidate has a finishReason other than FINISH_REASON_UNSPECIFIED, which the proto calls "unused" — or when the prompt was blocked and there are no candidates.
  • Salyro Behavior Run: the answer is the output_text.delta events, concatenated; tool events are listed; the stream begins with response.started and ends with response.completed, and there is no [DONE].

The assembled response is then read by the same reader the other response tools use. For every format, a documented or schema-built stream and the non-streamed response it stands for give the same result — that is what the tests check.

The summary line

A stream that did not end as a finished answer is one of two things, and the line above the answer says which. Did not finish means its end is not there: no [DONE], no message_stop, no final event, or the stream was cut or stopped at the bound — whatever else is missing, a stream with no end did not finish. Not the whole stream means its end is there, but its start is missing, a block or item never closed, or an event arrived for something never started — the capture is missing a piece from before the end.

When one kind of finding repeats — a hundred thousand frames that are not JSON — the first few are listed, and the rest are counted.

Salyro's streams

  • Chat Completions: data: [DONE] is sent only when the answer finished. A provider failure mid-answer ends the stream with one data: {"error": …} frame and no [DONE] — the reconstructor shows it and stops there. A Salyro chunk carries role, content and tool_calls in its delta, nothing else.
  • Responses: every event is numbered. A provider's finish_reason: "length" ends the stream with response.incomplete instead of response.completed.
  • Behavior Run: response.completed carries the id and the usage, never the text. A tool.failed event does not end the run.

What it finds

Each finding links here.

Stream: empty

No frame was found: no data: line followed by a blank line. Paste the body of a text/event-stream response, not its headers.

Stream: bom

The text starts with a byte-order mark (U+FEFF), which an editor or a capture tool sometimes adds. It is taken off: left on, the first line's field would be \uFEFFdata rather than data, and the first frame would be lost.

Stream: ignored line

A line the decoder reads as nothing. Either its field is not one SSE has — field names are case-sensitive, and only data, event, id and retry exist, so Data: or date: is ignored — or a frame names an event and has no data line, so nothing is dispatched for it. A frame of only id: or retry: is legal framing and is not reported.

Stream: not json

A frame's data is neither JSON nor [DONE]. The frame is listed and not read.

Stream: cut

The last frame has no blank line after it and its data does not parse: the stream was cut off in the middle of a frame. Salyro's decoder treats exactly this as a truncated answer.

Stream: unterminated

The last frame has no blank line after it but reads as a whole frame. A copy from a terminal usually drops the final blank line, so it is read — and marked.

Stream: too large

A frame held more than 1,048,576 characters before its blank line. Salyro's decoder checks its bound on what it is still holding after each read from the provider, so a frame that large, arriving across many reads, stops the stream. A pasted frame is all there at once, so the tool holds each one before its blank line and applies the same bound to it — with or without the blank line that ends it. Only empty lines between frames are skipped; a line of spaces, NBSP or U+FEFF is held like any other. Nothing after the stop is read, so the stream is not said to lack its end: it was not read that far.

Stream: crlf

Every line ends in \r\n. The SSE specification allows it; Salyro's decoder ends a frame on \n\n only, so read strictly it would find no frame. The tool reads it with its line ends made \n, and says so.

Stream: unknown event

A frame that is not part of the format being read — an event name it does not have, or a frame of another stream. It is listed and not read.

Stream: mixed

The frames name more than one format. The stream is read as the format most frames name; pick another to read it as that.

Stream: no end

The stream stops without the frame that ends a finished answer: [DONE], message_stop, a terminal response.* event, a Gemini finishReason or a Behavior response.completed. What arrived is shown, and it may be partial.

Stream: no start

A stream without the event it begins with: an Anthropic stream without message_start (the message's id, model and input usage), or a Behavior stream without response.started (the run's id and the version locked for it). What was pasted is not the whole stream, so it is not called complete even when its end is there. Chat Completions and Gemini streams have no start event.

Stream: block open

Something was opened and never closed: an Anthropic content block with no content_block_stop, a Responses output item with no response.output_item.done (when the stream has no final response object to rest on), or a Behavior tool that started and never completed or failed. What is shown of it may be partial. An Anthropic stream with an open block is not called complete, even with message_stop, and a tool input that never finished is not parsed as if it had.

Stream: prompt blocked

Gemini blocked the prompt: promptFeedback.blockReason is set and there are no candidates. Google's proto says the API "Returns no candidates at all only if there was something wrong with the prompt", so the response is finished — and filtered. The finding quotes the proto's meaning for the reason, and its advice: "Rephrase the prompt."

Stream: block unstarted

An event for something that was never started: an Anthropic content_block_delta or content_block_stop for an index no content_block_start opened, a Responses event for an output item no response.output_item.added opened, or a Behavior tool.completed or tool.failed for a step no tool.started began. What it carries is not assembled, and part of the stream is missing — so an Anthropic or Behavior stream is not called complete. A Responses stream with a final response object still takes its answer from that object.

Stream: block restarted

Something was started twice: a second content_block_start for an Anthropic index already in use, or a second response.output_item.added for a Responses output index. The later one replaces the earlier, and the finding says how much was replaced. An Anthropic stream with a restarted block is not called complete.

Stream: bad field

A field that does not have the shape Salyro's contract gives it: a Behavior response.started whose behavior is not a string or whose version is missing or neither a number nor null (null is a draft; a version that cannot be read is shown as unknown), or a response.completed whose usage is not the three counts inputTokens, outputTokens and totalTokens. It is not shown; a usage that cannot be read is unknown.

Stream: after end

Frames came after the one that ended the stream. A client stops reading at the end, so they are not part of the answer.

Stream: error event

The stream ended in an error, after the response had already started with a 200. What arrived before it is partial. Paste the error into the LLM Error Decoder.

Stream: sequence gap

A Responses event's sequence_number is not one more than the one before: an event is missing or out of order.

Stream: event mismatch

A frame's event: name and the type inside its data disagree. Clients that read one and clients that read the other will see different streams.

Stream: tool call incomplete

A tool call never received an id or a name. It cannot be answered, so it is left out of the answer — as Salyro's request log leaves it out — and its arguments are shown in the finding.

Stream: tool call no index

A tool-call fragment has no index. Salyro's assembly attaches it to call 0; in a stream with parallel calls that would join two calls into one.

Stream: arguments not json

A tool call's assembled arguments do not parse as JSON. A model sometimes produces them; the tool shows them as they are.

Stream: done mismatch

A …done event, or the final response object, disagrees with what its deltas assembled. The final value is kept.

Stream: not assembled

The frames carry something the assembly does not read — a delta field, an event or a delta type. It is named, and listed in the frames table.

Stream: no finish reason

A choice never received a finish_reason, though the stream ended.

Stream: incomplete

The stream ended with response.incomplete: the answer is real, and partial.

Stream: tool failed

A tool inside a Behavior run failed. The run goes on: the model is told and may recover. Only an error event ends a run.

What it does not know

  • Anything you did not paste. It reads the text; it does not connect to a stream.
  • When a frame arrived, unless each line starts with a timestamp — an ISO 8601 date-time or a time of day, optionally in brackets, as a capture tool writes it. Timestamps are taken off before anything else is read, so a stamped stream with \r\n line ends reads the same as one with \n.
  • Events it does not assemble, such as Responses' reasoning, web-search or image events. They are listed; the final response object, when there is one, still carries their result.
  • Whether a finish reason's meaning is current. Meanings are read from each provider's own page on the date shown; OpenAI's and Gemini's come from their published schemas, and xAI's page could not be read.

FAQ

Why does my stream show "did not finish"?

It has no end frame. A Chat Completions stream that finished ends with data: [DONE]; one that failed has an error frame, or simply stops. Check the last frames in the table.

Why is a tool call missing from the answer?

It never received an id or a name, so nobody could answer it. The finding shows its arguments.

How do I get a stream to paste?

curl -N with the request's stream: true prints the raw body. Most SDKs can log the raw events too.

Sources

The decoder and the Chat assembly are Salyro's own, from its shared package. Anthropic's events are from its streaming page on platform.claude.com, and the Anthropic examples are copied from it. OpenAI's chunks and Responses events are from its published OpenAPI description, and Gemini's from its published protos. Salyro's streams are from the API reference and the Behaviors page.

Where the numbers come from

  • Facts about models and providers that are not in that catalogue carry their own source and the date they were checked, and are shown as unknown where no reliable source exists.