From 5ec49de92a058a020a52292711d1fd27f188903d Mon Sep 17 00:00:00 2001 From: xiaoyuyu6420 <93528429+xiaoyuyu6420@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:27:17 +0800 Subject: [PATCH] fix(provider): count Anthropic cache_creation tokens in input usage Anthropic's input_tokens only covers tokens after the last cache breakpoint; cache_creation_input_tokens (tokens written into a new cache entry) were dropped entirely from TokenUsage, so usage.input/total undercounted requests that create a fresh cache entry. Fold cache_creation_input_tokens into input_other in both _extract_usage and the streaming _update_usage, matching the OpenAI provider's accounting where input_other holds all non-cache-read input (including cache writes). Official formula: total_input = cache_read + cache_creation + input_tokens. --- .../core/provider/sources/anthropic_source.py | 10 ++- tests/test_anthropic_source.py | 77 +++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 tests/test_anthropic_source.py diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index ee72bc9d44..c861ded6ba 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -445,15 +445,21 @@ def _extract_usage(self, usage: Usage | None) -> TokenUsage: if usage is None: return TokenUsage() # https://docs.claude.com/en/docs/build-with-claude/prompt-caching#tracking-cache-performance + # Anthropic's input_tokens excludes cache served reads AND writes, so + # cache_creation_input_tokens must be added back into input_other to + # keep total input (and context-occupancy stats) accurate. return TokenUsage( - input_other=usage.input_tokens or 0, + input_other=(usage.input_tokens or 0) + + (usage.cache_creation_input_tokens or 0), input_cached=usage.cache_read_input_tokens or 0, output=usage.output_tokens or 0, ) def _update_usage(self, token_usage: TokenUsage, usage: MessageDeltaUsage) -> None: if usage.input_tokens is not None: - token_usage.input_other = usage.input_tokens + token_usage.input_other = usage.input_tokens + ( + usage.cache_creation_input_tokens or 0 + ) if usage.cache_read_input_tokens is not None: token_usage.input_cached = usage.cache_read_input_tokens if usage.output_tokens is not None: diff --git a/tests/test_anthropic_source.py b/tests/test_anthropic_source.py new file mode 100644 index 0000000000..4aa0f2efb3 --- /dev/null +++ b/tests/test_anthropic_source.py @@ -0,0 +1,77 @@ +from anthropic.types import MessageDeltaUsage, Usage + +from astrbot.core.provider.entities import TokenUsage +from astrbot.core.provider.sources.anthropic_source import ProviderAnthropic + + +def _provider() -> ProviderAnthropic: + return ProviderAnthropic.__new__(ProviderAnthropic) + + +def test_anthropic_extract_usage_counts_cache_creation_input(): + provider = _provider() + + usage = provider._extract_usage( + Usage( + input_tokens=10, + cache_read_input_tokens=100, + cache_creation_input_tokens=50, + output_tokens=20, + ) + ) + + # Anthropic's input_tokens excludes cache writes, so cache_creation + # must be folded into input_other to keep total input accurate. + assert usage.input_other == 60 + assert usage.input_cached == 100 + assert usage.input == 160 + assert usage.output == 20 + + +def test_anthropic_extract_usage_without_cache_breakpoints(): + provider = _provider() + + usage = provider._extract_usage(Usage(input_tokens=30, output_tokens=10)) + + assert usage.input_other == 30 + assert usage.input_cached == 0 + assert usage.input == 30 + assert usage.output == 10 + + +def test_anthropic_extract_usage_none_returns_empty(): + provider = _provider() + + assert provider._extract_usage(None) == TokenUsage() + + +def test_anthropic_update_usage_counts_cache_creation_input(): + provider = _provider() + token_usage = TokenUsage(input_other=5, input_cached=0, output=0) + + provider._update_usage( + token_usage, + MessageDeltaUsage( + input_tokens=10, + cache_read_input_tokens=100, + cache_creation_input_tokens=50, + output_tokens=20, + ), + ) + + assert token_usage.input_other == 60 + assert token_usage.input_cached == 100 + assert token_usage.input == 160 + assert token_usage.output == 20 + + +def test_anthropic_update_usage_omitted_fields_are_preserved(): + provider = _provider() + token_usage = TokenUsage(input_other=5, input_cached=0, output=0) + + # message_delta usage only carries output tokens in practice. + provider._update_usage(token_usage, MessageDeltaUsage(output_tokens=7)) + + assert token_usage.input_other == 5 + assert token_usage.input_cached == 0 + assert token_usage.output == 7