Streaming
SSE shapes, the always-present final usage chunk, side-channel headers, and in-band error handling.
Streaming is standard OpenAI server-sent events: set "stream": true and read
data: records terminated by data: [DONE]. /v1/messages streams in the
native Anthropic event sequence.
Chat stream shape
Each data: record is an OpenAI chat.completion.chunk:
data: {"id":"chatcmpl-1","object":"chat.completion.chunk","created":1724680001,"model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":"Hel"},"finish_reason":null}]}
data: {"id":"chatcmpl-1","object":"chat.completion.chunk","created":1724680001,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"lo!"},"finish_reason":null}]}
data: {"id":"chatcmpl-1","object":"chat.completion.chunk","created":1724680001,"model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8,"prompt_tokens_details":{"cached_tokens":0,"cache_write_tokens":0},"completion_tokens_details":{"reasoning_tokens":0}}}
data: [DONE]Three guarantees:
modelin every chunk is your alias — chunks are otherwise passed through byte-identical, with model-name virtualization applied (upstream- private fields such asnvextare removed, as on non-streaming responses).- A final usage chunk is always present — on
/v1/chat/completionsthe gateway forcesstream_options.include_usage: trueregardless of what you sent, so the terminating chunk carriesusage. On the legacy/v1/completionsit cannot add the flag, so a streaming request must send"stream_options": {"include_usage": true}; without it the request is rejected with 400invalid_request(param: "stream_options.include_usage"), because the stream could not be metered. - That usage chunk has a normalized shape —
usage.prompt_tokens_details.{cached_tokens,cache_write_tokens}andusage.completion_tokens_details.reasoning_tokensare always present on the chunk carryingusage, defaulting to0when the backend didn't report that detail, and passed through unchanged when it did. This is the same guarantee non-streaming responses get (see Chat Completions) — the gateway never invents a nonzero number, and it holds regardless of how the upstream backend framed its own SSE output, so you see one consistentusageshape no matter which model served the request.
Side-channel headers (set before the first byte)
| Header | Value |
|---|---|
X-TF-Request-Id | Server-minted request id (same on every response type) |
X-TF-Provider | provider_metadata.provider |
X-TF-Actual-Model | provider_metadata.actual_model |
X-TF-Model | Your alias |
X-TF-Deprecation | Only for a deprecated alias: sunset=<RFC3339>; replacement=<alias> |
On streams and audio bodies provider_metadata never appears as a trailing
body field — it rides in these headers, which are set before the first byte.
The same headers are also sent on plain JSON responses, so you can use one code
path for both.
Tip
Optionally (server configuration TF_RELAY_STREAM_TERMINAL_METADATA=true), one
extra record is emitted before [DONE]:
data: {"object":"tf.provider_metadata","provider":"…","actual_model":"…"}
It is off by default because strict OpenAI SDK chunk parsers may reject an
unknown object value. Do not build on it unless your deployment enables it.
Detecting stream completion vs interruption
A stream ends in exactly one of two ways:
| Outcome | How you detect it |
|---|---|
| Clean completion | You saw data: [DONE]. |
| Interruption after first byte | One data: {"error":{…}} record, then EOF without [DONE]. |
The in-band error record has the uniform error shape:
data: {"error":{"type":"server_error","message":"upstream stream interrupted","code":"stream_interrupted","request_id":"req_…"}}
Codes: stream_interrupted (the provider's stream was reset) or
api_key_terminated (your key was revoked mid-stream).
The HTTP status is never rewritten after headers are sent — a failure after the
first byte is always this in-band record, which is why the [DONE] check is
the reliable completion signal.
Anthropic streaming (/v1/messages)
Streaming on the Messages dialect keeps Anthropic's native event sequence —
message_start, content_block_start, content_block_delta, …,
message_delta, message_stop — with the same side-channel headers. An
interruption surfaces as the Anthropic-dialect error record and the stream ends
without message_stop.