Topic 2.5
The OpenAI-Compatible Stream Format
In one line
Most providers stream answers as SSE events whose data is a chat.completion.chunk JSON object: a role-only first chunk, content deltas, a final chunk with finish_reason (and often usage), then the literal text [DONE]. A parser must survive every split and every odd chunk: empty choices, error events, a missing [DONE].
Think of it like this
A goods train. The first carriage carries the driver's note ("this train is from the Chai & Co warehouse"), the middle carriages each carry a few boxes (the text), the last carriage carries a board saying why the train stopped (the finish reason) and a bill for the load (the usage), and then a signal flag says the line is clear (the done marker). Every carriage has the same shape, and you unload the boxes from each.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Chat Completions
- The request and reply layout many providers share: a list of messages in, an assistant message out.
- chat.completion.chunk
- The
objectvalue of each streamed JSON message. One chunk adds a little to the answer. - delta
- The change a chunk makes to the answer so far, usually a piece of
content. - finish_reason
- Why the model stopped:
stop,length,content_filter,tool_calls.nulluntil the end. - usage
- Token counts for the request: prompt tokens, completion tokens and their total.
- [DONE]
- The final SSE event, a plain marker and not JSON.
- choices
- The list of possible answers in a chunk. Chat uses the first one. It can be empty.
- role
- Who is speaking in a message:
system,userorassistant.
Step by step
01Read a real stream
The first example fetches a streamed answer of three words and prints each parsed chunk. Read the list top to bottom: a role-only chunk with empty content, three chunks with one word each, a last chunk with an empty delta, finish_reason: stop and usage, and then [DONE].
The usage numbers come from the stand-in server, which estimates them. Real providers count real tokens.
02Write the reader once: readSSE
The second example is the function Mitra will use from now on. readSSE(response) is an async generator (async function*): you loop over it with for await, and each yield hands one meaningful thing to the caller: { type: "text", text }, { type: "finish", reason } or { type: "error", message }.
It keeps the buffer from Topic 2.4, decodes with TextDecoderStream from Topic 2.3, recognises the blank line in all three line-ending styles, ignores comments, stops at [DONE], and releases the reader in finally. Everything else, including how the answer is shown, is the caller's business.
for await (const part of readSSE(res)) {
if (part.type === "text") answer += part.text;
if (part.type === "finish") finish = part.reason; // "stop", "length", "content_filter", …
if (part.type === "error") throw new Error(part.message);
}03Prove it survives every split
The example runs the same answer, which contains an emoji, a Hindi word and an accented letter, through the stand-in server cut into packets of 0 (no cutting), 1, 2, 5 and 40 bytes. All five give the same text and finish: stop. Then it runs three finish reasons and an empty answer: length keeps the cut text and reports length, content_filter gives no text, and an empty answer gives "" with stop.
04Streams a real provider can send
The third example writes streams by hand: one ending with a usage-only chunk whose choices is empty, an error event inside a normal response, a stream that closes without [DONE], one that closes before any finish_reason, and a tool-call chunk with no text.
Read the output as a checklist of what your UI must decide: complete answer, partial answer with an error, complete without the marker, cut off, and no text at all.
05Decide, at the end, what actually happened
After the loop, the answer is complete when you saw a finish_reason or [DONE]. If the loop ended with neither, the connection broke. Keep these two facts in variables and let the caller choose the message. Phase 3 builds the states for them.
Try it yourself
- 1
Add a keep-alive to a stream
In the third example, change the start of the first stream from
": keep-alive\n\n" +to": keep-alive\n\n: still here\n\n" +.The first output line does not change. Comments never reach your code, however many a server sends.
- 2
Break the JSON
In the third example, add a stream to the object:
"bad JSON": "data: {not json}\n\n".The
JSON.parseinsidereadSSEthrows, and the example'scatchreportsbad JSON: "" | finish: (none) | error: Expected property name or '}' in JSON at position 1 (line 1 column 2). A real client must decide what to do with a corrupt event: failing the answer and keeping the text received so far is the safe default. - 3
Count the characters, not the chunks
In the second example, add a line at the end:
serve("/api/chat", fakeOpenAI("😀".repeat(3), { delayMs: 0, firstDelayMs: 0, splitBytes: 1 })); await collect("emoji");Even one byte at a time, three emoji arrive as
emoji: "😀😀😀" | finish: stop. That is the guaranteeTextDecoderStreamplus the buffer gives you.
Code & diagrams
res.text() is fine here because we only want to look at the whole stream. A screen would read it piece by piece instead.
Preview
Console
Nothing logged yet.Expected console output
chat.completion.chunk | delta: {"role":"assistant","content":""} | finish_reason: null
chat.completion.chunk | delta: {"content":"Chai"} | finish_reason: null
chat.completion.chunk | delta: {"content":" is"} | finish_reason: null
chat.completion.chunk | delta: {"content":" tea."} | finish_reason: null
chat.completion.chunk | delta: {} | finish_reason: stop | usage: {"prompt_tokens":8,"completion_tokens":3,"total_tokens":11}
[DONE]The finish reason content_filter is spelled with an underscore on the wire, and content-filter in the stand-in's script option. Always read the wire spelling.
Preview
Console
Nothing logged yet.Expected console output
splitBytes 0: "Chai ☕ is tea. चाय 🙂" | finish: stop
splitBytes 1: "Chai ☕ is tea. चाय 🙂" | finish: stop
splitBytes 2: "Chai ☕ is tea. चाय 🙂" | finish: stop
splitBytes 5: "Chai ☕ is tea. चाय 🙂" | finish: stop
splitBytes 40: "Chai ☕ is tea. चाय 🙂" | finish: stop
length: "A very long answer that was cut" | finish: length
content-filter: "" | finish: content_filter
empty answer: "" | finish: stopOnly the second and fourth cases need an error message on screen: the model failed, or the connection ended with no finish reason. The tool-call case is handled in Phase 8.
Preview
Console
Nothing logged yet.Expected console output
usage-only last chunk (choices is an empty array): "Hello there" | finish: stop
error event inside a 200 response: "Order ORD-1042 is" | finish: (none) | error: The model is overloaded.
no [DONE], the connection just closes: "Short answer" | finish: stop
connection closes before any finish_reason (cut off): "Order ORD-1042 is" | finish: (none)
tool-call chunk (no text at all): "" | finish: tool_callsBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Assume every chunk has a first choice
In readSSE of the third example, change const choice = chunk.choices && chunk.choices[0]; to const choice = chunk.choices[0];, and remove the choice && and choice.delta && guards on the next two lines.
Myth vs fact
Myth
Every chunk contains some text.
Fact
The first chunk usually has an empty content, the last often has an empty delta, and a usage chunk may have no choices at all.
Myth
[DONE] marks success.
Fact
It only marks the end of the stream. Success or failure is in the finish_reason and in whether an error event came before it.
Myth
A 200 response means the answer will be complete.
Fact
The status is sent before the model starts. Errors can arrive as events inside a 200 response, and the connection can still drop afterwards.
Myth
Every OpenAI-compatible provider sends exactly the same fields.
Fact
They share a core and differ at the edges: extra fields, where usage appears, whether [DONE] is sent. Use what you need and ignore the rest.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
When a streamed answer misbehaves, open the browser's network panel, select the request and read the raw event stream. Nine times in ten the problem is visible there: a missing field, an error event, a
finish_reason: length. It is the fastest debugging tool for AI features. - ▸
Store
usagewhen it arrives, but never show it as exact cost. It is tokens in and out for one request. Cost and budgets across a conversation, and what to do when a provider omits usage, are Phase 13 topics (Topic 13.1). - ▸
Make the parser's output small and typed (text, finish, error) and keep every provider quirk inside it. The rest of the app then never sees a raw chunk, and switching provider or SDK changes one file. This is exactly the seam an SDK takes over in Phase 5.
Remember this
- 1
The problem this solves: Topic 2.4 gave you messages. It did not say what is inside them. Most providers (and many servers you can run yourself) copy one layout, called the OpenAI-compatible Chat Completions format, so one parser serves many models. This topic shows exactly what the messages contain, so that you can read a raw stream in your browser's network panel and write a parser that does not break on the odd ones.
- 2
The request. You
POSTa JSON body withmessages(a list of{ role, content }, where the role issystem,userorassistant), the model name (a placeholder like"provider/model-name"here, because real names change), andstream: true. Providers may need an extra option to include token counts in a streamed reply (OpenAI's isstream_options: { include_usage: true }), so check your provider's documentation. Your own server route sends this to the provider and passes the stream back (Topic 1.5). - 3
**Each event's data is a JSON object, a
chat.completion.chunk.** It has anid, amodeland an arraychoices. You readchoices[0].deltaandchoices[0].finish_reason. A delta is the small change this chunk makes to the answer: usually{ content: "…" }with the next piece of text. Your job is to add eachdelta.contentto the answer. The first example prints a real stream so you can see every shape. - 4
The first chunk usually carries only the role. It looks like
delta: { role: "assistant", content: "" }: no text yet. It announces that the assistant is speaking. Code that doesanswer += chunk.choices[0].delta.contentworks on it, because the content is an empty string, but code that expects every chunk to have words will be wrong. Treat a missing,nullor emptycontentas "nothing to add". - 5
The last chunks say why it stopped. Until the end,
finish_reasonisnull. In the final chunk it is a word:stop(the model finished normally),length(it ran out of its allowed length, so the answer is cut off mid-thought),content_filter(the provider blocked it), ortool_calls(the model wants a tool run, Phase 8). The reason decides what the screen must say, which is why Topic 3.2 treatslengthas its own state. - 6
**Usage and the empty
choices.** Many providers also send token counts:usage: { prompt_tokens, completion_tokens, total_tokens }. Some put them in the final chunk. Others send one more chunk after it whosechoicesis an empty array, sochoices[0]isundefined. (OpenAI's documentation describes exactly this forinclude_usage: one extra chunk before[DONE], with emptychoices.) If the stream is cut off early, that usage chunk may never arrive, so usage can be missing even on a stream that showed text. A parser that readschoices[0].deltawithout checking breaks on a perfectly normal stream, after the answer was complete. Check thatchoices[0]exists. - 7
**
[DONE]is not JSON.** The stream ends with the eventdata: [DONE]. Those characters are not a JSON document, soJSON.parseon them throws. Compare to the string first. Some servers close the connection without sending it, so a stream that ends with neither afinish_reasonnor[DONE]has been cut off, not completed. The fourth stream in the third example is exactly that. - 8
Errors can arrive inside a 200. The HTTP status is sent before the model starts, so a failure that happens later cannot change it. Instead the provider may send an event whose data is
{ "error": { "message": … } }inside a response that already said200. Check every parsed chunk forerror. Failures before the stream starts do use a real status (400, 401, 429, 500) with a JSON body, which yourres.okcheck catches (Topic 0.4). - 9
"Compatible" is not "identical". Providers add fields: some stream the model's reasoning in an extra field, some add metadata, some send periodic comment lines. Write the parser to use what it needs and ignore the rest. Never assume a field exists, and never throw on one you do not recognise. One to watch for: a provider may report a refusal in a separate field and leave
contentempty (check your provider's reference for the name), so a parser that only readscontentshows a blank answer. Read your provider's reference for the fields it adds, and treat "no content and no finish reason" as a case to handle, not ignore.
Explain it without notes
What are the pieces of an OpenAI-compatible stream, in order?
What does finish_reason: length mean, and what should the screen do?
Why can reading choices[0].delta crash on a normal stream?
How can an error reach the client after the status was already 200?
How do you tell a completed stream from one that was cut off?
Practice
Write readChunks(text) that takes a complete SSE body as a string and returns { answer, finish }, using the field rules from this topic. Test it on the stream with the usage-only chunk.
Add a counter to the reader: completionTokens taken from usage.completion_tokens when present, otherwise null. Return it with the answer.
Trade-offs
- ↔
A small hand-written parser is a few dozen lines and teaches the format. A library handles more provider quirks and is tested more, but hides the wire format you need to understand when it breaks.
- ↔
Yielding typed parts (
text,finish,error) keeps the app independent of the provider's format, at the cost of dropping fields you did not model. Keep the raw chunk available in development for debugging.
Done when you can
I can list the pieces of a streamed answer from role-only chunk to
[DONE].I treat missing, null and empty
contentas nothing to add.I know what
stop,length,content_filterandtool_callsmean for the screen.I check that
choices[0]exists and look forerrorin every chunk.I can tell a complete stream from a cut-off one using the finish reason and the end marker.