n4nAI

Handling the [DONE] marker in OpenAI streaming responses

Learn how to correctly parse the OpenAI [DONE] marker in Server-Sent Events streams, with runnable Python and TypeScript code for robust LLM integrations.

n4n Team3 min read632 words

Audio narration

Coming soon — every post will get a voice note here.

Streaming completions from an OpenAI-compatible endpoint keeps a single HTTP connection open and pushes incremental tokens as Server-Sent Events. The openai done marker streaming convention terminates that stream with a non-JSON data: [DONE] line, and your client must recognize it precisely. Treat the marker as a control signal, not as a data record, or you will either hang waiting for a closed socket or crash on json.loads('[DONE]').

Step 1: Inspect the raw SSE frame format

OpenAI’s streaming response is plain text over HTTP, not WebSocket. Each event is one or more data: lines followed by a blank line. A minimal successful stream looks like this:

data: {"id":"chatcmpl-1","choices":[{"delta":{"content":"Hello"}}]}

data: {"id":"chatcmpl-1","choices":[{"delta":{"content":" world"}}]}

data: [DONE]

The [DONE] token is not wrapped in quotes and is not valid JSON. It is a sentinel. Any robust client must separate the control plane (the sentinel) from the data plane (the JSON delta objects). The SSE specification also allows event: and id: lines, but OpenAI-compatible APIs omit them; you only need to handle data:.

Step 2: Open a streaming request

Use a client that exposes the raw byte stream. In Python, requests with stream=True works; in Node, fetch with response.body.getReader() is standard.

import requests

def open_stream():
    resp = requests.post(
        "https://api.openai.com/v1/chat/completions",
        json={"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "hi"}], "stream": True},
        headers={"Authorization": "Bearer $OPENAI_API_KEY"},
        stream=True,
    )
    resp.raise_for_status()
    return resp
const resp = await fetch("https://api.openai.com/v1/chat/completions", {
  method: "POST",
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
  body: JSON.stringify({ model: "gpt-4o-mini", messages: [{ role: "user", content: "hi" }], stream: true }),
});
const reader = resp.body!.getReader();

Step 3: Buffer and split events correctly

TCP chunks do not respect line or event boundaries. Accumulate bytes and split on the double newline that separates SSE events. In Python, iter_lines handles line splitting but not event grouping; do that yourself.

def parse_sse(resp):
    event_lines = []
    for line in resp.iter_lines(decode_unicode=True):
        if line is None:
            continue
        if line.startswith("data: "):
            event_lines.append(line[6:])
        elif line == "":
            if event_lines:
                yield "".join(event_lines)
                event_lines = []
    if event_lines:
        yield "".join(event_lines)

This yields the payload after the data: prefix for each event, whether it is JSON or the [DONE] string. In TypeScript the same logic operates on a string buffer:

let buf = "";
const decoder = new TextDecoder();
function* eventsFromBuffer() {
  let idx;
  while ((idx = buf.indexOf("\n\n")) !== -1) {
    const event = buf.slice(0, idx);
    buf = buf.slice(idx + 2);
    const dataLine = event.split("\n").find(l => l.startsWith("data: "));
    if (dataLine) yield dataLine.slice(6).trim();
  }
}

Step 4: Detect the openai done marker streaming sentinel

Consume the generator and break on the exact sentinel. Strip whitespace because some proxies append \r or trailing spaces. The openai done marker streaming protocol is unforgiving: a single missed comparison leaves the loop waiting on a dead socket.

import json

def collect(resp):
    content = ""
    for data_str in parse_sse(resp):
        if data_str.strip() == "[DONE]":
            # clean termination
            return content
        chunk = json.loads(data_str)
        delta = chunk["choices"][0].get("delta", {})
        if "content" in delta:
            content += delta["content"]
    raise RuntimeError("Stream ended without [DONE]")

The TypeScript reader loop mirrors this:

let buf = "";
const decoder = new TextDecoder();
while (true) {
  const { done, value } = await reader.read();
  if (done) throw new Error("Stream ended without [DONE]");
  buf += decoder.decode(value, { stream: true });
  let idx;
  while ((idx = buf.indexOf("\n\n")) !== -1) {
    const event = buf.slice(0, idx);
    buf = buf.slice(idx + 2);
    const dataLine = event.split("\n").find(l => l.startsWith("data: "));
    if (!dataLine) continue;
    const data = dataLine.slice(6).trim();
    if (data === "[DONE]") { await reader.cancel(); break; }
    const chunk = JSON.parse(data);
    process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
  }
}

Step 5: Extract deltas, finish reason, and usage

Production code needs more than content. Capture finish_reason and usage if present. Some providers send usage only on the last JSON event before [DONE]; others omit it unless you pass stream_options: { include_usage: true }.

def collect_full(resp):
    content = ""
    finish_reason = None
    usage = None
    for data_str in parse_sse(resp):
        if data_str.strip() == "[DONE]":
            break
        chunk = json.loads(data_str)
        choice = chunk["choices"][0]
        delta = choice.get("delta", {})
        if "content" in delta:
            content += delta["content"]
        if choice.get("finish_reason"):
            finish_reason = choice["finish_reason"]
        if "usage" in chunk:
            usage = chunk["usage"]
    return content, finish_reason, usage

Step 6: Stay robust against backend switches

Network middleboxes and gateways may change providers mid-request. If you route through an OpenAI-compatible gateway such as n4n.ai, the same openai done marker streaming contract is emitted even when the backend model is swapped due to rate limits or degradation. Your parser should not inspect the model field to decide when to stop; it should trust the sentinel.

Also guard against premature socket closure. In Python, attach an else clause to the for loop (it runs if break never executes):

    else:
        raise RuntimeError("Connection closed before [DONE]")

In TypeScript, the reader.read() resolving done: true before you saw [DONE] is the equivalent failure; throw as shown above.

Step 7: Verify with a deterministic test

Mock the SSE source so the test never depends on network or quota. Below is a pytest snippet that validates both parsing and sentinel handling.

def test_parse_sse_done():
    class FakeResp:
        def iter_lines(self, decode_unicode=True):
            yield "data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}"
            yield ""
            yield "data: [DONE]"
            yield ""
    events = list(parse_sse(FakeResp()))
    assert events[0].startswith("{")
    assert events[-1].strip() == "[DONE]"

def test_collect():
    class FakeResp:
        def iter_lines(self, decode_unicode=True):
            yield "data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}"
            yield ""
            yield "data: [DONE]"
            yield ""
    assert collect(FakeResp()) == "Hi"

Run pytest -q and confirm both pass. For TypeScript, feed a ReadableStream with the same string chunks to your reader loop and assert the accumulated string equals "Hi" and that reader.cancel was called.

Integration check

Against a live endpoint, set max_tokens=5 and stream a trivial prompt. You should see the connection close within a second or two and your process exit cleanly. If it hangs, you missed the sentinel.

Common pitfalls

  • JSON parsing the sentinel: json.loads("[DONE]") throws. Always compare as string first.
  • Line-ending assumptions: Use splitlines() or strip \r. Windows-style \r\n will break naive line == "" checks if not stripped.
  • Multiple events per chunk: A single read() may contain two data: blocks. Your buffer must loop on \n\n, not just split once.
  • Trailing data: with no blank line: Some proxies flush the [DONE] without a final newline. Handle residual buffer after the socket closes.

The openai done marker streaming spec is simple, but the surrounding buffering is where most client bugs live. Write the parser once, cover it with the mock test above, and never think about it again.

Tagssseopenai-apistreamingparsing

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All server-sent events (sse) streaming deep dive posts →