The Streaming Response Shape of the DeepSeek API
8 min read · updated August 11, 2026
DeepSeek streams server-sent events in the OpenAI chat-completions shape, which means an existing client works. It departs from that shape in exactly two places, and both of them are silent failures in a client that does not expect them.
The SSE envelope
Set stream: true and the response is text/event-stream: a sequence of lines beginning data: , each carrying one JSON object, separated by blank lines, terminated by a literal data: [DONE] that is not JSON. That last detail is the first thing that breaks naive clients — parsing every payload as JSON throws on the final line.
curl -N https://api.deepseek.com/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $DEEPSEEK_API_KEY" \
-d '{
"model": "deepseek-chat",
"messages": [{"role":"user","content":"Name three primes."}],
"stream": true,
"stream_options": {"include_usage": true}
}'
data: {"id":"...","object":"chat.completion.chunk","created":1770000000,"model":"deepseek-chat","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
data: {"id":"...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"2"},"finish_reason":null}]}
data: {"id":"...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":", 3"},"finish_reason":null}]}
data: {"id":"...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: {"id":"...","object":"chat.completion.chunk","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":9,"total_tokens":21}}
data: [DONE]The -N on that curl disables buffering; without it you get the whole stream at once at the end and conclude that streaming is broken. Note also that object is chat.completion.chunk rather than chat.completion, which is the cheapest way for a shared handler to tell the two response types apart.
Chunk boundaries carry no meaning. A delta may be one token, several, or an empty string, and the split has nothing to do with word or sentence boundaries. Anything you do to the text — searching for a marker, detecting a tag, parsing JSON — must run over an accumulated buffer rather than over the delta you just received.
What is in a delta
The first chunk carries delta.role and usually an empty content. Subsequent chunks carry content fragments. The final content chunk carries a finish_reason and an empty delta. Tool calls arrive as delta.tool_calls, an array whose entries carry an index and partial fields — most importantly function.arguments arrives in fragments that must be concatenated per index before any attempt to parse them as JSON.
# accumulating a streamed tool call correctly
calls = {}
for chunk in stream:
for tc in (chunk.choices[0].delta.tool_calls or []):
slot = calls.setdefault(tc.index, {"name": "", "arguments": "", "id": ""})
if tc.id:
slot["id"] = tc.id
if tc.function and tc.function.name:
slot["name"] += tc.function.name
if tc.function and tc.function.arguments:
slot["arguments"] += tc.function.arguments
# only now is json.loads(slot["arguments"]) meaningfulKeying on index rather than on id is not optional: the identifier arrives on the first fragment only, and a parallel call produces interleaved fragments for several indices at once.
The reasoning_content channel
This is the first departure from the OpenAI shape. On deepseek-reasoner, deltas carry a reasoning_content key alongside — never inside — content. The trace streams first, then content begins. There is no marker between the phases: the reasoning key simply stops appearing and the content key starts.
data: {"choices":[{"index":0,"delta":{"reasoning_content":"Let me check "},"finish_reason":null}]}
data: {"choices":[{"index":0,"delta":{"reasoning_content":"whether 2027 is prime."},"finish_reason":null}]}
data: {"choices":[{"index":0,"delta":{"content":"2027 is"},"finish_reason":null}]}A client written against the OpenAI schema reads delta.content, finds it absent for the whole reasoning phase, and renders nothing — which presents to a user as a long hang followed by a sudden answer. The fix is to read both keys and route them to different places, which is also the nicer interface: you can show the trace in a collapsible panel from the first chunk rather than buffering. The parsing page covers the local-inference case, where the same information arrives as literal tags inside content instead.
Getting usage out of a stream
By default a streamed response reports no token counts at all. Send stream_options: {"include_usage": true} and a final chunk arrives with a populated usage object and an empty choices array.
That empty array is the second thing that breaks clients. Code indexing choices[0] unconditionally raises on the last chunk of every successful stream — an exception that fires after the user has already seen the complete answer, which makes it maddening to reproduce. Guard on if not chunk.choices: and handle usage there.
Turn it on. Without it you have no per-request cost data for streamed traffic, and streamed traffic is usually most of it. The usage chunk also carries the cache-hit split described in the context-caching page, which is the only place that ratio is observable.
finish_reason, including the one you have not seen
stop— the model finished, or hit one of your stop sequences. The two are not distinguished here, which is why a stop-sequence trigger has to be detected by inspecting the text.length—max_tokensor the context limit was reached. The answer is truncated mid-token-stream, not summarised.content_filter— the content was withheld by a filter.tool_calls— the model wants a tool executed and is waiting.insufficient_system_resource— DeepSeek specific. The request was interrupted because the inference system ran short of capacity. It is a 200 response with a partial answer, not an HTTP error, so retry logic keyed on status codes never sees it.
That last value is the single most useful thing on this page. It means a client ported from another provider will treat a server-side capacity failure as a complete answer and hand a truncated response to the user. Branch on it explicitly and retry, with backoff, the same way you would retry a 503.
finish_reason values is extensible and providers add to it. Treat any value you do not recognise as a failure rather than as a success — an unknown reason is by definition not stop. The current list is in DeepSeek’s create-chat-completion reference.Failures after the headers are sent
The hardest part of streaming is not the parsing. It is that the HTTP status arrives before the model has produced anything, so every failure from that point on has to travel through a connection that has already reported success. Your error handling has to work in two places instead of one.
- The status code is committed early. A 200 on a streamed request means the request was accepted, not that it will complete. Validation failures — a bad model name, a context overflow, an unsupported parameter — still arrive as ordinary 4xx responses with no stream, because they are detected before generation starts. Everything after that cannot use a status code.
- Capacity interruptions arrive as data.
insufficient_system_resourceis the documented case, and it is a normal-looking terminal chunk on a successful connection. Only afinish_reasoncheck catches it. - A truncated stream is not an error object. If the connection drops mid-generation you get neither a terminal
finish_reasonnor[DONE]— just an end of body. Track whether you saw the terminator; a stream that ended without one is a failure regardless of how much text you accumulated. - Retries are not resumable. There is no offset to resume from, so a retry regenerates from the beginning and you pay for both attempts. If you have already shown partial output to a user, decide deliberately whether to replace it or to keep it and stop.
- Timeouts need to be per-chunk, not per-request. A total-request timeout either fires on legitimately long generations or is so generous that a stalled stream hangs for minutes. An idle timeout — no chunk for N seconds — is the right shape, and on the reasoning path it needs to be generous enough to cover thinking before any content appears.
- Something in between may be buffering. If a stream arrives all at once in production and incrementally in development, suspect a proxy or CDN rather than the API. Streaming responses need buffering disabled along the entire path.