diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 1f405de9817..2194b384a23 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -2109,6 +2109,11 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): """Anthropic reports prompt cache tokens as top-level ``cache_read_input_tokens`` / ``cache_creation_input_tokens``; no other API surface uses those keys, and the Responses API mapping would silently drop them. + + Requiring a cache key is deliberate: Responses API usage also carries top-level + ``input_tokens``, so the cache keys are the only shape discriminator between the + two. A cache-free Anthropic payload falls through to the Responses API mapping, + which is safe because both mappings agree whenever no cache tokens are present. """ if "prompt_tokens" in usage_object or "input_tokens" not in usage_object: return False diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index 34fbea95e5b..231d3b48754 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -5867,3 +5867,19 @@ def test_is_anthropic_usage_object_distinguishes_chat_usage(): ).model_dump() ) assert not AnthropicConfig.is_anthropic_usage_object({"input_tokens": 3, "output_tokens": 5}) + + +def test_is_anthropic_usage_object_rejects_responses_api_usage(): + """completion_cost checks the Anthropic shape before the Responses API shape, so a + Responses API usage payload, whose cache reads live in nested input_tokens_details, + must never match; matching would route it past the converter that reads the nested + field and its cache reads would be billed at the full input rate.""" + assert not AnthropicConfig.is_anthropic_usage_object( + { + "input_tokens": 4017, + "output_tokens": 5, + "total_tokens": 4022, + "input_tokens_details": {"cached_tokens": 4014}, + "output_tokens_details": {"reasoning_tokens": 0}, + } + ) diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py index 77b6f902368..a268bdb640c 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py @@ -989,6 +989,13 @@ class TestTranslateResponse: "cache_read_input_tokens": 4004, } + def test_missing_usage_maps_to_zero_tokens(self): + """A response without a usage object must map to zeroed Anthropic usage.""" + assert LiteLLMAnthropicToResponsesAPIAdapter.translate_responses_api_usage_to_anthropic_usage(None) == { + "input_tokens": 0, + "output_tokens": 0, + } + def test_model_and_id_preserved(self): """Model and response ID from the Responses API are forwarded.""" response = _make_mock_response(