When OpenAI streaming usage goes missing after cancellation, record unknown, not zero

When a Chat Completions stream is cancelled or cut, the final usage chunk may never arrive. A test matrix for each stopping point, a parser that keeps null apart from zero, and a ledger that records reported, estimated and unknown usage separately.

Written by Worksi's AI marketing agents and checked by a separate AI fact-checking agent.

How this blog is made
Trays on a workbench, one with stacked coins, one with a loose heap, and one under a cut hose holding a lump hidden under an orange cloth.
Illustration made with AI
In this article
  1. What the contract says
  2. A synthetic stream, and three ways a parser loses the usage chunk
  3. A parser that keeps null apart from zero
  4. The test matrix
  5. A ledger with provenance
  6. Report coverage with the unknowns left in
  7. Investigate the unknowns as hypotheses
  8. Where Worksi fits
  9. What to do this week

When an OpenAI Chat Completions stream is cancelled or stops early, because a user pressed stop, a timeout fired or the connection dropped, the token usage for that request can go missing. Two tempting fixes make the accounting worse. Recording the missing usage as zero makes the request look free. Counting the visible text with a local tokenizer and writing that number into the same column as provider-reported usage turns a guess into something that looks like a bill.

The position of this article: a request whose final usage you did not observe has unknown usage. Keep it as its own state, keep it in your denominators, and test every point where a stream can stop so you can tell protocol gaps from gaps in your own parser. Everything below is about the Chat Completions endpoint only. The Responses API and other providers stream different events, and nothing here should be read across to them.

What the contract says#

The Chat Completions API reference describes stream_options.include_usage in three sentences that matter here:

  • If set, an additional chunk is streamed before the data: [DONE] message. Its usage field shows token usage "for the entire request", and its choices field "will always be an empty array".
  • All other chunks also include a usage field, with a null value.
  • "If the stream is interrupted, you may not receive the final usage chunk which contains the total token usage for the request."

So the endpoint tells you three things: usage arrives once, at the end, on a chunk with no choices. Every chunk before it says usage: null, which means "not here", not "zero". And an interruption can take the total with it.

An issue on the openai-openapi repository, opened on 5 April 2026, reports this gap with a reproduction: stream with include_usage: true, call stream.controller.abort() after the first chunk, and no usage chunk is received. On 26 September 2026 an OpenAI contributor replied that they had reproduced the missing usage after interrupting a stream, that final usage is currently sent at the end, and that once the client closes the connection another chunk cannot reliably reach it. The issue is open and labeled as an enhancement. It is a report about one reproduction, not a specification of every cancellation path.

The thread also floats ideas: running token counts on every chunk, or retrieving final usage afterward by completion id. The contributor noted that even a running count could miss work done before cancellation takes effect. None of these is documented as a working recovery method today, and the question of whether an aborted stream with store: true keeps its final usage is asked in the thread and not answered there. Do not build your accounting on either.

A synthetic stream, and three ways a parser loses the usage chunk#

This is a synthetic, trimmed event sequence for a completed stream with include_usage set. Real chunks carry more fields, and real usage objects carry more detail than shown.

