From 2ec3490b40ce0a21008ec8c092c560935b759a14 Mon Sep 17 00:00:00 2001 From: Tai An Date: Sat, 1 Aug 2026 18:24:05 -0700 Subject: [PATCH] fix(anthropic): count billed cache tokens in token usage (#6768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../llms/providers/anthropic/completion.py | 13 +++- .../tests/llms/anthropic/test_anthropic.py | 61 ++++++++++++++++++- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/lib/crewai/src/crewai/llms/providers/anthropic/completion.py b/lib/crewai/src/crewai/llms/providers/anthropic/completion.py index 7deecbf609..5f153f5fd5 100644 --- a/lib/crewai/src/crewai/llms/providers/anthropic/completion.py +++ b/lib/crewai/src/crewai/llms/providers/anthropic/completion.py @@ -1971,10 +1971,19 @@ def _extract_anthropic_token_usage( cache_creation_tokens = ( getattr(usage, "cache_creation_input_tokens", 0) or 0 ) + # Anthropic reports cache reads/writes as counters *separate* from + # ``input_tokens``, and bills both (writes at 1.25x, reads at 0.1x). + # Other providers (OpenAI, Gemini) already fold cached tokens into + # their prompt count and expose the cached figure as a breakdown, so + # report the billed input here to match. Leaving them out made + # ``total_tokens`` undercount every prompt-cached request. + billed_input_tokens = ( + input_tokens + cache_read_tokens + cache_creation_tokens + ) result: dict[str, Any] = { - "input_tokens": input_tokens, + "input_tokens": billed_input_tokens, "output_tokens": output_tokens, - "total_tokens": input_tokens + output_tokens, + "total_tokens": billed_input_tokens + output_tokens, "cached_prompt_tokens": cache_read_tokens, "cache_creation_tokens": cache_creation_tokens, } diff --git a/lib/crewai/tests/llms/anthropic/test_anthropic.py b/lib/crewai/tests/llms/anthropic/test_anthropic.py index fd21d3b8a7..08ff78fbdb 100644 --- a/lib/crewai/tests/llms/anthropic/test_anthropic.py +++ b/lib/crewai/tests/llms/anthropic/test_anthropic.py @@ -1653,13 +1653,70 @@ def test_anthropic_cache_creation_tokens_extraction(): mock_response.model = None usage = llm._extract_anthropic_token_usage(mock_response) - assert usage["input_tokens"] == 100 + # Anthropic keeps cache reads/writes out of ``input_tokens``; both are + # billed, so the reported prompt count is the billed input (100 + 30 + 20) + # and the total covers every billed token. + assert usage["input_tokens"] == 150 assert usage["output_tokens"] == 50 - assert usage["total_tokens"] == 150 + assert usage["total_tokens"] == 200 assert usage["cached_prompt_tokens"] == 30 assert usage["cache_creation_tokens"] == 20 +def test_anthropic_total_tokens_includes_cache_tokens(): + """Cache reads/writes are billed, so they must reach total_tokens. + + Regression test for the undercount reported in #6768: ``total_tokens`` was + ``input_tokens + output_tokens``, which omitted both Anthropic cache + counters and made the field unusable for cost estimation on any cached + workload. + """ + llm = LLM(model="anthropic/claude-3-5-sonnet-20241022") + + mock_response = MagicMock() + mock_response.content = [MagicMock(text="test response")] + mock_response.usage = MagicMock( + input_tokens=100, + output_tokens=50, + cache_read_input_tokens=30, + cache_creation_input_tokens=20, + ) + mock_response.stop_reason = None + mock_response.model = None + + usage = llm._extract_anthropic_token_usage(mock_response) + llm._track_token_usage_internal(usage) + summary = llm.get_token_usage_summary() + + # The shared normalizer recomputes the total as prompt + completion, so the + # billed input has to be in ``input_tokens`` for the total to survive it. + assert summary.total_tokens == 200 + assert summary.prompt_tokens == 150 + assert summary.completion_tokens == 50 + assert summary.cached_prompt_tokens == 30 + assert summary.cache_creation_tokens == 20 + + +def test_anthropic_total_tokens_unchanged_without_cache(): + """A request that touches no cache keeps input + output as the total.""" + llm = LLM(model="anthropic/claude-3-5-sonnet-20241022") + + mock_response = MagicMock() + mock_response.content = [MagicMock(text="test response")] + mock_response.usage = MagicMock( + input_tokens=40, + output_tokens=20, + cache_read_input_tokens=0, + cache_creation_input_tokens=0, + ) + mock_response.stop_reason = None + mock_response.model = None + + usage = llm._extract_anthropic_token_usage(mock_response) + assert usage["input_tokens"] == 40 + assert usage["total_tokens"] == 60 + + def test_anthropic_missing_cache_fields_default_to_zero(): """Test that missing cache fields default to zero.""" llm = LLM(model="anthropic/claude-3-5-sonnet-20241022")