diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index 018486cba41..0666dee12fa 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -616,6 +616,7 @@ def _get_effective_prompt_tokens_for_tiered_pricing(usage: Usage) -> float: them separate. When prompt_tokens_details is present, prefer the category fields so the threshold check can avoid double-counting cache tokens. """ + prompt_tokens = float(getattr(usage, "prompt_tokens", 0) or 0) if usage.prompt_tokens_details is not None: details = usage.prompt_tokens_details raw_text_tokens = getattr(details, "text_tokens", None) @@ -626,17 +627,29 @@ def _get_effective_prompt_tokens_for_tiered_pricing(usage: Usage) -> float: # 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 + audio_tokens = float(getattr(details, "audio_tokens", 0) or 0) + image_tokens = float(getattr(details, "image_tokens", 0) or 0) + video_tokens = float(getattr(details, "video_tokens", 0) or 0) + detail_total = ( + text_tokens + + audio_tokens + + image_tokens + + video_tokens + + cached_tokens + + cache_creation + ) + cache_detail_tokens = cached_tokens + cache_creation + if cache_detail_tokens > 0 and detail_total > prompt_tokens: + return prompt_tokens + return max(prompt_tokens, detail_total) # No prompt_tokens_details. Add explicit cache fields only if they are # not already rolled into prompt_tokens (determined by their presence). - prompt_tokens = float(getattr(usage, "prompt_tokens", 0) or 0) cache_read_tokens = float(getattr(usage, "cache_read_input_tokens", 0) or 0) cache_creation_tokens = float(getattr(usage, "cache_creation_input_tokens", 0) or 0) return prompt_tokens + cache_read_tokens + cache_creation_tokens @@ -1140,8 +1153,11 @@ def _calculate_input_cost( # First check if input_cost_per_image_token is available. If not, default to generic input_cost_per_token. image_token_cost_key = "input_cost_per_image_token" if model_info.get(image_token_cost_key) is None: - image_token_cost_key = "input_cost_per_token" - prompt_cost += calculate_cost_component(model_info, image_token_cost_key, prompt_tokens_details["image_tokens"]) + prompt_cost += float(prompt_tokens_details["image_tokens"]) * prompt_base_cost + else: + prompt_cost += calculate_cost_component( + model_info, image_token_cost_key, prompt_tokens_details["image_tokens"] + ) ### VIDEO TOKEN COST if prompt_tokens_details["video_tokens"] and not ( diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index 591305aa37a..06625b2f695 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -2661,6 +2661,63 @@ def test_custom_pricing_does_not_double_count_cached_tokens_when_text_tokens_mis litellm.model_cost.pop(f"anthropic/{model_id}", None) +def test_custom_pricing_uses_tiered_rate_when_multimodal_details_cross_threshold(): + """ + Multimodal prompt detail tokens should count toward tier thresholds even + when text_tokens is present. + """ + + model_id = "tiered-custom-pricing-multimodal-details-test" + model_info = { + "litellm_provider": "openai", + "mode": "chat", + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "input_cost_per_token_above_200k_tokens": 0.00001, + "output_cost_per_token_above_200k_tokens": 0.0000375, + } + text_tokens = 150_000 + image_tokens = 60_001 + + litellm.register_model(model_cost={model_id: model_info}) + try: + response = ModelResponse( + id="test-id", + created=1234567890, + model=f"openai/{model_id}", + object="chat.completion", + choices=[], + usage=Usage( + prompt_tokens=text_tokens + image_tokens, + completion_tokens=0, + total_tokens=text_tokens + image_tokens, + prompt_tokens_details=PromptTokensDetailsWrapper( + text_tokens=text_tokens, + image_tokens=image_tokens, + ), + ), + ) + + cost = completion_cost( + completion_response=response, + model=f"openai/{model_id}", + custom_llm_provider="openai", + custom_pricing=True, + router_model_id=model_id, + ) + + expected_cost = ( + text_tokens + image_tokens + ) * model_info["input_cost_per_token_above_200k_tokens"] + base_cost = (text_tokens + image_tokens) * model_info["input_cost_per_token"] + + assert cost == pytest.approx(expected_cost) + assert cost != pytest.approx(base_cost) + finally: + litellm.model_cost.pop(model_id, None) + litellm.model_cost.pop(f"openai/{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