Streaming

Read chat completions as Server-Sent Events: the frame order, the chunk and usage shapes, and how to tell a finished stream from a failed one.

stream is optional and false by default. With stream: true, POST /api/v1/chat answers with text/event-stream: OpenAI-style Server-Sent Events in which every event is one chat.completion.chunk JSON object on a data: line. The stream ends with the literal line data: [DONE], and that line is the only signal that the completion finished.

curl -N https://api.cruxy.ai/api/v1/chat \
  -H "Authorization: Bearer $CRUXY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "vaani",
    "stream": true,
    "messages": [
      { "role": "user", "content": "Write a haiku about monsoon rain." }
    ]
  }' | tee stream.txt
 
# The completion finished only if the stream ended with [DONE].
grep -q '^data: \[DONE\]' stream.txt || echo "stream failed before [DONE]"

Frame order

A successful stream sends these frames, in this order:

#FramechoicesWhat it carries
1Role primerOne element; delta is {"role": "assistant", "content": ""}The turn's cruxy block: routed_tier, request_id, and reasoning_effort, plus memory_recall and rules_applied when they apply.
2Content or tool-call deltas, zero or moreOne element; delta has a content fragment or tool_calls fragments, never bothThe answer, piece by piece. finish_reason is null.
3FinishOne element; delta is {}finish_reason. No usage.
4Usage[], an empty arrayThe final usage object, plus cruxy.memory_capture when the turn stored memory.
5data: [DONE]Nothing. It is not JSON; do not parse it.

On the wire:

data: {"id":"chatcmpl-8f3a1c2d","object":"chat.completion.chunk","model":"vaani","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}],"cruxy":{"routed_tier":"vaani","request_id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","reasoning_effort":"low"}}
 
data: {"id":"chatcmpl-8f3a1c2d","object":"chat.completion.chunk","model":"vaani","choices":[{"index":0,"delta":{"content":"Clouds lean"},"finish_reason":null}]}
 
data: {"id":"chatcmpl-8f3a1c2d","object":"chat.completion.chunk","model":"vaani","choices":[{"index":0,"delta":{"content":" on the hills"},"finish_reason":null}]}
 
data: {"id":"chatcmpl-8f3a1c2d","object":"chat.completion.chunk","model":"vaani","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
 
data: {"id":"chatcmpl-8f3a1c2d","object":"chat.completion.chunk","model":"vaani","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":48,"total_tokens":60,"cost":{"amount":<amount>,"currency":"<currency>","tier":"vaani",...}}}
 
data: [DONE]

Changing the framing or this order is a breaking change that ships only under a new dated Cruxy-Version, so you can rely on it. New cruxy.* fields can appear without a version change, so ignore fields you do not recognize.

The chunk

FieldMeaning
idThe completion id.
objectchat.completion.chunk.
modelThe tier.
choicesOne element on every frame that carries output; an empty array on the usage frame.
choices[0].index0.
choices[0].deltaThe fragment: role on the primer, then content or tool_calls, then {} on the finish frame.
choices[0].finish_reasonnull until the finish frame, then stop, tool_calls, length, or content_filter.
usagePresent only on the usage frame.
cruxyThe Cruxy-native block; see Chat.

The role primer's content is an empty string, not absent. Appending every content you see is safe; the primer adds nothing.

Tool-call deltas

When the model calls a tool, deltas carry tool_calls fragments instead of content. Every fragment has an index and a piece of function.arguments, a JSON string; fragments can also carry the call's id, type, and function.name. Assemble the fragments per index, concatenating the arguments pieces. A finish frame with finish_reason: "tool_calls" means the fragments are complete: run the calls and answer them as Handle tool calls describes.

The usage frame

The usage frame is the only frame with usage, and it is never merged into the finish frame before it, because an OpenAI-compatible client reads usage only from the frame whose choices is empty.

FieldMeaning
prompt_tokensInput tokens.
completion_tokensOutput tokens.
total_tokensThe sum.
billable_input_tokens, cache_read_tokens, cache_write_tokensOmitted when zero; absence means zero.
prompt_tokens_detailsThe prompt-cache breakdown, present only when the prompt cache was used.
costAn object, not a number: amount, currency, tier, and the per-million-token rates input_unit, output_unit, and cache_read_unit. Read cost.amount for a scalar.

A stream names its token fields the OpenAI way (prompt_tokens, completion_tokens); a non-streaming response uses input_tokens and output_tokens. Usage and cost covers both shapes, the cost object, and which tokens are billed.

[DONE] is the only success signal

Once the stream opens, the 200 is already sent. If the completion fails after that, no error frame follows: the gateway stops sending and closes the response without the finish frame, the usage frame, or data: [DONE].

To detect it on the client:

  1. Set a flag when you read data: [DONE], as the sample above does with sawDone.
  2. When the read loop ends, treat a missing flag as a failure.
  3. Treat an exception from the read itself, such as a dropped connection, as a failure too.

A stream that ends with [DONE] is complete, but the answer can still be cut short: finish_reason: "length" means the output reached max_tokens or the tier's cap. On kavi, which reasons by default, a small max_tokens can be spent entirely on thinking; see Features overview.

The gateway retries a failing model only while the stream opens, never after a byte has reached you. A stream may run for up to five minutes.

A stream stores and replays nothing under Idempotency-Key, since a stream cannot be replayed: a retry with the same key and body streams a new generation. When both attempts complete, the request is billed once. An attempt that did not complete is billed for the tokens it delivered, and the retry is billed as well.

Other chat wires

The rules above are for POST /api/v1/chat. The other two chat endpoints end their streams differently:

  • POST /api/v2/chat sends named events (start, reasoning, delta) and always ends with exactly one finish or error frame. It has no [DONE]; a stream that closes without finish or error failed.
  • POST /v1/chat/completions ends a stream that fails after it opens with a data: {"error": {...}} frame instead of [DONE]. It sends the usage frame only when the request sets stream_options: {"include_usage": true}.

To render a stream in a browser without exposing your key, see Streaming chat in the browser.

Next steps

Was this page helpful?