Migrating From a Provider's Native Streaming to Server-Sent Events
11 min read · updated August 11, 2026
Teams standardising a client on “plain SSE” usually discover that the provider they are migrating away from was already sending SSE. The work is not format conversion. It is reconciling dialects that share a wire format and disagree about everything above it.
What this migration actually is
Server-sent events is a transport: a text stream of field: value lines grouped into events by blank lines, defined in the HTML standard. The major LLM providers stream over it. What differs between them is the payload — whether events carry names, what the JSON inside data: looks like, how the stream signals that it is finished, and where usage totals appear.
So the deliverable is not an SSE parser. It is one SSE parser plus a per-provider decoder that maps that provider’s payloads onto a single internal event type your UI and your logging both consume. That reframing matters because it tells you where the hard part is: not in the framing, which is twenty lines, but in the three or four semantic differences that have no clean counterpart.
The wire format, precisely
Worth stating exactly, because most bugs in hand-written consumers are framing bugs rather than semantic ones. Per the WHATWG HTML standard’s server-sent events section, a stream is UTF-8 text served as text/event-stream, made of lines:
data:— the payload. Multipledata:lines in one event are concatenated with a newline between them, which is the detail hand-rolled parsers most often get wrong.event:— an optional event name. Absent means the default type, which a browserEventSourcedelivers asmessage.id:— an optional event id. The client remembers the last one and sends it back asLast-Event-IDwhen it reconnects.retry:— a reconnection delay in milliseconds.- A line beginning with a colon is a comment, used as a keepalive.
- A blank line dispatches the accumulated event. An event is not an event until the blank line arrives, so a parser that dispatches on newline rather than on double-newline will truncate.
One practical consequence: browser EventSource is almost never the right client here, because it issues a GET and cannot set request headers, and an LLM call is a POST carrying an API key. You want fetch with a streamed body, decoded through a double-newline-delimited buffer.
What each provider sends
Nameless events with a sentinel
OpenAI’s Chat Completions streaming sends data: lines with no event: name. Each payload is a chunk object whose choices[0].delta holds the incremental content, and the stream ends with a literal data: [DONE]. That sentinel is the detail that breaks naive ports: it is not JSON, so a decoder that runs JSON.parse on every data: payload throws on the last one. Special-case it before parsing. Usage totals are omitted from a streamed response by default; passing stream_options: { include_usage: true } adds a final chunk carrying usage with an empty choices array — which is the second thing that breaks naive ports, because a consumer that assumes choices[0] exists on every chunk crashes on the very last one. The library covers the payload itself in the streaming chunk format.
Named events with a lifecycle
Anthropic’s Messages API streaming sends both event: and data:, and the names describe a document being built rather than a series of deltas: Anthropic’s streaming documentation defines message_start, content_block_start, content_block_delta, content_block_stop, message_delta and message_stop, plus ping and error. There is no [DONE]; the terminal event is message_stop. The stop reason arrives on message_delta rather than at the start, and output token counts arrive there too. Content is indexed, so a delta names which block it belongs to — which is how text and tool-call arguments interleave without ambiguity.
Named events over a response document
OpenAI’s Responses API also uses named events, dotted rather than underscored, over a response object rather than a message: response.created, response.output_item.added, response.output_text.delta, response.output_item.done, response.completed. Tool arguments stream as their own delta events. Terminal states are response.completed, response.failed and response.incomplete — three endings rather than one, which is a real difference for a consumer that assumed a stream either finishes or errors.
The normalised event
Everything above collapses into a small union. Design it to what your UI needs, not to the richest provider, or you will be synthesising events that one side does not have.
type StreamEvent =
| { kind: "start"; id: string }
| { kind: "text"; index: number; delta: string }
| { kind: "tool_start"; index: number; id: string; name: string }
| { kind: "tool_args"; index: number; delta: string }
| { kind: "end"; reason: "stop" | "length" | "tool_use" | "filtered" | "error";
usage?: { input: number; output: number } };Now the lossy parts, which are the whole reason this page is longer than the parser.
- Stop reasons do not map one to one. Chat Completions puts
finish_reasonon the final chunk with values includingstop,length,tool_callsandcontent_filter. Anthropic sendsstop_reasononmessage_deltawith values includingend_turn,max_tokens,stop_sequenceandtool_use. The Responses API expresses truncation as a status plus an incomplete-reason rather than as a finish reason at all. Some values genuinely have no counterpart, and a union that pretends otherwise loses information your retry logic needs. Where a value does not map, keep the raw one alongside the normalised one. - Usage arrives at different times and under different names. One dialect requires you to opt in and delivers it in a final chunk; another delivers output tokens on the penultimate event and input tokens at the start. If your cost accounting assumes one usage object at the end, it will silently record zero for the other provider. Normalise to a single
endevent carrying usage, and log loudly when it is missing rather than defaulting to zero. - Index semantics differ. One numbers content blocks; another numbers choices and carries tool-call indices separately. Pick one meaning for your
indexfield and document it, because two meanings in one field is a bug that only appears with parallel tool calls. - Reconnection does not work the way SSE promises.The standard’s
Last-Event-IDresume requires the server to emitid:lines and to honour the header. Generation streams largely do not, so an automatic reconnect starts a new completion: you pay twice and the user sees the answer restart. Treat a dropped stream as a failed request handled by your own retry policy, not as something the transport recovers — and see testing reconnect logic and what changes when the API holds state.
Doing the rewrite
- Capture real streams first. Send one request per provider with
curl -Nand save the raw bytes to a file. Every step below runs against those files, with no network and no key. - Write the framing parser: buffer bytes, split on a blank line, collect
event:and joineddata:lines, skip comment lines. Test it against the captures, including a capture you split at an awkward byte boundary to prove partial-chunk handling. - Write one decoder per provider that turns a framed event into zero or more
StreamEventvalues. Zero is important:pingand the[DONE]sentinel both decode to nothing. - Handle the terminal cases explicitly in each decoder — the sentinel,
message_stop, and the three response statuses — and make an unrecognised event name a logged warning rather than a throw. Providers add events; a strict parser turns an additive change into an outage. - Rewrite the consumer against
StreamEventonly. It should not be possible to tell from the consumer which provider is upstream; if it is, something leaked through the union. - Add a test that feeds each capture through and asserts the same normalised sequence shape from both, and a test that truncates a capture mid-event to prove the consumer surfaces an error instead of hanging.