Skip to content

Stream chat completions

A non-streaming chat completion returns nothing until the whole answer is generated. For anything a person watches — a chat UI, an agent trace, a long summary — that wait is the product. Streaming sends the answer as it is produced, so the first token reaches your user in a fraction of the total time.

This page shows the streaming call in three languages, how to read the incremental delta objects, and what to do when the connection drops mid-stream.

The examples use glm-5, an OpenAI-format chat model.

Set stream: true and iterate over the chunks. The wire format is standard OpenAI server-sent events — nothing here is Router-specific.

Terminal window
curl https://router-api.0g.ai/v1/chat/completions \
--max-time 120 \
--fail-with-body \
--no-buffer \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ZG_API_KEY" \
-d '{
"model": "glm-5",
"messages": [{"role": "user", "content": "Explain trusted execution environments in three sentences."}],
"stream": true
}'

--no-buffer is what makes the output appear incrementally; without it curl buffers and you see the whole stream at once. --fail-with-body makes curl exit non-zero on an HTTP error while still printing the error JSON.

To go back to a single blocking response, drop stream (or set it to false) and read choices[0].message.content instead of iterating.

Each server-sent event carries one chunk. Consecutive delta.content values concatenate into the final answer; the last chunk sets finish_reason and carries an empty delta.

{
"id": "chatcmpl-2a23c437-775d-4f28-b25c-824e8ba14e0b",
"object": "chat.completion.chunk",
"model": "glm-5",
"created": 1784535735,
"choices": [
{
"index": 0,
"delta": { "role": "assistant", "content": null, "reasoning_content": "1" },
"finish_reason": null,
"logprobs": null
}
],
"usage": null
}

The chunk that ends the text sets finish_reason and an empty content:

{
"choices": [
{ "index": 0, "delta": { "content": "", "reasoning_content": null }, "finish_reason": "stop", "logprobs": null }
],
"usage": null
}

One more chunk follows it, with no choices at all, carrying the totals and the trace block:

{
"choices": [],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 922,
"completion_tokens_details": { "reasoning_tokens": 919 },
"total_tokens": 932
},
"x_0g_trace": {
"request_id": "8312e180-3ecb-43d3-8fd9-06fe5777a59a",
"provider": "0xB01EBd79c3fd63ff52fD47C3935119601EEe2FdB",
"billing": { "input_cost": "29900000000000", "output_cost": "12428560000000000", "total_cost": "12458460000000000" }
}
}

Live responses, 2026-07-20. Then the raw SSE stream terminates with data: [DONE]; both SDKs consume it for you and end the iteration.

Two things this shape implies. A loop that breaks on finish_reason: "stop" never sees the usage and cost, so read through to [DONE] if you want them. And a loop that assumes choices[0] exists on every chunk throws on the final one — guard it. Reasoning models also stream reasoning_content before any content arrives, which is why a stream can look idle for several seconds and still be working.

Two failures look alike from the outside and need opposite handling.

The request never started. The Router rejected it before any bytes of the body were produced, so you get a normal HTTP error with a status and a code401, 402, 429, 503. Nothing was billed for output. Apply the usual policy: honour Retry-After on 429, fix the request on 4xx, do not hammer a 503.

The stream stopped mid-answer. The connection dropped, the timeout fired, or the process was cancelled. You hold a partial answer and no finish_reason. There is no resume: a retry re-runs the whole completion from the start and is billed again.

Decide up front what a partial answer means for your application:

  • Discard it and retry once if the output must be complete or structured, such as JSON or a tool call. A truncated JSON object is worse than no object.
  • Keep it and mark it truncated if the output is prose being displayed to a person. Track that you never saw a finish_reason, so downstream code cannot mistake it for a finished answer.

Streaming chunks do not carry the x_0g_trace block that non-streaming responses return, so per-request billing and provider data are not available on a streamed call. Use a non-streaming request when you need them inline.

Status code Cause Fix
400 invalid_body Malformed request, for example stream sent as a string Fix the body; do not retry unchanged
401 invalid_api_key ZG_API_KEY unset, wrong, revoked, or an mk- key Inference needs a live sk- key
402 insufficient_balance Deposit exhausted Top up, then retry
429 rate_limit_exceeded Too many requests Wait for Retry-After seconds
502 provider_error Provider failed after failover was exhausted Short retry is reasonable
503 no_available_provider No healthy provider for the model Retry with backoff or switch model
Output stops mid-sentence, no finish_reason Connection dropped; see above

Full list with retry guidance: Error codes.