From aa7dcb286a085d17cb9b3d2e88f31fc8cd45eab6 Mon Sep 17 00:00:00 2001 From: fei <204683769+feiiiiii5@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:56:47 +0800 Subject: [PATCH 1/3] fix(telemetry): include Anthropic cache tokens in total token extraction extract_token_usage() had no key names for Anthropic's cache_creation_input_tokens / cache_read_input_tokens fields, so total_tokens silently dropped cached tokens for Anthropic-backed calls. These tokens are billed by the provider and feed into backend high-usage anomaly checks via LLM_TOKENS_TOTAL, so under-reporting has operational impact beyond cost display. Extract both cache fields and add them to the computed total when no explicit total is provided; behavior is unchanged when the fields are absent or an explicit total_tokens is present. Fixes #2572 Signed-off-by: fei <204683769+feiiiiii5@users.noreply.github.com> --- .../src/rhesis/telemetry/token_extraction.py | 20 +++++++++++- tests/sdk/telemetry/test_token_extraction.py | 32 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 tests/sdk/telemetry/test_token_extraction.py diff --git a/packages/rhesis/src/rhesis/telemetry/token_extraction.py b/packages/rhesis/src/rhesis/telemetry/token_extraction.py index 549c912ea0..7e99b3ab57 100644 --- a/packages/rhesis/src/rhesis/telemetry/token_extraction.py +++ b/packages/rhesis/src/rhesis/telemetry/token_extraction.py @@ -182,8 +182,26 @@ def extract_token_usage(usage: Union[Dict, Any]) -> Tuple[int, int, int]: ], ) + # Anthropic cache tokens (billed separately but part of actual usage) + cache_creation_tokens = get_first_value( + usage, + [ + "cache_creation_input_tokens", + "cacheCreationInputTokens", # camelCase variant + ], + ) + cache_read_tokens = get_first_value( + usage, + [ + "cache_read_input_tokens", + "cacheReadInputTokens", # camelCase variant + ], + ) + # Calculate total if not explicitly provided if not total_tokens and (input_tokens or output_tokens): - total_tokens = input_tokens + output_tokens + total_tokens = ( + input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens + ) return input_tokens, output_tokens, total_tokens diff --git a/tests/sdk/telemetry/test_token_extraction.py b/tests/sdk/telemetry/test_token_extraction.py new file mode 100644 index 0000000000..5a3cd5055e --- /dev/null +++ b/tests/sdk/telemetry/test_token_extraction.py @@ -0,0 +1,32 @@ +"""Tests for provider-agnostic token usage extraction.""" + +from rhesis.telemetry.token_extraction import extract_token_usage + + +class TestExtractTokenUsage: + def test_openai_format(self): + usage = {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30} + assert extract_token_usage(usage) == (10, 20, 30) + + def test_anthropic_format_with_cache_tokens(self): + usage = { + "input_tokens": 50, + "output_tokens": 20, + "cache_creation_input_tokens": 1000, + "cache_read_input_tokens": 4000, + } + input_tk, output_tk, total_tk = extract_token_usage(usage) + assert input_tk == 50 + assert output_tk == 20 + assert total_tk == 50 + 20 + 1000 + 4000 + + def test_anthropic_format_without_cache_tokens(self): + usage = {"input_tokens": 50, "output_tokens": 20} + assert extract_token_usage(usage) == (50, 20, 70) + + def test_gemini_format(self): + usage = {"prompt_token_count": 15, "candidates_token_count": 25} + assert extract_token_usage(usage) == (15, 25, 40) + + def test_none_returns_zeroes(self): + assert extract_token_usage(None) == (0, 0, 0) From da7a7f0da7fb60ea04e20f3d7d16ec79aa3353ee Mon Sep 17 00:00:00 2001 From: fei <204683769+feiiiiii5@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:02:45 +0800 Subject: [PATCH 2/3] fix(telemetry): compute total when only cache tokens are present Widen the total-computation condition to include cache tokens per peqy review feedback: a provider returning only cache hits with zero input/output would previously report total=0. Signed-off-by: fei <204683769+feiiiiii5@users.noreply.github.com> --- packages/rhesis/src/rhesis/telemetry/token_extraction.py | 2 +- tests/sdk/telemetry/test_token_extraction.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/rhesis/src/rhesis/telemetry/token_extraction.py b/packages/rhesis/src/rhesis/telemetry/token_extraction.py index 7e99b3ab57..889d5d70c2 100644 --- a/packages/rhesis/src/rhesis/telemetry/token_extraction.py +++ b/packages/rhesis/src/rhesis/telemetry/token_extraction.py @@ -199,7 +199,7 @@ def extract_token_usage(usage: Union[Dict, Any]) -> Tuple[int, int, int]: ) # Calculate total if not explicitly provided - if not total_tokens and (input_tokens or output_tokens): + if not total_tokens and (input_tokens or output_tokens or cache_creation_tokens or cache_read_tokens): total_tokens = ( input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens ) diff --git a/tests/sdk/telemetry/test_token_extraction.py b/tests/sdk/telemetry/test_token_extraction.py index 5a3cd5055e..c3d6d60452 100644 --- a/tests/sdk/telemetry/test_token_extraction.py +++ b/tests/sdk/telemetry/test_token_extraction.py @@ -30,3 +30,7 @@ def test_gemini_format(self): def test_none_returns_zeroes(self): assert extract_token_usage(None) == (0, 0, 0) + + def test_cache_only_tokens(self): + usage = {"cache_creation_input_tokens": 1000, "cache_read_input_tokens": 4000} + assert extract_token_usage(usage) == (0, 0, 5000) From a1d1c660adf7d24ff58f8d48469c031b91b9958b Mon Sep 17 00:00:00 2001 From: fei <204683769+feiiiiii5@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:31:02 +0800 Subject: [PATCH 3/3] fix(telemetry): keep cache tokens when usage arrives as an object extract_token_usage()'s attribute path built its dict from a common_attrs list missing the two Anthropic cache keys, so a native Usage object dropped cache_creation_input_tokens / cache_read_input_tokens whenever input/output tokens made the dict truthy (it never reached model_dump()). Add both keys, and cover the explicit-total contract: a provider-supplied total_tokens is honored as-is instead of being recomputed with cache tokens double-counted. Signed-off-by: fei <204683769+feiiiiii5@users.noreply.github.com> --- .../src/rhesis/telemetry/token_extraction.py | 5 ++++ tests/sdk/telemetry/test_token_extraction.py | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/packages/rhesis/src/rhesis/telemetry/token_extraction.py b/packages/rhesis/src/rhesis/telemetry/token_extraction.py index 889d5d70c2..716702a690 100644 --- a/packages/rhesis/src/rhesis/telemetry/token_extraction.py +++ b/packages/rhesis/src/rhesis/telemetry/token_extraction.py @@ -119,6 +119,11 @@ def extract_token_usage(usage: Union[Dict, Any]) -> Tuple[int, int, int]: "candidates_token_count", "total_token_count", "generated_tokens", + # Anthropic cache tokens: without these the object-to-dict path + # drops them whenever input/output tokens make usage_dict truthy, + # so a native Usage object never reaches model_dump(). + "cache_creation_input_tokens", + "cache_read_input_tokens", ] for attr in common_attrs: if hasattr(usage, attr): diff --git a/tests/sdk/telemetry/test_token_extraction.py b/tests/sdk/telemetry/test_token_extraction.py index c3d6d60452..7a4129e5e6 100644 --- a/tests/sdk/telemetry/test_token_extraction.py +++ b/tests/sdk/telemetry/test_token_extraction.py @@ -24,6 +24,32 @@ def test_anthropic_format_without_cache_tokens(self): usage = {"input_tokens": 50, "output_tokens": 20} assert extract_token_usage(usage) == (50, 20, 70) + def test_anthropic_usage_object_keeps_cache_tokens(self): + """A native Usage object goes through attribute extraction; cache keys must survive it.""" + + class Usage: + input_tokens = 50 + output_tokens = 20 + total_tokens = None + cache_creation_input_tokens = 1000 + cache_read_input_tokens = 4000 + + # Pre-fix, common_attrs dropped the cache keys while input/output made + # usage_dict truthy, so the object never reached model_dump(). + assert extract_token_usage(Usage()) == (50, 20, 5070) + + def test_explicit_total_is_honored_with_cache_tokens(self): + """An explicit provider total is used as-is: cache tokens are already inside it.""" + usage = { + "input_tokens": 50, + "output_tokens": 20, + "total_tokens": 70, + "cache_creation_input_tokens": 1000, + "cache_read_input_tokens": 4000, + } + # Recomputing here would double-count cache tokens (5150 != 70). + assert extract_token_usage(usage) == (50, 20, 70) + def test_gemini_format(self): usage = {"prompt_token_count": 15, "candidates_token_count": 25} assert extract_token_usage(usage) == (15, 25, 40)