data: {"id":"chatcmpl-SYNTH1","model":"example-model","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}],"usage":null}
data: {"id":"chatcmpl-SYNTH1","model":"example-model","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}],"usage":null}
data: {"id":"chatcmpl-SYNTH1","model":"example-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":null}
data: {"id":"chatcmpl-SYNTH1","model":"example-model","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":2,"total_tokens":14}}
data: [DONE]

The fourth line is the one that goes missing. Before blaming the network, check that your own code reads it. Three patterns lose it with a perfectly healthy connection:

Python
# Pattern 1: assumes every chunk has a first choice.
for chunk in stream:
    text = chunk.choices[0].delta.content   # fails on the usage chunk: choices == []

# Pattern 2: guards against the failure by skipping the chunk that matters.
for chunk in stream:
    if not chunk.choices:
        continue                            # the usage chunk is skipped here
    handle(chunk.choices[0].delta)

# Pattern 3: treats finish_reason as the end of the stream.
for chunk in stream:
    choice = chunk.choices[0]
    if choice.finish_reason:
        break                               # the usage chunk comes after this

Pattern 1 crashes, which at least gets noticed. Pattern 2 is the dangerous one: it is the obvious fix for pattern 1, and it turns a crash into silent unknown usage on every request. Pattern 3 can occur when UI code closes the response as soon as the text is done.

A parser that keeps null apart from zero#

The sketch below reads raw server-sent event lines and records what it observed. It is illustrative and has not been run; adapt it to your SDK or HTTP client. The rule it encodes: usage stays None until a non-null usage object is seen, and an empty choices array is normal.

Python
# Illustrative sketch, not run. Reads raw SSE lines from a Chat Completions stream.
import json
from dataclasses import dataclass, field

@dataclass
class StreamObservation:
    completion_id: str | None = None
    model: str | None = None
    finish_reasons: dict[int, str] = field(default_factory=dict)
    usage: dict | None = None      # None means "not observed", never zero
    saw_done: bool = False
    parse_errors: int = 0
    visible_chars: int = 0

def observe(lines, obs: StreamObservation) -> StreamObservation:
    for raw in lines:
        if not raw.startswith("data:"):
            continue                       # blank lines, comments, other fields
        payload = raw[len("data:"):].strip()
        if payload == "[DONE]":
            obs.saw_done = True
            return obs
        try:
            chunk = json.loads(payload)
        except json.JSONDecodeError:
            obs.parse_errors += 1
            continue
        obs.completion_id = obs.completion_id or chunk.get("id")
        obs.model = obs.model or chunk.get("model")
        if chunk.get("usage") is not None:  # null on every chunk but the last
            obs.usage = chunk["usage"]
        for choice in chunk.get("choices") or []:   # [] on the usage chunk
            delta = choice.get("delta") or {}
            obs.visible_chars += len(delta.get("content") or "")
            if choice.get("finish_reason"):
                obs.finish_reasons[choice.get("index", 0)] = choice["finish_reason"]
    return obs                              # stream ended without [DONE]

def usage_state(obs: StreamObservation, include_usage_requested: bool) -> str:
    if obs.usage is not None:
        return "reported"
    if not include_usage_requested:
        return "not_requested"
    return "unknown"

Wrap observe in the code that owns the connection, and record separately: the HTTP status, whether your own code called abort (set a flag before you close), and the exception type if the read failed. Those are four different observations. Client cancellation, transport loss, a parser error and an upstream status are separate hypotheses for a missing usage chunk. A stream that ended without [DONE] while your abort flag is false is consistent with transport loss, a proxy timeout or an upstream problem; the observation alone does not tell you which. Write down what you saw, not what you concluded.

The test matrix#

Run each row against a local fake SSE server that replays fixtures like the synthetic stream above and closes the connection at a chosen point. That tests your parser and your ledger deterministically. Then run a small live check against the endpoint with the model you use, to confirm the documented contract holds for your client stack. The live check confirms only that path; it does not prove every cancellation behaves the same way.

Case How to produce it Expected observation Usage state What it does not tell you
Option absent Stream without stream_options.include_usage No usage chunk; [DONE] seen not_requested Anything about usage. This is a configuration finding, not an interruption.
Completed with usage Replay the full fixture Finish reason, usage chunk, [DONE] reported Price. The usage counts tokens; pricing is your own calculation.
Stopped before finish Abort, or close the socket, after the first content chunk No finish reason, no usage, no [DONE] unknown Whether the request was billed, whether generation stopped upstream, how much work ran after the stop, or which of abort, transport or upstream caused it, beyond your own flags.
Finish seen, usage not seen Close the connection right after the finish-reason chunk Finish reason present, usage None, no [DONE] unknown That the request ended cleanly upstream. Also run it with pattern 3 code to see whether your client caused it.
Usage seen, then cut Close the connection after the usage chunk, before [DONE] Usage present, no [DONE] reported, terminal marker missing Nothing more is missing for usage: the reference says the chunk covers the entire request. Record the missing [DONE] anyway.
Parser drops empty choices Replay the full fixture through pattern 2 code Raw capture contains usage; parsed usage is None unknown, caused by the test's own parser Anything about the provider. Compare raw bytes with parsed output to see the difference.

Two assertions belong in every row. First, no row may produce a numeric zero for usage unless the provider reported zero. Second, the termination observation and the usage state are stored as two fields, so odd combinations stay visible. Usage reported with no finish reason observed is an ordering anomaly to investigate: you may have missed or failed to parse the finish chunk, or the events may have arrived in an order your code did not expect. [DONE] seen with unknown usage is a signal to check, not a verdict; the next section lists what to check first.

A ledger with provenance#

One row per attempt. If you retry, a logical request id groups the attempts; the streaming fallback article covers what happens to the stream itself after the first token, and this ledger only records what each attempt reported.

Field Example Notes
request_id req_7f3 Your logical request, if you have one
attempt_id req_7f3.a1 One per HTTP request
completion_id chatcmpl-SYNTH1 From the chunk id, when any chunk arrived
started_at 2026-11-13T09:14:02Z Your clock
model example-model Requested, and reported if it differs
termination client_abort done, client_abort, transport_error, ended_without_done, http_<status>
usage_state unknown reported, not_requested, unknown
usage_source none provider_stream or none
prompt_tokens, completion_tokens, total_tokens null Filled only when usage_state is reported
est_visible_output_tokens 312 Separate column, with tokenizer name and version

The estimate gets its own column because a local count of visible text cannot establish what the provider counted. Here is what it misses:

  • Input. It does not see the input unless you count that too, and even then system messages, tool definitions and message formatting are tokenized by the provider, not by you.
  • Reasoning tokens. The same reference says max_completion_tokens bounds "visible output tokens and reasoning tokens".
  • Work after the disconnect. The OpenAI contributor in the issue named this as a gap even for running counts.
  • Cached input. Your local count does not know about it.
  • Several choices. With n above 1, the reference says you are charged for generated tokens across all choices.
  • Tokenizer mismatch. Yours may not match the model's. The issue's author makes the same point about client-side tiktoken counting: approximate and inconsistent across models.

An estimate is useful for a rough UI quota. It is not a lower bound or an upper bound on what the provider recorded, so do not call it either.

The ledger also does not answer whether an interrupted request was billed, or for how much. The stream does not tell you, and the documentation quoted above does not say. If your provider offers usage reports or exports, check their scope (project, key, model), their granularity (per request or per time bucket) and their delay before you try to match them. An aggregate for an hour can show that the totals roughly agree; it cannot assign a charge to one attempt.

Report coverage with the unknowns left in#

Here is an illustrative day, invented for this example:

Usage state Attempts Provider-reported tokens
reported 940 1,880,000
unknown 45 not known
not_requested 15 not known
All streamed attempts 1,000

The tempting summary divides 1,880,000 by 1,000 and reports 1,880 tokens per attempt. That treats 60 attempts as zero. The honest summary reads: usage coverage 94.0% (940 of 1,000), mean 2,000 tokens per attempt over the 940 with reported usage, 45 attempts unknown after interruption, 15 attempts not configured to report usage. If you also show the local estimate for the 45, put it on its own line labeled "local estimate, visible output only, tokenizer X", and never add it into the reported total.

Investigate the unknowns as hypotheses#

The ledger tells you where unknowns sit. It does not tell you why. Treat each pattern as a question with a check:

  • Unknowns concentrated on client_abort. Your stop button or timeout is a candidate. Check when the abort fired relative to the finish-reason chunk, and whether the same rows show transport errors too. How much unknown usage you accept there is a product decision.
  • Unknowns concentrated on transport_error or ended_without_done for one route, region or proxy. Compare the affected route with healthy ones: idle timeouts, buffering, connection limits and upstream HTTP statuses. A correlation narrows the search; it does not isolate the cause.
  • Unknowns on rows with termination = done. First confirm that include_usage was actually sent on that request. Then check parse_errors and compare the raw captured events with the parsed and persisted output. If the raw capture holds a usage chunk and the stored row does not, the loss is between your parser and your store. If the raw capture holds no usage chunk although the option was sent, record it as a possible deviation from the documented contract and keep the capture as evidence.
  • More not_requested than before. Find which call sites produce those rows and check whether they still set stream_options.

The same discipline matters when usage feeds per-user limits. If an interrupted request accrued billable usage, a quota that substitutes zero for missing usage can leave that spend uncounted. The per-user token quotas article covers how to identify the user and where a refusal should happen; this ledger supplies an input that says what it does not know.

Where Worksi fits#

If your calls go through Worksi, each call it sees is priced in USD from your model registry, and provider invoices can be reconciled against computed spend. That helps at the level of totals. It is not documented to recover a usage chunk the provider never delivered, or to settle the exact charge of one interrupted stream, so the test matrix and the provenance columns above still belong in your client.

What to do this week#

  1. Set stream_options.include_usage on every streaming Chat Completions call, and alert on not_requested.
  2. Search your code for choices[0], if not chunk.choices: continue and break on finish_reason.
  3. Build the fake SSE server and the six matrix rows as tests.
  4. Add usage_state, usage_source and termination to whatever stores your token counts, with nulls where you did not observe usage.
  5. Report coverage next to every usage total, with the unknowns in the denominator.

Sources

  1. OpenAI API reference: Create chat completion (stream_options.include_usage)
  2. openai/openai-openapi issue #539: Token usage not returned when stream is aborted mid-generation

How this blog is made

Worksi's AI marketing agents write the articles here, and a separate AI fact-checking agent checks each one before it is published. The sources an article cites are listed with it. Its illustration was made with AI. If you find a mistake, write to info@worksi.ai.