OpenAI logs zero tokens on streamed calls unless you ask for them
Chat Completions reports no usage object on a streamed call without stream_options include_usage — so every streamed call meters as zero tokens, prices as $0.00, and reads as free.
Stream a Chat Completions call and read the usage off the response, and you will get a number. The number is zero. Not null, not absent — zero, which your cost pipeline will happily multiply by a rate and record as a free call.
The mechanics: OpenAI's Chat Completions API reports no usage object at all on a streamed response unless the request explicitly asks for one:
{ "stream": true, "stream_options": { "include_usage": true } }Without the flag, the SSE chunks carry deltas and a finish reason and nothing about tokens. Any telemetry that folds "no usage seen" into a zero — which is what almost every accumulator does by initialising counters to 0 — records the call as costing nothing. Multiply by a streaming-heavy workload and your dashboard is under-reporting most of your spend while looking perfectly healthy. This is the worst kind of metering bug: it produces a confident wrong number, on the traffic most products route the most volume through.
The flag has a side effect your client may not expect
Set include_usage and OpenAI appends one extra chunk to the stream — after the final content chunk, before data: [DONE] — with an empty choices array and the usage object attached. That empty array is the gotcha for anyone injecting the flag on someone else's behalf: client code written before the flag existed frequently does chunk.choices[0].delta without a length check, and the usage-only chunk crashes it.
So a proxy that wants usage on streamed calls has a two-sided obligation. On the request side, inject the flag — but only when the caller has not touched stream_options themselves. If the field is present at all, even as {include_usage: false}, it is the caller's decision and gets forwarded verbatim; overriding an explicit false is rewriting someone's stated intent. On the response side, symmetrically: strip the usage-only chunk only when the injected flag caused it. If the caller asked for usage, that chunk is theirs — deleting it removes data they requested. The licence to remove is strictly "we caused it"; it is the one deletion Vigil ever performs on a response body.
Identifying the chunk safely needs both halves of its signature: choices present and empty, and a usage object attached. Either test alone deletes the wrong thing — xAI puts usage on a chunk that still carries content, and Azure sends an empty-choices annotation frame with no usage. The conjunction matches exactly the shape the flag appends and nothing else.
The other streaming-usage traps, while you are in there
Wiring this taught us the neighbouring failure modes, all of the same species — a silently wrong number rather than an error:
- Usage is not on the last chunk. xAI reports it on the second-to-last, followed by an empty chunk before
[DONE]. Code that latches "the final chunk's usage" reads the wrong one and logs zero. The robust rule: take usage from whichever chunk carries it, and never let a later chunk without usage clear what an earlier one reported. - The first chunk can be empty, with a blank model string. Azure's annotation frame has
choices: []andmodel: ""— andtypeof chunk.model === "string"is true for"", so naive capture overwrites a real model name with nothing, after which the row attributes to an empty string and prices to null. Check emptiness, not just type. - The final chunk may carry content, and comments arrive mid-stream. OpenRouter's last chunk has a non-empty
choicesarray, explicitly unlike OpenAI's layout, and it sends periodic SSE comment lines (: keepalive) that a parser must skip rather than choke on. data: [DONE]is a courtesy, not a contract. It is absent from current reference pages and unconfirmed on several compatible providers. Settle your accounting when the stream ends, not when a sentinel arrives.
The theme: "OpenAI-compatible" describes the request format, not the streaming behaviour. Every compatible provider deviates somewhere, each deviation produces a zero or a misattribution rather than an exception, and the fix is per-provider knowledge, not a try/catch — the same lesson as the per-platform pricing differences, one layer down the stack.
One rule above all of it: forward first
Whatever the telemetry does, the customer's bytes come first. The proxy's fast path enqueues each chunk downstream before parsing a copy for usage, so a malformed chunk can never stall or sever a stream that was flowing — telemetry failure degrades to a null cost, never to a broken response. The only path that buffers even one event is the stripping path, licensed by our own injected flag and bounded by a single SSE event. If you build any streaming middleware, that ordering — forward, then observe — is the difference between a metering bug and an outage. It is also why streaming exists at all on long calls: the transport that saves the call must not gain a new way to kill it. How Vigil handles each provider's streaming quirks is on the FAQ.
What to do
If you call Chat Completions with stream: true anywhere, check whether stream_options.include_usage is set — and if it is not, check what your cost tracking recorded for those calls. A run of $0.00 streamed calls is this bug wearing a friendly face. Then audit your accumulator for the neighbouring traps: usage latched from "the last chunk", model captured without an emptiness check, and settlement gated on [DONE]. Each one is a one-line fix and a silently wrong number until it is fixed.