n4nAI

Parsing the data: field in an OpenAI SSE stream

Learn how to implement robust openai sse data field parsing for streaming LLM responses: buffer events, extract data lines, handle [DONE], and verify.

n4n Team4 min read781 words

Audio narration

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

When you call an OpenAI-compatible chat completions endpoint with stream: true, the HTTP body is not one JSON document. It is a Server-Sent Events (SSE) feed where each event carries a data: line containing a JSON delta. Proper openai sse data field parsing requires more than json.loads(line)—you must respect SSE framing, accumulate multi-line data, ignore comment pings, and stop cleanly on data: [DONE]. This guide walks through a correct, minimal parser you can drop into any Python service.

Step 1: Open the stream with correct request semantics

SSE is just HTTP with a long-lived response. The client sends a normal POST; the server replies with Content-Type: text/event-stream and keeps the connection open. Use a streaming HTTP client so the body is not buffered.

import requests
import os

url = "https://api.openai.com/v1/chat/completions"
headers = {
    "Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}",
    "Accept": "text/event-stream",
    "Content-Type": "application/json",
}
payload = {
    "model": "gpt-4o-mini",
    "messages": [{"role": "user", "content": "Say hello in 5 words."}],
    "stream": True,
}

resp = requests.post(url, headers=headers, json=payload, stream=True)
resp.raise_for_status()

The Accept header is not strictly required by OpenAI, but it documents intent. stream=True is what stops requests from calling resp.json() internally and waiting for EOF.

Step 2: Buffer bytes and split on SSE boundaries

TCP segments and HTTP chunked encoding do not respect SSE event boundaries. A single recv may contain half an event; the next may contain two and a half. The foundation of openai sse data field parsing is a byte buffer that emits complete event blocks terminated by a blank line (\n\n or \r\n\r\n). Do not decode UTF-8 per chunk—multibyte characters like emoji can split across reads.

def sse_events(raw_stream):
    buf = b""
    for chunk in raw_stream:
        buf += chunk
        # Split on either CRLF or LF blank lines
        while b"\r\n\r\n" in buf or b"\n\n" in buf:
            if b"\r\n\r\n" in buf:
                event, buf = buf.split(b"\r\n\r\n", 1)
            else:
                event, buf = buf.split(b"\n\n", 1)
            yield event.decode("utf-8", errors="replace")
    if buf.strip():
        # Connection closed with a partial final event: best-effort emit
        yield buf.decode("utf-8", errors="replace")

This generator yields raw event text, including any event:, id:, or data: lines. It does not yet interpret content. For async services, the same logic applies inside an async for chunk in response.aiter_bytes() loop with an async def generator.

Step 3: Extract the data: field from each event

An SSE event is a sequence of lines. Relevant fields start with data:, event:, id:, or retry:. Lines beginning with : are comments (OpenAI sends : ping periodically to keep proxies alive). Per the SSE spec, multiple data: lines in one event are concatenated with a newline. OpenAI emits exactly one data: line per event, but a robust parser should not assume that.

def extract_data_field(event_block):
    data_lines = []
    for line in event_block.splitlines():
        if line.startswith(":"):
            continue
        if line.startswith("data:"):
            value = line[len("data:"):]
            if value.startswith(" "):  # optional single space after colon
                value = value[1:]
            data_lines.append(value)
        # event:, id:, retry: are ignored for OpenAI streams
    return "\n".join(data_lines)

Correct handling of the optional space matters: data:{} and data: {} are both valid. Stripping unconditionally will corrupt the JSON for the former.

Step 4: Decode JSON and handle the [DONE] sentinel

OpenAI terminates the stream with a final event whose data: line is the literal string [DONE]. Passing that to json.loads raises. Check for it before decoding. Also note that when you set stream_options: {"include_usage": True}, the second-to-last event may contain a usage object instead of a delta—your parser should tolerate keys missing from delta.

import json

