Skip to content

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. Multiple data: 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 browser EventSource delivers as message.
  • id: — an optional event id. The client remembers the last one and sends it back as Last-Event-ID when 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_reason on the final chunk with values including stop, length, tool_calls and content_filter. Anthropic sends stop_reason on message_delta with values including end_turn, max_tokens, stop_sequence and tool_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 end event 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 index field 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-ID resume requires the server to emit id: 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

  1. Capture real streams first. Send one request per provider with curl -N and save the raw bytes to a file. Every step below runs against those files, with no network and no key.
  2. Write the framing parser: buffer bytes, split on a blank line, collect event: and joined data: lines, skip comment lines. Test it against the captures, including a capture you split at an awkward byte boundary to prove partial-chunk handling.
  3. Write one decoder per provider that turns a framed event into zero or more StreamEvent values. Zero is important: ping and the [DONE] sentinel both decode to nothing.
  4. 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.
  5. Rewrite the consumer against StreamEvent only. It should not be possible to tell from the consumer which provider is upstream; if it is, something leaked through the union.
  6. 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.