From 3203cd9895643b9c157f999817a06b13747d66a4 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 11 Mar 2026 20:10:42 -0700 Subject: [PATCH] [Fix] UI - Usage: Cached tokens always showing zero for OpenAI models OpenAI stores cached tokens under prompt_tokens_details.cached_tokens, but the daily spend writer only checked the top-level cache_read_input_tokens field (used by Anthropic/DeepSeek). Added fallback to read from prompt_tokens_details when the top-level field is absent. Co-Authored-By: Claude Opus 4.6 --- litellm/proxy/db/db_spend_update_writer.py | 44 ++++++++++++++--- .../proxy/db/test_db_spend_update_writer.py | 49 +++++++++++++++++++ 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/db/db_spend_update_writer.py b/litellm/proxy/db/db_spend_update_writer.py index 28b1e6601b1..ba46b44ac0d 100644 --- a/litellm/proxy/db/db_spend_update_writer.py +++ b/litellm/proxy/db/db_spend_update_writer.py @@ -1852,6 +1852,38 @@ class DBSpendUpdateWriter: unique_constraint_name="tag_date_api_key_model_custom_llm_provider_mcp_namespaced_tool_name_endpoint", ) + @staticmethod + def _get_cache_read_input_tokens(usage_obj: dict) -> int: + """ + Extract cache read input tokens from a usage object dict. + + Anthropic/DeepSeek store this as a top-level `cache_read_input_tokens` field. + OpenAI stores it nested under `prompt_tokens_details.cached_tokens`. + """ + value = usage_obj.get("cache_read_input_tokens", 0) or 0 + if value: + return value + prompt_details = usage_obj.get("prompt_tokens_details") or {} + if isinstance(prompt_details, dict): + return prompt_details.get("cached_tokens", 0) or 0 + return 0 + + @staticmethod + def _get_cache_creation_input_tokens(usage_obj: dict) -> int: + """ + Extract cache creation input tokens from a usage object dict. + + Anthropic stores this as a top-level `cache_creation_input_tokens` field. + It may also appear under `prompt_tokens_details.cache_creation_tokens`. + """ + value = usage_obj.get("cache_creation_input_tokens", 0) or 0 + if value: + return value + prompt_details = usage_obj.get("prompt_tokens_details") or {} + if isinstance(prompt_details, dict): + return prompt_details.get("cache_creation_tokens", 0) or 0 + return 0 + async def _common_add_spend_log_transaction_to_daily_transaction( self, payload: Union[dict, SpendLogsPayload], @@ -1932,12 +1964,12 @@ class DBSpendUpdateWriter: api_requests=1, successful_requests=1 if request_status == "success" else 0, failed_requests=1 if request_status != "success" else 0, - cache_read_input_tokens=usage_obj.get("cache_read_input_tokens", 0) - or 0, - cache_creation_input_tokens=usage_obj.get( - "cache_creation_input_tokens", 0 - ) - or 0, + cache_read_input_tokens=self._get_cache_read_input_tokens( + usage_obj + ), + cache_creation_input_tokens=self._get_cache_creation_input_tokens( + usage_obj + ), ) return daily_transaction except Exception as e: diff --git a/tests/test_litellm/proxy/db/test_db_spend_update_writer.py b/tests/test_litellm/proxy/db/test_db_spend_update_writer.py index b5f82ef04c5..a129d96c1bb 100644 --- a/tests/test_litellm/proxy/db/test_db_spend_update_writer.py +++ b/tests/test_litellm/proxy/db/test_db_spend_update_writer.py @@ -1428,3 +1428,52 @@ async def test_commit_spend_updates_uses_pipeline(): mock_redis_update_buffer.get_all_daily_end_user_spend_update_transactions_from_redis_buffer.assert_not_called() mock_redis_update_buffer.get_all_daily_agent_spend_update_transactions_from_redis_buffer.assert_not_called() mock_redis_update_buffer.get_all_daily_tag_spend_update_transactions_from_redis_buffer.assert_not_called() + + +class TestGetCacheTokenHelpers: + """Tests for _get_cache_read_input_tokens and _get_cache_creation_input_tokens. + + These helpers extract cache token counts from usage objects. Anthropic/DeepSeek + store them as top-level fields, while OpenAI nests them under prompt_tokens_details. + """ + + def test_cache_read_from_top_level_field(self): + """Anthropic-style: cache_read_input_tokens at top level.""" + usage = {"cache_read_input_tokens": 500} + assert DBSpendUpdateWriter._get_cache_read_input_tokens(usage) == 500 + + def test_cache_read_from_prompt_tokens_details(self): + """OpenAI-style: cached_tokens nested in prompt_tokens_details.""" + usage = {"prompt_tokens_details": {"cached_tokens": 300}} + assert DBSpendUpdateWriter._get_cache_read_input_tokens(usage) == 300 + + def test_cache_read_top_level_takes_precedence(self): + """When both are present, top-level field wins.""" + usage = { + "cache_read_input_tokens": 500, + "prompt_tokens_details": {"cached_tokens": 300}, + } + assert DBSpendUpdateWriter._get_cache_read_input_tokens(usage) == 500 + + def test_cache_read_empty_usage(self): + assert DBSpendUpdateWriter._get_cache_read_input_tokens({}) == 0 + + def test_cache_read_none_values(self): + usage = { + "cache_read_input_tokens": None, + "prompt_tokens_details": None, + } + assert DBSpendUpdateWriter._get_cache_read_input_tokens(usage) == 0 + + def test_cache_creation_from_top_level_field(self): + """Anthropic-style: cache_creation_input_tokens at top level.""" + usage = {"cache_creation_input_tokens": 200} + assert DBSpendUpdateWriter._get_cache_creation_input_tokens(usage) == 200 + + def test_cache_creation_from_prompt_tokens_details(self): + """cache_creation_tokens nested in prompt_tokens_details.""" + usage = {"prompt_tokens_details": {"cache_creation_tokens": 150}} + assert DBSpendUpdateWriter._get_cache_creation_input_tokens(usage) == 150 + + def test_cache_creation_empty_usage(self): + assert DBSpendUpdateWriter._get_cache_creation_input_tokens({}) == 0