fix(anthropic): count billed cache tokens in token usage (#6768) - #6770
fix(anthropic): count billed cache tokens in token usage (#6768)#6770Anai-Guo wants to merge 1 commit into
Conversation
) Anthropic reports cache reads (cache_read_input_tokens) and cache writes (cache_creation_input_tokens) as counters separate from input_tokens, and bills both. _extract_anthropic_token_usage captured them but left them out of the prompt and total counts, so every prompt-cached request reported a total_tokens well below what was billed, and the undercount grew with cache hit rate. Report the billed input instead. This matches the convention the other providers already use — OpenAI and Gemini fold cached tokens into their prompt count and expose the cached figure as a breakdown — and it is what UsageMetrics.from_provider_dict assumes: it recomputes the total as prompt + completion and discards the provider total, so the cache tokens have to be in the prompt count to survive normalization. Requests that touch no cache are unaffected.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughChangesAnthropic token accounting
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
ErenAta16
left a comment
There was a problem hiding this comment.
The direction is right and, unusually for this kind of fix, it is complete in one edit. _extract_anthropic_token_usage is the only place cache_read_input_tokens and cache_creation_input_tokens are read in completion.py, and it is called from all six paths:
1006 sync completion
1240 streaming final_message
1445 tool-call follow-up
1554 async completion
1764 async streaming final_message
1870 async tool-call follow-up
so streaming and the tool follow-ups are covered without separate edits. That is worth stating in the PR body, since the usual failure mode for a usage fix is landing it on the non-streaming path only.
The detail that makes the fix necessary rather than cosmetic is the one your second test pins: the shared normalizer recomputes the total as prompt plus completion, so putting the cache counts anywhere other than input_tokens would have them silently dropped again one layer up. Asserting through get_token_usage_summary() rather than only on the dict is what actually proves that, and it is the assertion I would protect if this ever gets refactored.
One correction to the stated motivation. The comment cites the billing multipliers:
writes at 1.25x, reads at 0.1x
and the test docstring says the field was "unusable for cost estimation". Both are true, but folding at face value does not make total_tokens usable for cost estimation either. 100 cache-read tokens and 100 fresh input tokens now contribute equally to total_tokens while differing tenfold in price. What the fix actually delivers is a correct token count, which is the right thing for a field named total_tokens to be, and it makes cached_prompt_tokens and cache_creation_tokens load-bearing rather than informational, since a cost estimate has to reconstruct the split from them. I would reword the docstring to claim the token count rather than the cost, otherwise the next person reading it will assume total_tokens * price is meaningful.
The double-count trap, and why it deserves a changelog line. input_tokens now includes both cache counters while cached_prompt_tokens and cache_creation_tokens still sit beside it. Anyone who wrote
usage["input_tokens"] + usage["cached_prompt_tokens"]against the current shape, which was the correct way to get billed input before this PR, now double counts. The changed assertion in test_anthropic_cache_creation_tokens_extraction from 100 to 150 is the honest signal that this is a public-number change rather than a bug fix that nobody can observe. Existing dashboards will show a step change on cached workloads. That is the right outcome, but it should be announced rather than discovered.
test_anthropic_total_tokens_unchanged_without_cache is a good inclusion for exactly that reason: it draws the line showing uncached workloads see no movement at all, which is what most users will want to confirm first.
Fixes #6768.
Problem
Anthropic reports cache reads (
cache_read_input_tokens) and cache writes (cache_creation_input_tokens) as counters that are separate frominput_tokens, and bills both (writes at 1.25×, reads at 0.1×)._extract_anthropic_token_usagecaptured both values and surfaced them individually, but left them out of the totals — so every prompt-cached request reported atotal_tokenswell below what was billed, and the undercount grew with cache hit rate.Why the one-line fix in the issue isn't enough
The issue points at
total_tokens: input_tokens + output_tokens. Fixing only that line does not change what users actually read (llm._token_usage/get_token_usage_summary()), becauseUsageMetrics.from_provider_dictdiscards the provider's total and recomputes it:Verified against
main:So the cache tokens have to be part of the prompt count to survive normalization.
Fix
Report the billed input from the Anthropic extractor:
cached_prompt_tokensandcache_creation_tokensstay as they are, now as a breakdown of that prompt count. This is the convention the other providers already follow — OpenAI'sprompt_tokensand Gemini'sprompt_token_countboth include cached tokens and expose the cached figure separately — and it is exactly whatfrom_provider_dictassumes. It also keeps the change inside the Anthropic provider: no shared normalizer change, so OpenAI/Gemini/Azure numbers are untouched.Verification
Ran an old-vs-new harness against the installed
crewai==1.15.9, whosecompletion.py,usage_metrics.pyandbase_llm.pyare byte-identical tomain:total_tokensbeforeNone)Non-cached requests are unaffected; the two cached rows were undercounting 50 and 200 billed tokens respectively.
Tests (run against the patched module, then re-run against unpatched
mainto confirm they actually guard the regression):test_anthropic_total_tokens_includes_cache_tokens— new; asserts end-to-end through_track_token_usage_internal→get_token_usage_summary(), which is the path the issue's repro reads.test_anthropic_total_tokens_unchanged_without_cache— new; pins the no-cache path.test_anthropic_cache_creation_tokens_extraction— existing; itsinput_tokens/total_tokensnumbers moved to 150/200. That test's subject is cache-field extraction (cached_prompt_tokens == 30,cache_creation_tokens == 20, both unchanged); the totals were incidental and encoded the undercount.test_anthropic_missing_cache_fields_default_to_zero— existing, unchanged, still passes.ruff==0.15.1 format --checkclean;ruff checkoutput on the touched file is identical tomain.Note
If you'd rather
input_tokenskeep mirroring the raw Anthropic field, the alternative is to havefrom_provider_dicthonor an explicit providertotal_tokens. I did not take that route: it is shared code, and Gemini'stotal_token_countincludes thinking tokens, so honoring it there would move Gemini's numbers too — a bigger change than this issue calls for. Happy to switch if you prefer that shape.🤖 Generated with Claude Code