From be16df73852900bdbc82aa14244b5416c99b204f Mon Sep 17 00:00:00 2001 From: Henry Su Date: Wed, 12 Aug 2026 22:06:40 -0500 Subject: [PATCH] fix(cost): honor an explicit zero cache-creation cost in batch pricing batch_cost_calculator resolved the cache-write rate with `model_info.get("cache_creation_input_token_cost") or input_cost_per_token`, so a model that prices cache writes at an explicit 0.0 was billed at the plain input rate because `or` short-circuits on a falsy 0.0. The sibling cache-read path already reads its rate through calculate_cost_component, which treats a present 0.0 as a real price. Distinguish a missing key from a present zero so the two cache buckets price consistently. --- litellm/cost_calculator.py | 3 +- tests/test_litellm/test_cost_calculator.py | 320 +++++++++------------ 2 files changed, 132 insertions(+), 191 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 6b6653c5646..15946dbb18b 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -2174,7 +2174,8 @@ def batch_cost_calculator( cache_read_cost_key: Final = _get_service_tier_cost_key("cache_read_input_token_cost", None) total_prompt_cost += calculate_cost_component(model_info, cache_read_cost_key, cache_read_tokens) / 2 - cache_creation_cost: Final = model_info.get("cache_creation_input_token_cost") or input_cost_per_token + cache_creation_rate: Final = model_info.get("cache_creation_input_token_cost") + cache_creation_cost: Final = cache_creation_rate if cache_creation_rate is not None else input_cost_per_token total_prompt_cost += cache_creation_tokens * cache_creation_cost / 2 if output_cost_per_token_batches: total_completion_cost = usage.completion_tokens * output_cost_per_token_batches diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index 3f024e2fd03..25893ea5736 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -3,9 +3,7 @@ import sys import pytest -sys.path.insert( - 0, os.path.abspath("../..") -) # Adds the parent directory to the system path +sys.path.insert(0, os.path.abspath("../..")) # Adds the parent directory to the system path from pydantic import BaseModel @@ -102,9 +100,7 @@ def test_completion_cost_uses_response_model_for_dynamic_routing(): def test_cost_calculator_with_response_cost_in_additional_headers(): class MockResponse(BaseModel): - _hidden_params = { - "additional_headers": {"llm_provider-x-litellm-response-cost": 1000} - } + _hidden_params = {"additional_headers": {"llm_provider-x-litellm-response-cost": 1000}} result = response_cost_calculator( response_object=MockResponse(), @@ -247,13 +243,12 @@ def test_cost_calculator_with_usage(monkeypatch): # Step 1: Test a model where input_cost_per_image_token is not set. # In this case the calculation should use input_cost_per_token as fallback. - assert ( - model_info.get("input_cost_per_image_token") is None - ), "Test case expects that input_cost_per_image_token is not set" + assert model_info.get("input_cost_per_image_token") is None, ( + "Test case expects that input_cost_per_image_token is not set" + ) expected_cost = ( - usage.prompt_tokens_details.audio_tokens - * model_info["input_cost_per_audio_token"] + usage.prompt_tokens_details.audio_tokens * model_info["input_cost_per_audio_token"] + usage.prompt_tokens_details.text_tokens * model_info["input_cost_per_token"] + usage.prompt_tokens_details.image_tokens * model_info["input_cost_per_token"] + usage.completion_tokens * model_info["output_cost_per_token"] @@ -288,12 +283,9 @@ def test_cost_calculator_with_usage(monkeypatch): ) expected_cost = ( - usage.prompt_tokens_details.audio_tokens - * temp_model_info_object["input_cost_per_audio_token"] - + usage.prompt_tokens_details.text_tokens - * temp_model_info_object["input_cost_per_token"] - + usage.prompt_tokens_details.image_tokens - * temp_model_info_object["input_cost_per_image_token"] + usage.prompt_tokens_details.audio_tokens * temp_model_info_object["input_cost_per_audio_token"] + + usage.prompt_tokens_details.text_tokens * temp_model_info_object["input_cost_per_token"] + + usage.prompt_tokens_details.image_tokens * temp_model_info_object["input_cost_per_image_token"] + usage.completion_tokens * temp_model_info_object["output_cost_per_token"] ) @@ -310,9 +302,7 @@ def test_transcription_cost_uses_token_pricing(): prompt_tokens=14, completion_tokens=45, total_tokens=59, - prompt_tokens_details=PromptTokensDetailsWrapper( - text_tokens=0, audio_tokens=14 - ), + prompt_tokens_details=PromptTokensDetailsWrapper(text_tokens=0, audio_tokens=14), ) response = TranscriptionResponse(text="demo text") response.usage = usage @@ -380,9 +370,7 @@ def test_handle_realtime_stream_cost_calculation(): {"type": "session.created", "session": {"model": "gpt-3.5-turbo"}}, { "type": "response.done", - "response": { - "usage": {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150} - }, + "response": {"usage": {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150}}, }, { "type": "response.done", @@ -413,9 +401,7 @@ def test_handle_realtime_stream_cost_calculation(): expected_cost = (300 * 0.0015 / 1000) + ( # input tokens (100 + 200) 150 * 0.002 / 1000 ) # output tokens (50 + 100) - assert ( - abs(cost - expected_cost) <= 0.00075 - ) # Allow small floating point differences + assert abs(cost - expected_cost) <= 0.00075 # Allow small floating point differences # Test with different model name in session results[0]["session"]["model"] = "gpt-4" @@ -495,14 +481,7 @@ def test_handle_realtime_stream_cost_calculation_stores_cost_breakdown(): assert logging_obj.cost_breakdown is not None assert logging_obj.cost_breakdown["input_cost"] > 0 assert logging_obj.cost_breakdown["output_cost"] > 0 - assert ( - abs( - logging_obj.cost_breakdown["input_cost"] - + logging_obj.cost_breakdown["output_cost"] - - total_cost - ) - < 1e-9 - ) + assert abs(logging_obj.cost_breakdown["input_cost"] + logging_obj.cost_breakdown["output_cost"] - total_cost) < 1e-9 assert abs(logging_obj.cost_breakdown["total_cost"] - total_cost) < 1e-9 @@ -576,9 +555,7 @@ def test_realtime_logging_object_allows_null_transcript_in_conversation_item_add }, ] - usage = RealtimeAPITokenUsageProcessor.collect_and_combine_usage_from_realtime_stream_results( - results=results - ) + usage = RealtimeAPITokenUsageProcessor.collect_and_combine_usage_from_realtime_stream_results(results=results) logging_result = RealtimeAPITokenUsageProcessor.create_logging_realtime_object( usage=usage, results=results, @@ -628,9 +605,7 @@ def test_realtime_logging_object_does_not_validate_unknown_event_types(): }, ] - usage = RealtimeAPITokenUsageProcessor.collect_and_combine_usage_from_realtime_stream_results( - results=results - ) + usage = RealtimeAPITokenUsageProcessor.collect_and_combine_usage_from_realtime_stream_results(results=results) # On unfixed code this raises pydantic ValidationError instead of returning. logging_result = RealtimeAPITokenUsageProcessor.create_logging_realtime_object( usage=usage, @@ -642,8 +617,7 @@ def test_realtime_logging_object_does_not_validate_unknown_event_types(): unknown_types = { r["type"] for r in logging_result.results - if r["type"] - in ("rate_limits.updated", "response.function_call_arguments.delta") + if r["type"] in ("rate_limits.updated", "response.function_call_arguments.delta") } assert unknown_types == { "rate_limits.updated", @@ -676,9 +650,7 @@ def test_realtime_transcription_duration_cost(monkeypatch): "type": "session.created", "session": { "type": "transcription", - "audio": { - "input": {"transcription": {"model": "gpt-realtime-whisper"}} - }, + "audio": {"input": {"transcription": {"model": "gpt-realtime-whisper"}}}, }, }, { @@ -693,9 +665,7 @@ def test_realtime_transcription_duration_cost(monkeypatch): }, ] - combined = RealtimeAPITokenUsageProcessor.collect_and_combine_usage_from_realtime_stream_results( - results=results - ) + combined = RealtimeAPITokenUsageProcessor.collect_and_combine_usage_from_realtime_stream_results(results=results) logging_obj = Logging( model="gpt-realtime-whisper", messages=[], @@ -788,9 +758,7 @@ def test_realtime_transcription_token_billed_fallback(monkeypatch): # gpt-4o-transcribe: input_cost_per_audio_token = 2.5e-06, input_cost_per_token = 2.5e-06, # output_cost_per_token = 1e-05 - model_info = litellm.get_model_info( - model="gpt-4o-transcribe", custom_llm_provider="openai" - ) + model_info = litellm.get_model_info(model="gpt-4o-transcribe", custom_llm_provider="openai") usage = { "type": "tokens", "input_tokens": 40, @@ -871,10 +839,7 @@ def test_get_transcription_model_falls_back_to_session_model(monkeypatch): mock_response=True, ) - assert ( - result._hidden_params["response_cost"] - > result_2._hidden_params["response_cost"] - ) + assert result._hidden_params["response_cost"] > result_2._hidden_params["response_cost"] model_info = router.get_deployment_model_info( model_id="my-unique-model-id", model_name="anthropic/claude-sonnet-4-5-20250929" @@ -1037,9 +1002,7 @@ def test_tiered_pricing_only_deployment_selects_router_model_id(): assert entry.get("input_cost_per_token") is None assert entry.get("tiered_pricing") is not None # The stripped shared alias must not carry tiered pricing. - assert ( - litellm.model_cost["dashscope/qwen-tier-only-test"].get("tiered_pricing") is None - ) + assert litellm.model_cost["dashscope/qwen-tier-only-test"].get("tiered_pricing") is None selected = _select_model_name_for_cost_calc( model="dashscope/qwen-tier-only-test", @@ -1121,9 +1084,7 @@ def test_azure_realtime_cost_calculator(): combined_usage_object=Usage( prompt_tokens=100, completion_tokens=100, - prompt_tokens_details=PromptTokensDetailsWrapper( - text_tokens=10, audio_tokens=90 - ), + prompt_tokens_details=PromptTokensDetailsWrapper(text_tokens=10, audio_tokens=90), ), custom_llm_provider="azure", litellm_model_name="my-custom-azure-deployment", @@ -1200,14 +1161,10 @@ def test_azure_audio_output_cost_calculation(): wrong_total_cost = expected_input_cost + wrong_output_cost # Verify audio tokens are NOT charged at text rate (the bug) - assert ( - abs(cost - wrong_total_cost) > 0.001 - ), "Bug: Audio tokens are being charged at text token rate" + assert abs(cost - wrong_total_cost) > 0.001, "Bug: Audio tokens are being charged at text token rate" # Verify cost matches - assert ( - abs(cost - expected_total_cost) < 0.0000001 - ), f"Expected cost {expected_total_cost}, got {cost}" + assert abs(cost - expected_total_cost) < 0.0000001, f"Expected cost {expected_total_cost}, got {cost}" def test_default_image_cost_calculator(monkeypatch): @@ -1221,9 +1178,7 @@ def test_default_image_cost_calculator(monkeypatch): monkeypatch.setattr( litellm, "model_cost", - { - "azure/bf9001cd7209f5734ecb4ab937a5a0e2ba5f119708bd68f184db362930f9dc7b": temp_object - }, + {"azure/bf9001cd7209f5734ecb4ab937a5a0e2ba5f119708bd68f184db362930f9dc7b": temp_object}, ) args = { @@ -1439,9 +1394,7 @@ def test_gemini_25_implicit_caching_cost(): expected_cost = 0.00068708 # Allow for small floating point differences - assert ( - abs(result - expected_cost) < 1e-8 - ), f"Expected cost {expected_cost}, but got {result}" + assert abs(result - expected_cost) < 1e-8, f"Expected cost {expected_cost}, but got {result}" print(f"✓ Gemini 2.5 implicit caching cost calculation is correct: ${result:.8f}") @@ -1512,9 +1465,7 @@ def test_log_context_cost_calculation(): # Get model info to understand the pricing from litellm import get_model_info - model_info = get_model_info( - model="claude-4-sonnet-20250514", custom_llm_provider="anthropic" - ) + model_info = get_model_info(model="claude-4-sonnet-20250514", custom_llm_provider="anthropic") # Calculate expected cost based on actual model pricing input_cost_per_token = model_info.get("input_cost_per_token", 0) @@ -1522,12 +1473,8 @@ def test_log_context_cost_calculation(): cache_creation_cost_per_token = model_info.get("cache_creation_input_token_cost", 0) # Check if tiered pricing is applied - input_cost_above_200k = model_info.get( - "input_cost_per_token_above_200k_tokens", input_cost_per_token - ) - output_cost_above_200k = model_info.get( - "output_cost_per_token_above_200k_tokens", output_cost_per_token - ) + input_cost_above_200k = model_info.get("input_cost_per_token_above_200k_tokens", input_cost_per_token) + output_cost_above_200k = model_info.get("output_cost_per_token_above_200k_tokens", output_cost_per_token) cache_creation_above_200k = model_info.get( "cache_creation_input_token_cost_above_200k_tokens", cache_creation_cost_per_token, @@ -1535,31 +1482,23 @@ def test_log_context_cost_calculation(): print(f"DEBUG: Base input cost per token: ${input_cost_per_token:.2e}") print(f"DEBUG: Base output cost per token: ${output_cost_per_token:.2e}") - print( - f"DEBUG: Base cache creation cost per token: ${cache_creation_cost_per_token:.2e}" - ) + print(f"DEBUG: Base cache creation cost per token: ${cache_creation_cost_per_token:.2e}") # Handle tiered pricing - if not available, use base pricing if input_cost_above_200k is not None: - print( - f"DEBUG: Tiered input cost per token (>200k): ${input_cost_above_200k:.2e}" - ) + print(f"DEBUG: Tiered input cost per token (>200k): ${input_cost_above_200k:.2e}") else: print("DEBUG: No tiered input pricing available, using base pricing") input_cost_above_200k = input_cost_per_token if output_cost_above_200k is not None: - print( - f"DEBUG: Tiered output cost per token (>200k): ${output_cost_above_200k:.2e}" - ) + print(f"DEBUG: Tiered output cost per token (>200k): ${output_cost_above_200k:.2e}") else: print("DEBUG: No tiered output pricing available, using base pricing") output_cost_above_200k = output_cost_per_token if cache_creation_above_200k is not None: - print( - f"DEBUG: Tiered cache creation cost per token (>200k): ${cache_creation_above_200k:.2e}" - ) + print(f"DEBUG: Tiered cache creation cost per token (>200k): ${cache_creation_above_200k:.2e}") else: print("DEBUG: No tiered cache creation pricing available, using base pricing") cache_creation_above_200k = cache_creation_cost_per_token @@ -1573,13 +1512,9 @@ def test_log_context_cost_calculation(): print(f"DEBUG: Expected total: ${expected_total:.6f}") # Allow for small floating point differences - assert ( - abs(result - expected_total) < 1e-6 - ), f"Expected cost ${expected_total:.6f}, but got ${result:.6f}" + assert abs(result - expected_total) < 1e-6, f"Expected cost ${expected_total:.6f}, but got ${result:.6f}" - print( - f"✓ Log context cost calculation with tiered pricing is correct: ${result:.6f}" - ) + print(f"✓ Log context cost calculation with tiered pricing is correct: ${result:.6f}") print(f" - Input tokens (300k): ${expected_input_cost:.6f}") print(f" - Output tokens (50k): ${expected_output_cost:.6f}") print(f" - Cache creation (1k): ${expected_cache_cost:.6f}") @@ -1638,8 +1573,7 @@ def test_gemini_25_explicit_caching_cost_direct_usage(): expected_actual_cost = ( model_info["input_cost_per_token"] * usage.prompt_tokens_details.text_tokens - + model_info["cache_read_input_token_cost"] - * usage.prompt_tokens_details.cached_tokens + + model_info["cache_read_input_token_cost"] * usage.prompt_tokens_details.cached_tokens + model_info["output_cost_per_token"] * usage.completion_tokens ) @@ -1714,12 +1648,12 @@ def test_azure_ai_cache_cost_calculation(): print(f"Output cost: {output_cost}, Expected: {expected_output_cost}") print(f"Total cost: {total_cost}") - assert ( - abs(input_cost - expected_input_cost) < 1e-10 - ), f"Input cost mismatch: got {input_cost}, expected {expected_input_cost}" - assert ( - abs(output_cost - expected_output_cost) < 1e-10 - ), f"Output cost mismatch: got {output_cost}, expected {expected_output_cost}" + assert abs(input_cost - expected_input_cost) < 1e-10, ( + f"Input cost mismatch: got {input_cost}, expected {expected_input_cost}" + ) + assert abs(output_cost - expected_output_cost) < 1e-10, ( + f"Output cost mismatch: got {output_cost}, expected {expected_output_cost}" + ) def test_cost_discount_vertex_ai(): @@ -1953,9 +1887,7 @@ def test_cost_margin_combined(): ) # Set 8% margin + $0.0005 fixed for openai - litellm.cost_margin_config = { - "openai": {"percentage": 0.08, "fixed_amount": 0.0005} - } + litellm.cost_margin_config = {"openai": {"percentage": 0.08, "fixed_amount": 0.0005}} # Calculate cost with margin cost_with_margin = completion_cost( @@ -2075,9 +2007,7 @@ def test_cost_margin_provider_overrides_global(): print("✓ Cost margin provider override test passed:") print(f" - Original cost: ${cost_without_margin:.6f}") - print( - f" - Cost with provider margin (10%, overrides 5% global): ${cost_with_provider_margin:.6f}" - ) + print(f" - Cost with provider margin (10%, overrides 5% global): ${cost_with_provider_margin:.6f}") print(f" - Margin added: ${cost_with_provider_margin - cost_without_margin:.6f}") @@ -2163,9 +2093,7 @@ def test_azure_image_generation_cost_calculator(): size=None, usage=ImageUsage( input_tokens=0, - input_tokens_details=ImageUsageInputTokensDetails( - image_tokens=0, text_tokens=0 - ), + input_tokens_details=ImageUsageInputTokensDetails(image_tokens=0, text_tokens=0), output_tokens=0, total_tokens=0, ), @@ -2238,9 +2166,7 @@ def test_completion_cost_extracts_service_tier_from_response(): assert flex_cost < standard_cost, "Flex cost should be less than standard cost" flex_ratio = flex_cost / standard_cost - assert ( - 0.45 <= flex_ratio <= 0.55 - ), f"Flex pricing should be ~50% of standard, got {flex_ratio:.2f}" + assert 0.45 <= flex_ratio <= 0.55, f"Flex pricing should be ~50% of standard, got {flex_ratio:.2f}" def test_completion_cost_extracts_service_tier_from_usage(): @@ -2254,9 +2180,7 @@ def test_completion_cost_extracts_service_tier_from_usage(): model = "gpt-5-nano" # Create usage object with service_tier - usage_with_service_tier = Usage( - prompt_tokens=1000, completion_tokens=500, total_tokens=1500 - ) + usage_with_service_tier = Usage(prompt_tokens=1000, completion_tokens=500, total_tokens=1500) # Set service_tier as an attribute on the usage object setattr(usage_with_service_tier, "service_tier", "flex") @@ -2274,9 +2198,7 @@ def test_completion_cost_extracts_service_tier_from_usage(): ) # Create usage object without service_tier - usage_without_service_tier = Usage( - prompt_tokens=1000, completion_tokens=500, total_tokens=1500 - ) + usage_without_service_tier = Usage(prompt_tokens=1000, completion_tokens=500, total_tokens=1500) # Create ModelResponse with usage without service_tier response_standard = ModelResponse( @@ -2297,9 +2219,7 @@ def test_completion_cost_extracts_service_tier_from_usage(): assert flex_cost < standard_cost, "Flex cost should be less than standard cost" flex_ratio = flex_cost / standard_cost - assert ( - 0.45 <= flex_ratio <= 0.55 - ), f"Flex pricing should be ~50% of standard, got {flex_ratio:.2f}" + assert 0.45 <= flex_ratio <= 0.55, f"Flex pricing should be ~50% of standard, got {flex_ratio:.2f}" def test_completion_cost_service_tier_priority(): @@ -2357,9 +2277,7 @@ def test_completion_cost_service_tier_priority(): assert cost_from_usage > 0, "Cost from usage should be greater than 0" # Costs should be similar (all using flex) - assert ( - abs(cost_from_params - cost_from_usage) < 1e-6 - ), "Costs from params and usage should be similar (both flex)" + assert abs(cost_from_params - cost_from_usage) < 1e-6, "Costs from params and usage should be similar (both flex)" def test_completion_cost_service_tier_for_bedrock(): @@ -2609,9 +2527,7 @@ def test_completion_cost_non_string_response_service_tier_defers_to_served_tier( }, reasoning_content=None, ) - response = ModelResponse( - usage=usage, model=model, service_tier={"name": "priority"} - ) + response = ModelResponse(usage=usage, model=model, service_tier={"name": "priority"}) cost = completion_cost( completion_response=response, @@ -2713,9 +2629,7 @@ def test_anthropic_cost_per_token_prices_cache_at_served_tier_with_multiplier(): ) usage.speed = "fast" - prompt_cost, completion_cost = anthropic_cost_per_token( - model=model, usage=usage, service_tier="priority" - ) + prompt_cost, completion_cost = anthropic_cost_per_token(model=model, usage=usage, service_tier="priority") # non-cache input priced at the priority rate and scaled by the fast # multiplier; the 200 cache-hit tokens priced at the priority cache rate @@ -2763,29 +2677,27 @@ def test_gemini_cache_tokens_details_no_negative_values(): usage = VertexGeminiConfig._calculate_usage(completion_response) # Text tokens should be non-cached text only: 9402 - 9393 = 9 - assert ( - usage.prompt_tokens_details.text_tokens == 9 - ), f"Expected text_tokens=9, got {usage.prompt_tokens_details.text_tokens}" + assert usage.prompt_tokens_details.text_tokens == 9, ( + f"Expected text_tokens=9, got {usage.prompt_tokens_details.text_tokens}" + ) # Image tokens should be non-cached image only: 258 - 258 = 0 - assert ( - usage.prompt_tokens_details.image_tokens == 0 - ), f"Expected image_tokens=0, got {usage.prompt_tokens_details.image_tokens}" + assert usage.prompt_tokens_details.image_tokens == 0, ( + f"Expected image_tokens=0, got {usage.prompt_tokens_details.image_tokens}" + ) # Total cached should match - assert ( - usage.prompt_tokens_details.cached_tokens == 9651 - ), f"Expected cached_tokens=9651, got {usage.prompt_tokens_details.cached_tokens}" + assert usage.prompt_tokens_details.cached_tokens == 9651, ( + f"Expected cached_tokens=9651, got {usage.prompt_tokens_details.cached_tokens}" + ) # MOST IMPORTANT: text_tokens should NEVER be negative - assert ( - usage.prompt_tokens_details.text_tokens >= 0 - ), f"BUG: text_tokens is negative ({usage.prompt_tokens_details.text_tokens})! This was the issue in #18750" - - print( - "✅ Issue #18750 fix verified: text_tokens is correctly calculated and non-negative" + assert usage.prompt_tokens_details.text_tokens >= 0, ( + f"BUG: text_tokens is negative ({usage.prompt_tokens_details.text_tokens})! This was the issue in #18750" ) + print("✅ Issue #18750 fix verified: text_tokens is correctly calculated and non-negative") + def test_gemini_without_cache_tokens_details(): """ @@ -2852,18 +2764,18 @@ def test_gemini_implicit_caching_cost_calculation(): usage = VertexGeminiConfig._calculate_usage(completion_response) # Verify parsing - assert ( - usage.cache_read_input_tokens == 8000 - ), f"cache_read_input_tokens should be 8000, got {usage.cache_read_input_tokens}" - assert ( - usage.prompt_tokens_details.cached_tokens == 8000 - ), f"cached_tokens should be 8000, got {usage.prompt_tokens_details.cached_tokens}" + assert usage.cache_read_input_tokens == 8000, ( + f"cache_read_input_tokens should be 8000, got {usage.cache_read_input_tokens}" + ) + assert usage.prompt_tokens_details.cached_tokens == 8000, ( + f"cached_tokens should be 8000, got {usage.prompt_tokens_details.cached_tokens}" + ) # CRITICAL: text_tokens should be (10000 - 8000) = 2000, NOT 10000 # This is the fix for issue #16341 - assert ( - usage.prompt_tokens_details.text_tokens == 2000 - ), f"text_tokens should be 2000 (10000 - 8000), got {usage.prompt_tokens_details.text_tokens}" + assert usage.prompt_tokens_details.text_tokens == 2000, ( + f"text_tokens should be 2000 (10000 - 8000), got {usage.prompt_tokens_details.text_tokens}" + ) # Verify cost calculation uses cached token pricing response = ModelResponse( @@ -2901,9 +2813,7 @@ def test_gemini_implicit_caching_cost_calculation(): f"Cached tokens may not be using reduced pricing." ) - print( - "✅ Issue #16341 fix verified: Gemini implicit caching cost calculated correctly" - ) + print("✅ Issue #16341 fix verified: Gemini implicit caching cost calculated correctly") def test_additional_costs_only_for_azure_ai(): @@ -3066,12 +2976,7 @@ def test_custom_pricing_applies_cache_creation_input_cost_via_prompt_details(): }, ) - expected = ( - (4000 - 1000 - 500) * 0.0000025 - + 1000 * 0.00000025 - + 500 * 0.000003125 - + 100 * 0.000015 - ) + expected = (4000 - 1000 - 500) * 0.0000025 + 1000 * 0.00000025 + 500 * 0.000003125 + 100 * 0.000015 assert cost == pytest.approx(expected) @@ -3116,9 +3021,7 @@ def test_custom_pricing_applies_cache_creation_input_cost_via_cache_write_tokens }, ) - expected_prompt = ( - (4000 - 1000 - 500) * 0.0000025 + 1000 * 0.00000025 + 500 * 0.000003125 - ) + expected_prompt = (4000 - 1000 - 500) * 0.0000025 + 1000 * 0.00000025 + 500 * 0.000003125 expected_completion = 100 * 0.000015 assert prompt_cost == pytest.approx(expected_prompt) @@ -3158,10 +3061,7 @@ def test_extract_cache_read_tokens_zero_when_missing(): assert _extract_cache_read_tokens({}) == 0 assert _extract_cache_read_tokens({"cache_read_input_tokens": None}) == 0 - assert ( - _extract_cache_read_tokens({"prompt_tokens_details": {"cached_tokens": None}}) - == 0 - ) + assert _extract_cache_read_tokens({"prompt_tokens_details": {"cached_tokens": None}}) == 0 def test_extract_cache_creation_tokens_anthropic_top_level(): @@ -3203,12 +3103,7 @@ def test_extract_cache_creation_tokens_zero_when_missing(): assert _extract_cache_creation_tokens({}) == 0 assert _extract_cache_creation_tokens({"cache_creation_input_tokens": None}) == 0 - assert ( - _extract_cache_creation_tokens( - {"prompt_tokens_details": {"cache_write_tokens": None}} - ) - == 0 - ) + assert _extract_cache_creation_tokens({"prompt_tokens_details": {"cache_write_tokens": None}}) == 0 def test_custom_pricing_anthropic_style_cache_tokens_not_double_counted(): @@ -3366,12 +3261,8 @@ def test_completion_cost_logs_reasoning_and_cache_breakdown(): prompt_tokens=209, completion_tokens=3996, total_tokens=4205, - completion_tokens_details=CompletionTokensDetailsWrapper( - reasoning_tokens=3114, text_tokens=882 - ), - prompt_tokens_details=PromptTokensDetailsWrapper( - cached_tokens=100, text_tokens=109 - ), + completion_tokens_details=CompletionTokensDetailsWrapper(reasoning_tokens=3114, text_tokens=882), + prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=100, text_tokens=109), ), ) @@ -3530,3 +3421,52 @@ def test_completion_cost_prices_anthropic_shaped_cache_read_tokens(): ) assert cost == pytest.approx(3 * 5e-6 + 4014 * 5e-7 + 5 * 3e-5, rel=1e-9) + + +def test_batch_cost_calculator_honors_explicit_zero_cache_creation_cost(): + from litellm.cost_calculator import batch_cost_calculator + + usage = Usage( + prompt_tokens=1000, + completion_tokens=0, + total_tokens=1000, + prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=0, cache_creation_tokens=1000), + ) + model_info = { + "input_cost_per_token": 1e-6, + "output_cost_per_token": 2e-6, + "cache_creation_input_token_cost": 0.0, + } + + prompt_cost, _ = batch_cost_calculator( + usage=usage, + model="claude-3-5-sonnet-20241022", + custom_llm_provider="anthropic", + model_info=model_info, + ) + + assert prompt_cost == 0.0 + + +def test_batch_cost_calculator_falls_back_to_input_when_cache_creation_cost_missing(): + from litellm.cost_calculator import batch_cost_calculator + + usage = Usage( + prompt_tokens=1000, + completion_tokens=0, + total_tokens=1000, + prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=0, cache_creation_tokens=1000), + ) + model_info = { + "input_cost_per_token": 1e-6, + "output_cost_per_token": 2e-6, + } + + prompt_cost, _ = batch_cost_calculator( + usage=usage, + model="claude-3-5-sonnet-20241022", + custom_llm_provider="anthropic", + model_info=model_info, + ) + + assert prompt_cost == pytest.approx(1000 * 1e-6 / 2)