Python: fix Anthropic streaming double-counting token usage - #7162
Python: fix Anthropic streaming double-counting token usage#7162he-yufeng wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes incorrect token accounting for Anthropic streaming responses in the Python Anthropic package by converting Anthropic’s cumulative usage snapshots into incremental usage updates that can be safely summed by ChatResponse.from_updates, aligning streaming totals with the non-streaming path.
Changes:
- Thread a per-stream
UsageDetailsaccumulator through the streaming generator to emit usage increments instead of cumulative snapshots. - Update
_process_stream_eventto optionally accept the per-stream accumulator and apply_incremental_usageto bothmessage_startandmessage_deltausage. - Add regression tests covering both the basic cumulative-output case and the server-tool cumulative-input case.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| python/packages/anthropic/agent_framework_anthropic/_chat_client.py | Adds per-stream usage reconciliation (_incremental_usage) and threads an accumulator through streaming event processing to prevent double-counting. |
| python/packages/anthropic/tests/test_anthropic_client.py | Adds regression tests ensuring streaming usage totals match Anthropic-reported cumulative totals for both output-only and input+output delta usage. |
| emitted_counts = cast("dict[str, int]", emitted) | ||
| delta: dict[str, int] = {} | ||
| for key, value in cast("dict[str, int | None]", cumulative).items(): | ||
| if value is None: | ||
| continue | ||
| delta[key] = value - emitted_counts.get(key, 0) | ||
| emitted_counts[key] = value | ||
| return cast("UsageDetails", delta) |
eavanvalkenburg
left a comment
There was a problem hiding this comment.
overall this is fine, but it is redoing stuff already covered in the core library
| return usage_details | ||
|
|
||
| @staticmethod | ||
| def _incremental_usage(cumulative: UsageDetails, emitted: UsageDetails | None) -> UsageDetails: |
There was a problem hiding this comment.
we have a built-in helper for this: _types.add_usage_details
Python Test Coverage Report •
Python Unit Test Overview
|
||||||||||||||||||||||||||||||
|
Hey @he-yufeng can you please address the comments on the PR if you're still working on it? |
- accumulate the emitted totals in a plain dict instead of string-cast TypedDict views, so static checkers see real types throughout - compute the increment through _types.add_usage_details with negated emitted totals instead of a hand-rolled subtraction loop; keys absent from a snapshot stay untouched, matching the partial-delta semantics
|
Both addressed in e198d31:
|
Motivation & Context
On the Anthropic streaming path, usage tokens are double-counted.
_process_stream_eventemits a usageContenton bothmessage_startandmessage_delta, andChatResponse.from_updatessums every usageContentit sees. But Anthropic'smessage_deltausage is a cumulative total for the message, not a per-delta increment (from their streaming docs: "The token counts shown in the usage field of the message_delta event are cumulative"). Summing themessage_startseed onto the cumulativemessage_deltatotal inflatesusage_details:output_token_countlands at 26 when the API reported 25 (the seed isoutput_tokens: 1), and for server-tool turns that also report cumulative input onmessage_delta, the prompt tokens get double-counted too.The streaming total should match what the non-streaming path produces.
Description & Review Guide
from_updatesexpects each update's usage to be an increment it can sum._process_stream_eventnow takes a per-streamemitted_usageaccumulator and emits the increment of each snapshot over what has already been emitted, so the summation reconstructs the final cumulative usage instead of inflating it. The accumulator lives in the per-request_stream()generator rather than on the client, so concurrent streams don't interfere, and when no accumulator is passed (a direct single-event call) the snapshot is returned unchanged, so existing callers and tests are untouched.response.usage_detailsafter a streaming Anthropic call now equals the cumulative totals the API reported, matching the non-streaming path. This fixes both the unconditional output-seed inflation and the server-tool input double-count._incremental_usage, and thatmessage_start's input-side counts are preserved whenmessage_deltacarries onlyoutput_tokens(the basic case).Two regression tests cover the basic case (output 25 / input 10, not 26) and the server-tool case where
message_deltaalso reports cumulative input (output 25 / input 12, not doubled).Related Issue
Fixes #7143
Contribution Checklist