Mapping Streaming Events Between Chat APIs
10 min read · updated August 11, 2026
All three APIs stream over server-sent events, which makes the transport identical and the payloads nothing alike. A parser written against one and pointed at another rarely crashes. It hangs, or it concatenates JSON into your prose, or it reports usage that is wrong by a factor of the number of chunks.
Named events versus a single chunk type
Server-sent events allow each message to carry an event: name in addition to its data: payload, and the providers made opposite choices about using it.
OpenAI’s Chat Completions stream does not name its events. Every message is a data: line holding a JSON object with object: "chat.completion.chunk", and the meaning of a chunk is determined by which fields inside it are populated. The stream ends with a literal sentinel line, data: [DONE], which is not JSON and will throw if you feed it to a parser without checking for it first.
Anthropic names every event, and the names are the vocabulary you switch on: message_start, content_block_start, content_block_delta, content_block_stop, message_delta, message_stop, plus ping for keepalive and error for a mid-stream failure. There is no [DONE] sentinel; message_stop is the terminator. OpenAI’s newer Responses API also uses named, semantically typed events — deltas arrive on response.output_text.delta and the stream concludes with a response.completed event carrying the assembled response object.
Gemini takes a third position. Requesting the streaming endpoint with SSE gives you a sequence of data: lines, each holding a complete GenerateContentResponse — the same object shape as the non-streaming reply, containing only the newest text in candidates[0].content.parts. There is no separate chunk type, because a chunk is a whole response.
One reply, three wire formats
Take the four-word answer “Paris is the capital” and follow it out. On OpenAI, the first chunk carries the role and usually no text, then one chunk per fragment, then a chunk with an empty delta and a populated finish_reason:
data: {"object":"chat.completion.chunk","choices":[{"index":0,
"delta":{"role":"assistant","content":""},"finish_reason":null}]}
data: {"object":"chat.completion.chunk","choices":[{"index":0,
"delta":{"content":"Paris"},"finish_reason":null}]}
data: {"object":"chat.completion.chunk","choices":[{"index":0,
"delta":{"content":" is the capital"},"finish_reason":null}]}
data: {"object":"chat.completion.chunk","choices":[{"index":0,
"delta":{},"finish_reason":"stop"}]}
data: [DONE]On Anthropic the same reply is a nested, bracketed structure:
event: message_start
data: {"type":"message_start","message":{"id":"msg_01","role":"assistant",
"content":[],"usage":{"input_tokens":14,"output_tokens":1}}}
event: content_block_start
data: {"type":"content_block_start","index":0,
"content_block":{"type":"text","text":""}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,
"delta":{"type":"text_delta","text":"Paris"}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,
"delta":{"type":"text_delta","text":" is the capital"}}
event: content_block_stop
data: {"type":"content_block_stop","index":0}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},
"usage":{"output_tokens":5}}
event: message_stop
data: {"type":"message_stop"}The text is at delta.text, not delta.content, and it is scoped to an indexed block rather than to the message. That index is the load-bearing difference, and the next section is mostly about it.
Four ways a borrowed parser misreads
- It waits for a sentinel that never comes. A loop written as “read lines until one equals
[DONE]” reads an entire Anthropic response correctly and then blocks until the connection times out. The user sees a complete answer that never finishes loading. This is the single most common symptom of a borrowed parser. - It reads the wrong path and finds nothing.
choices[0].delta.contentdoes not exist on an Anthropic event. If your accessor is optional-chained, you get a stream of empty strings and a blank response with no error anywhere. - It concatenates blocks that were never one string. OpenAI streams a single text channel per choice. Anthropic streams indexed blocks, and a reply that uses a tool has a text block at index 0 and a tool-use block at index 1. A parser that appends every delta it sees to one buffer, ignoring
indexand the block type incontent_block_start, produces prose with a half-formed JSON object welded onto the end. - It treats a keepalive as content. Anthropic emits
pingevents, and a parser that assumes every event carries a delta either crashes on the missing field or inserts an empty fragment. It also emitserrormid-stream, which is a real failure arriving on a connection that has already returned HTTP 200 — code that only checks the status line will never see it.
Gemini adds a fifth of its own, in the opposite direction: because each chunk is a full response object, a parser that treats a chunk as cumulative state rather than as an increment will render the last fragment only, and a parser that treats the repeated usageMetadata as an increment will sum it and report a total many times the real one.
Streaming tool arguments
Tool arguments stream as text fragments on every API, because they are generated as text, and each API frames the fragments differently.
OpenAI puts them in delta.tool_calls, an array whose elements carry an index. The id and the function name appear only on the first fragment for a given index; every fragment after that carries a slice of function.arguments and nothing else. Reassembly means keying a buffer on the index and remembering the name you saw once. Anthropic frames the same thing as an indexed content block: content_block_start announces a tool_use block with its id and name and an empty input, then content_block_delta events carry {"type":"input_json_delta","partial_json":"..."} until content_block_stop.
Both are the same idea and neither gives you parseable JSON until the block ends, which is the fact that matters for anything trying to react to arguments early. The mapping between them is mechanical once you have the indexed-buffer model; it is impossible if your intermediate representation is a single string, which is the same lesson as the one on tool schema mapping.
Usage arrives at three different moments
Where in the stream you learn what the request cost differs enough to break naive accounting.
Anthropic gives you input tokens up front, on message_start, and the final output count late, on message_delta. OpenAI’s Chat Completions gives you nothing at all unless you asked: you must send stream_options with include_usage set true, and then a final chunk arrives with an empty choices array and a populated usage object. A parser that assumes every chunk has a choice at index zero throws on precisely that chunk, which is why some integrations quietly never enable the option. Gemini attaches usageMetadata to every chunk, so the answer is available immediately and repeatedly, and only the last one is complete.
The field names differ underneath as well — token counts are spelled prompt_tokens and completion_tokens in one place, input_tokens and output_tokens in another, and promptTokenCount and candidatesTokenCount in a third — which the usage object mapping works through. The terminal signal has the same problem and is covered on the finish reason mapping. For the mechanism of streaming itself, independent of any provider, see streaming responses.