diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index 5e4f57da507..16b4d494dd9 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -619,12 +619,20 @@ def _get_effective_prompt_tokens_for_tiered_pricing(usage: Usage) -> float: """ if usage.prompt_tokens_details is not None: details = usage.prompt_tokens_details - text_tokens = float(getattr(details, "text_tokens", None) or 0) - if text_tokens == 0: - # text_tokens not set by this provider; fall back to prompt_tokens - text_tokens = float(getattr(usage, "prompt_tokens", 0) or 0) + raw_text_tokens = getattr(details, "text_tokens", None) cached_tokens = float(getattr(details, "cached_tokens", 0) or 0) cache_creation = float(getattr(details, "cache_creation_tokens", 0) or 0) + if raw_text_tokens is None: + # Some providers populate prompt_tokens_details without text_tokens. + # If prompt_tokens already covers the cache detail total, use it as + # the rolled-up input total. Otherwise cache tokens were reported + # outside prompt_tokens and must be added for the tier threshold. + prompt_tokens = float(getattr(usage, "prompt_tokens", 0) or 0) + cache_detail_tokens = cached_tokens + cache_creation + if cache_detail_tokens > 0 and prompt_tokens < cache_detail_tokens: + return prompt_tokens + cache_detail_tokens + return prompt_tokens + text_tokens = float(raw_text_tokens or 0) return text_tokens + cached_tokens + cache_creation # No prompt_tokens_details — add explicit cache fields only if they are diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index f0c6711a60a..591305aa37a 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -2594,6 +2594,73 @@ def test_custom_pricing_above_200k_cache_creation_tokens_uses_tiered_rate(): litellm.model_cost.pop(f"vertex_ai/{model_id}", None) +def test_custom_pricing_does_not_double_count_cached_tokens_when_text_tokens_missing(): + """ + Providers can send prompt_tokens_details without text_tokens while the rolled + up prompt_tokens value already includes cache tokens. Tier checks should not + add those cache tokens a second time. + """ + + model_id = "tiered-custom-pricing-anthropic-details-test" + model_info = { + "litellm_provider": "anthropic", + "mode": "chat", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 0.0000005, + "input_cost_per_token_above_200k_tokens": 0.00001, + "output_cost_per_token_above_200k_tokens": 0.0000375, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000125, + "cache_read_input_token_cost_above_200k_tokens": 0.000001, + } + cache_creation_tokens = 120_000 + + litellm.register_model(model_cost={model_id: model_info}) + try: + response = ModelResponse( + id="test-id", + created=1234567890, + model="anthropic/claude-sonnet-4-5", + object="chat.completion", + choices=[], + usage=Usage( + prompt_tokens=130_000, + completion_tokens=0, + total_tokens=130_000, + prompt_tokens_details=PromptTokensDetailsWrapper( + cache_creation_tokens=cache_creation_tokens + ), + ), + ) + + cost = completion_cost( + completion_response=response, + model="anthropic/claude-sonnet-4-5", + custom_llm_provider="anthropic", + custom_pricing=True, + router_model_id=model_id, + ) + + non_cache_prompt_tokens = 10_000 + expected_cost = ( + non_cache_prompt_tokens * model_info["input_cost_per_token"] + + cache_creation_tokens * model_info["cache_creation_input_token_cost"] + ) + unexpected_tiered_cost = ( + non_cache_prompt_tokens + * model_info["input_cost_per_token_above_200k_tokens"] + + cache_creation_tokens + * model_info["cache_creation_input_token_cost_above_200k_tokens"] + ) + + assert cost == pytest.approx(expected_cost) + assert cost != pytest.approx(unexpected_tiered_cost) + finally: + litellm.model_cost.pop(model_id, None) + litellm.model_cost.pop(f"anthropic/{model_id}", None) + + def test_extract_cache_read_tokens_anthropic_top_level(): from litellm.proxy.spend_tracking.savings import extract_cache_read_tokens as _extract_cache_read_tokens