def parse_openai_sse(raw_stream):
    for event in sse_events(raw_stream):
        data_str = extract_data_field(event)
        if not data_str:
            continue
        if data_str.strip() == "[DONE]":
            return
        try:
            chunk = json.loads(data_str)
        except json.JSONDecodeError:
            # Partial event due to mid-stream cut; skip or log
            continue
        choices = chunk.get("choices", [])
        if not choices:
            continue
        delta = choices[0].get("delta", {})
        if "content" in delta:
            yield delta["content"]

This generator yields string fragments suitable for printing or accumulating.

Step 5: Run the full client loop

Tie Steps 1–4 into a runnable consumer:

def stream_completion(prompt, model="gpt-4o-mini"):
    resp = requests.post(
        url,
        headers=headers,
        json={**payload, "model": model,
              "messages": [{"role": "user", "content": prompt}]},
        stream=True,
    )
    resp.raise_for_status()
    for text in parse_openai_sse(resp.iter_content(chunk_size=1024)):
        print(text, end="", flush=True)
    print()

if __name__ == "__main__":
    stream_completion("Explain SSE in one sentence.")

iter_content(chunk_size=1024) feeds raw bytes to our buffer. If you point this at an OpenAI-compatible gateway such as n4n.ai, the same framing holds; the endpoint exposes 240+ models behind one route and may execute an automatic fallback if a provider is degraded. Your parse_openai_sse should treat a connection close before [DONE] as a retryable error, not a parse failure.

Step 6: Handle mid-stream interruptions and fallback

Providers fail. A gateway that honors client routing directives might switch upstream models mid-request only if it restarts the stream; otherwise the TCP connection drops. Wrap the consumption loop so a truncated event does not crash the caller:

def safe_stream(prompt, max_retries=2):
    for attempt in range(max_retries):
        try:
            for text in stream_completion(prompt):
                yield text
            return
        except (requests.ConnectionError, json.JSONDecodeError) as e:
            if attempt == max_retries - 1:
                raise
            continue

If you cache partial results, respect any cache-control hints the gateway forwards—some providers cache prompt prefixes and signal that via headers. That is not parsing, but it is part of building a correct streaming client.

Step 7: Verify your parser works

Verification does not require a live API key. Write a static fixture containing typical SSE bytes, including a comment ping and a split event, then assert the parser reconstructs the text and stops at [DONE].

FIXTURE = b"""\
: ping

data: {"choices":[{"delta":{"content":"Hello"}}]}

data: {"choices":[{"delta":{"content":" world"}}]}

data: [DONE]

"""

def test_parser():
    out = "".join(parse_openai_sse([FIXTURE]))
    assert out == "Hello world"

Run pytest. If the assertion passes, your openai sse data field parsing handles framing, comments, and termination. For live verification, capture a real stream to disk with curl and replay it:

curl -N -X POST https://api.openai.com/v1/chat/completions \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}],"stream":true}' \
  -o stream.txt

Then feed open(stream.txt, "rb") into parse_openai_sse and confirm the concatenated output matches expectations and no JSONDecodeError is raised on the [DONE] line.

Common pitfalls

  • Using iter_lines() and assuming each line is an event. A chunk may split data: across two reads, and iter_lines will hand you a partial prefix.
  • Decoding bytes as UTF-8 per chunk instead of across buffer boundaries—multibyte characters will corrupt.
  • Stripping the data: prefix without handling the optional space; both data:{} and data: {} are valid.
  • Ignoring the event: field; OpenAI doesn’t send it, but some proxies emit event: error with a JSON payload in data:.
  • Treating [DONE] as JSON. It is a bare string, not an object.

TypeScript note

In Node, use fetch and read response.body.getReader(). Accumulate Uint8Array chunks in a buffer, decode with TextDecoder incrementally, and split on \n\n. The same extract_data_field logic applies. Avoid JSON.parse on the raw line including the data: prefix.

Solid openai sse data field parsing is boring code: a buffer, a split, a prefix strip, a sentinel check. Get those right and your LLM streaming client survives every provider quirk.

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 →