Command Palette

Search for a command to run...

Hectal
PHASE 2Beginner ~29 min· topic 5 of 8

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 object value 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. null until 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, user or assistant.

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.

Read a real streamdiagram
Rendering diagram…

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.

the shape of the readerwhole filejs
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.

Decide, at the end, what actually happeneddiagram
Rendering diagram…

Try it yourself

  1. 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. 2

    Break the JSON

    In the third example, add a stream to the object: "bad JSON": "data: {not json}\n\n".

    The JSON.parse inside readSSE throws, and the example's catch reports bad 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. 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 guarantee TextDecoderStream plus the buffer gives you.

Code & diagrams

A real stream, chunk by chunk

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

Press Run to see the page.

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]
readSSE: one reader that survives every split

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

Press Run to see the page.

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: stop
Streams a real provider can send

Only 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

Press Run to see the page.

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_calls

Break 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.

terminal
$ The first console line
── what you'll see ──
usage-only last chunk (choices is an empty array): "Hello there" | finish: stop | error: Cannot read properties of undefined (reading 'delta')

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 usage when 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. 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. 2

    The request. You POST a JSON body with messages (a list of { role, content }, where the role is system, user or assistant), the model name (a placeholder like "provider/model-name" here, because real names change), and stream: true. Providers may need an extra option to include token counts in a streamed reply (OpenAI's is stream_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. 3

    **Each event's data is a JSON object, a chat.completion.chunk.** It has an id, a model and an array choices. You read choices[0].delta and choices[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 each delta.content to the answer. The first example prints a real stream so you can see every shape.

  4. 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 does answer += chunk.choices[0].delta.content works on it, because the content is an empty string, but code that expects every chunk to have words will be wrong. Treat a missing, null or empty content as "nothing to add".

  5. 5

    The last chunks say why it stopped. Until the end, finish_reason is null. 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), or tool_calls (the model wants a tool run, Phase 8). The reason decides what the screen must say, which is why Topic 3.2 treats length as its own state.

  6. 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 whose choices is an empty array, so choices[0] is undefined. (OpenAI's documentation describes exactly this for include_usage: one extra chunk before [DONE], with empty choices.) 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 reads choices[0].delta without checking breaks on a perfectly normal stream, after the answer was complete. Check that choices[0] exists.

  7. 7

    **[DONE] is not JSON.** The stream ends with the event data: [DONE]. Those characters are not a JSON document, so JSON.parse on them throws. Compare to the string first. Some servers close the connection without sending it, so a stream that ends with neither a finish_reason nor [DONE] has been cut off, not completed. The fourth stream in the third example is exactly that.

  8. 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 said 200. Check every parsed chunk for error. Failures before the stream starts do use a real status (400, 401, 429, 500) with a JSON body, which your res.ok check catches (Topic 0.4).

  9. 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 content empty (check your provider's reference for the name), so a parser that only reads content shows 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

01

What are the pieces of an OpenAI-compatible stream, in order?

02

What does finish_reason: length mean, and what should the screen do?

03

Why can reading choices[0].delta crash on a normal stream?

04

How can an error reach the client after the status was already 200?

05

How do you tell a completed stream from one that was cut off?

Practice

01

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.

02

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 content as nothing to add.

  • I know what stop, length, content_filter and tool_calls mean for the screen.

  • I check that choices[0] exists and look for error in every chunk.

  • I can tell a complete stream from a cut-off one using the finish reason and the end marker.