Streaming message_delta erases accumulated usage
Your streamed calls report less than they cost and your non-streamed calls are perfect. That asymmetry has one common cause: the final event was assigned to your usage variable instead of added to it, discarding everything counted before it.
How usage arrives on a stream
A streamed Anthropic response spreads usage across events rather than handing you one object at
the end. message_start carries the input side — including the cache fields.
message_delta arrives last and carries the output side.
event: message_start
data: {"message": {"usage": {
"input_tokens": 25,
"cache_read_input_tokens": 75,
"output_tokens": 1
}}}
event: message_delta
data: {"usage": {"output_tokens": 412}}
Note what the final event does not contain: input_tokens, and neither cache
field. It is a delta, not a summary. Anything that treats it as a summary loses the input side
entirely.
The bug
# WRONG — the last event replaces everything counted before it
async for event in stream:
if event.usage:
usage = event.usage # input_tokens and cache reads: gone
# RIGHT — the input side is captured once, the output side accumulates
async for event in stream:
if event.type == "message_start":
usage = event.message.usage
elif event.type == "message_delta":
usage.output_tokens = event.usage.output_tokens
In the wrong version the recorded input is whatever the final delta happened to carry, which is usually nothing at all. On a cache-heavy agent workload — where the input side is the expensive side — this discards the majority of the bill. I fixed this in mcp-use #2127, alongside uncounted Anthropic cache tokens in the same code path.
This is a different defect from the cache-token one and they frequently coexist, because both live in the same handler. Fixing only the cache fields still leaves streamed calls wrong; fixing only the accumulation still leaves cached calls wrong. The cache-token defect →
Why it survives review
The code reads correctly. if event.usage: usage = event.usage looks like
defensive assignment — take the usage whenever the event has usage. It is only wrong because
of a protocol detail that is not visible at the call site: that later events carry a subset of
the fields, not a superset.
Nothing raises. The stream completes, the text is correct, the usage object exists and has plausible integers in it. The only observable symptom is a number on a dashboard that is too small, months later.
The test that catches it
Assert against a recorded event sequence rather than a live call, so the test is deterministic and runs in CI:
def test_streamed_usage_keeps_the_input_side():
events = [
message_start(input_tokens=25, cache_read_input_tokens=75, output_tokens=1),
content_delta("..."),
message_delta(output_tokens=412),
]
usage = collect_usage(events)
# the input side survives the final delta
assert usage.input_tokens == 25
assert usage.cache_read_input_tokens == 75
# and the output side is the final value, not the first
assert usage.output_tokens == 412
The first two assertions fail on the buggy implementation and pass on the fixed one. That is
the whole test. Every fix I ship carries one like it, because a correctness fix without a test
that fails on main is just a diff someone will undo.
Checking your own stack
- Split one production day's reconciliation by
stream=Trueandstream=False. - If the gap sits almost entirely on the streamed side, this is your defect.
- Find every handler that reads usage from a stream event and check whether it assigns or accumulates.
- Check gateways too — a proxy that re-emits events can drop
message_startfields on the way through.
If your reported spend and your provider invoice disagree, one of them is lying. Tell me which providers, libraries and gateways sit in your path and I'll tell you where to look first — before you engage me. arthi1805@gmail.com
Related: OpenAI prompt_tokens vs Anthropic input_tokens
· Why your Claude bill is higher than your dashboard