From 302a8d43da054f98b7ccb75baf43a8a79bb3ea40 Mon Sep 17 00:00:00 2001 From: shivam Date: Thu, 10 Sep 2026 22:02:58 +0000 Subject: [PATCH 01/11] fix(cost): bill cached realtime audio tokens at the audio cache-read rate Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/cost_calculator.py | 78 +++++++++++++------ .../litellm_core_utils/llm_cost_calc/utils.py | 40 ++++++++-- ...odel_prices_and_context_window_backup.json | 3 + .../transformation.py | 45 ++++++----- litellm/responses/utils.py | 3 + litellm/types/llms/openai.py | 14 ++++ litellm/types/utils.py | 6 ++ model_prices_and_context_window.json | 3 + .../llm_cost_calc/test_llm_cost_calc_utils.py | 68 ++++++++++++++++ .../responses/test_responses_utils.py | 41 ++++++++++ tests/test_litellm/test_cost_calculator.py | 61 +++++++++++++++ 11 files changed, 314 insertions(+), 48 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 814eaaf76f7..7cd3ea8f303 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -108,6 +108,7 @@ from litellm.types.llms.openai import ( ) from litellm.types.rerank import RerankBilledUnits, RerankResponse from litellm.types.utils import ( + CachedTokensDetails, CallTypesLiteral, LiteLLMRealtimeStreamLoggingObject, LlmProviders, @@ -2310,6 +2311,60 @@ def _summable_prompt_token_fields(prompt_tokens_details: BaseModel) -> list[str] return [attr for attr in field_names if attr != "cache_creation_tokens"] +def _combine_cached_tokens_details( + current: CachedTokensDetails | None, new: CachedTokensDetails +) -> CachedTokensDetails: + def _sum_optional(current_value: int | None, new_value: int | None) -> int | None: + if current_value is None and new_value is None: + return None + return (current_value or 0) + (new_value or 0) + + return CachedTokensDetails( + text_tokens=_sum_optional( + current.text_tokens if current is not None else None, new.text_tokens + ), + audio_tokens=_sum_optional( + current.audio_tokens if current is not None else None, new.audio_tokens + ), + image_tokens=_sum_optional( + current.image_tokens if current is not None else None, new.image_tokens + ), + ) + + +def _combine_prompt_tokens_details(combined: Usage, usage: Usage) -> None: + if not (hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details): + return + if not hasattr(combined, "prompt_tokens_details") or not combined.prompt_tokens_details: + combined.prompt_tokens_details = PromptTokensDetailsWrapper() + + # Check what keys exist in the model's prompt_tokens_details + # Access model_fields on the class, not the instance, to avoid Pydantic 2.11+ deprecation warnings + for attr in _summable_prompt_token_fields(usage.prompt_tokens_details): + if ( + hasattr(usage.prompt_tokens_details, attr) + and not attr.startswith("_") + and not callable(_attribute_value(usage.prompt_tokens_details, attr)) + ): + current_val = getattr(combined.prompt_tokens_details, attr, 0) or 0 + new_val = getattr(usage.prompt_tokens_details, attr, 0) or 0 + if new_val is not None and isinstance(new_val, (int, float)): + setattr( + combined.prompt_tokens_details, + attr, + current_val + new_val, + ) + + new_cached_tokens_details: Final = getattr( + usage.prompt_tokens_details, "cached_tokens_details", None + ) + if isinstance(new_cached_tokens_details, CachedTokensDetails): + combined.prompt_tokens_details.cached_tokens_details = _combine_cached_tokens_details( + getattr(combined.prompt_tokens_details, "cached_tokens_details", None), + new_cached_tokens_details, + ) + + class BaseTokenUsageProcessor: @staticmethod def combine_usage_objects(usage_objects: list[Usage]) -> Usage: @@ -2318,7 +2373,6 @@ class BaseTokenUsageProcessor: """ from litellm.types.utils import ( CompletionTokensDetailsWrapper, - PromptTokensDetailsWrapper, Usage, ) @@ -2337,27 +2391,7 @@ class BaseTokenUsageProcessor: and isinstance(current_val, (int, float)) ): setattr(combined, attr, current_val + new_val) - # Handle nested prompt_tokens_details - if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details: - if not hasattr(combined, "prompt_tokens_details") or not combined.prompt_tokens_details: - combined.prompt_tokens_details = PromptTokensDetailsWrapper() - - # Check what keys exist in the model's prompt_tokens_details - # Access model_fields on the class, not the instance, to avoid Pydantic 2.11+ deprecation warnings - for attr in _summable_prompt_token_fields(usage.prompt_tokens_details): - if ( - hasattr(usage.prompt_tokens_details, attr) - and not attr.startswith("_") - and not callable(_attribute_value(usage.prompt_tokens_details, attr)) - ): - current_val = getattr(combined.prompt_tokens_details, attr, 0) or 0 - new_val = getattr(usage.prompt_tokens_details, attr, 0) or 0 - if new_val is not None and isinstance(new_val, (int, float)): - setattr( - combined.prompt_tokens_details, - attr, - current_val + new_val, - ) + _combine_prompt_tokens_details(combined, usage) # Handle nested completion_tokens_details if hasattr(usage, "completion_tokens_details") and usage.completion_tokens_details: diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index e5977ca4156..18ef99597a0 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -9,6 +9,8 @@ from types import MappingProxyType from typing import Any, Final, Literal, TypedDict, cast from zoneinfo import ZoneInfo, ZoneInfoNotFoundError +from typing_extensions import ReadOnly + import litellm from litellm._internal_context import current_billing_time from litellm._logging import verbose_logger @@ -772,6 +774,7 @@ def calculate_cache_writing_cost( class PromptTokensDetailsResult(TypedDict): cache_hit_tokens: int + cache_hit_audio_tokens: ReadOnly[int] cache_creation_tokens: int cache_creation_token_details: CacheCreationTokenDetails | None text_tokens: int @@ -802,12 +805,26 @@ def parse_prompt_tokens_details(usage: Usage) -> PromptTokensDetailsResult: ) or None ) - text_tokens: Final = ( - cast(int | None, getattr(usage.prompt_tokens_details, "text_tokens", None)) - or 0 # default to prompt tokens, if this field is not set + cached_tokens_details: Final = getattr(usage.prompt_tokens_details, "cached_tokens_details", None) + cached_text_tokens: Final = _get_token_detail_value(cached_tokens_details, "text_tokens") or 0 + cached_audio_tokens: Final = _get_token_detail_value(cached_tokens_details, "audio_tokens") or 0 + cached_image_tokens: Final = _get_token_detail_value(cached_tokens_details, "image_tokens") or 0 + text_tokens: Final = max( + ( + cast(int | None, getattr(usage.prompt_tokens_details, "text_tokens", None)) + or 0 # default to prompt tokens, if this field is not set + ) + - cached_text_tokens, + 0, + ) + audio_tokens: Final = max( + (cast(int | None, getattr(usage.prompt_tokens_details, "audio_tokens", 0)) or 0) - cached_audio_tokens, + 0, + ) + image_tokens: Final = max( + (cast(int | None, getattr(usage.prompt_tokens_details, "image_tokens", 0)) or 0) - cached_image_tokens, + 0, ) - audio_tokens: Final = cast(int | None, getattr(usage.prompt_tokens_details, "audio_tokens", 0)) or 0 - image_tokens: Final = cast(int | None, getattr(usage.prompt_tokens_details, "image_tokens", 0)) or 0 video_tokens: Final = _coerce_token_count(getattr(usage.prompt_tokens_details, "video_tokens", 0)) character_count: Final = ( cast( @@ -835,6 +852,7 @@ def parse_prompt_tokens_details(usage: Usage) -> PromptTokensDetailsResult: return PromptTokensDetailsResult( cache_hit_tokens=cache_hit_tokens, + cache_hit_audio_tokens=min(cached_audio_tokens, cache_hit_tokens), cache_creation_tokens=cache_creation_tokens, cache_creation_token_details=cache_creation_token_details, text_tokens=text_tokens, @@ -918,7 +936,16 @@ def _calculate_input_cost( prompt_cost = float(prompt_tokens_details["text_tokens"]) * prompt_base_cost ### CACHE READ COST - Now uses tiered pricing - prompt_cost += float(prompt_tokens_details["cache_hit_tokens"]) * cache_read_cost + cache_hit_audio_tokens: Final = prompt_tokens_details["cache_hit_audio_tokens"] + audio_cache_read_rate: Final = _get_cost_per_unit( + model_info, + _get_service_tier_cost_key("cache_read_input_audio_token_cost", service_tier), + None, + ) + prompt_cost += float(prompt_tokens_details["cache_hit_tokens"] - cache_hit_audio_tokens) * cache_read_cost + prompt_cost += float(cache_hit_audio_tokens) * ( + audio_cache_read_rate if audio_cache_read_rate is not None else cache_read_cost + ) ### AUDIO COST if prompt_tokens_details["audio_tokens"]: @@ -1149,6 +1176,7 @@ def generic_cost_per_token( ### PROCESSING COST prompt_tokens_details = PromptTokensDetailsResult( cache_hit_tokens=0, + cache_hit_audio_tokens=0, cache_creation_tokens=0, cache_creation_token_details=None, text_tokens=usage.prompt_tokens, diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 1c0bd32d782..7f4ca991bd3 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -32502,6 +32502,7 @@ }, "gpt-realtime": { "cache_creation_input_audio_token_cost": 4e-07, + "cache_read_input_audio_token_cost": 4e-07, "cache_read_input_token_cost": 4e-07, "deprecation_date": "2027-01-20", "input_cost_per_audio_token": 3.2e-05, @@ -32535,6 +32536,7 @@ }, "gpt-realtime-1.5": { "cache_creation_input_audio_token_cost": 4e-07, + "cache_read_input_audio_token_cost": 4e-07, "cache_read_input_token_cost": 4e-07, "input_cost_per_audio_token": 3.2e-05, "input_cost_per_image_token": 5e-06, @@ -32702,6 +32704,7 @@ }, "gpt-realtime-2025-08-28": { "cache_creation_input_audio_token_cost": 4e-07, + "cache_read_input_audio_token_cost": 4e-07, "cache_read_input_token_cost": 4e-07, "deprecation_date": "2027-01-20", "input_cost_per_audio_token": 3.2e-05, diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index fca5b0d11cf..cc84c68b0f5 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -45,6 +45,7 @@ from litellm.responses.litellm_completion_transformation.session_handler import ) from litellm.types.llms.openai import ( AllMessageValues, + CachedTokensDetails, ChatCompletionImageObject, ChatCompletionImageUrlObject, ChatCompletionRedactedThinkingBlock, @@ -2681,27 +2682,31 @@ class LiteLLMCompletionResponsesConfig: # Translate prompt_tokens_details to input_tokens_details if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details is not None: prompt_details: Final = usage.prompt_tokens_details - input_details_dict: Final[dict[str, int]] = {} - - if hasattr(prompt_details, "cached_tokens") and prompt_details.cached_tokens is not None: - input_details_dict["cached_tokens"] = prompt_details.cached_tokens - else: - input_details_dict["cached_tokens"] = 0 - - if hasattr(prompt_details, "text_tokens") and prompt_details.text_tokens is not None: - input_details_dict["text_tokens"] = prompt_details.text_tokens - - if hasattr(prompt_details, "audio_tokens") and prompt_details.audio_tokens is not None: - input_details_dict["audio_tokens"] = prompt_details.audio_tokens - - cache_write_tokens = getattr(prompt_details, "cache_write_tokens", None) or getattr( - prompt_details, "cache_creation_tokens", None + cached_tokens_details: Final = getattr(prompt_details, "cached_tokens_details", None) + response_usage.input_tokens_details = InputTokensDetails( + cached_tokens=( + prompt_details.cached_tokens + if hasattr(prompt_details, "cached_tokens") and prompt_details.cached_tokens is not None + else 0 + ), + text_tokens=( + prompt_details.text_tokens + if hasattr(prompt_details, "text_tokens") and prompt_details.text_tokens is not None + else None + ), + audio_tokens=( + prompt_details.audio_tokens + if hasattr(prompt_details, "audio_tokens") and prompt_details.audio_tokens is not None + else None + ), + cache_write_tokens=( + getattr(prompt_details, "cache_write_tokens", None) + or getattr(prompt_details, "cache_creation_tokens", None) + ), + cached_tokens_details=( + cached_tokens_details if isinstance(cached_tokens_details, CachedTokensDetails) else None + ), ) - if cache_write_tokens is not None: - input_details_dict["cache_write_tokens"] = cache_write_tokens - - if input_details_dict: - response_usage.input_tokens_details = InputTokensDetails(**input_details_dict) # Translate completion_tokens_details to output_tokens_details if hasattr(usage, "completion_tokens_details") and usage.completion_tokens_details is not None: diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index 599e978df6a..d63e3ddf0aa 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -1179,6 +1179,9 @@ class ResponseAPILoggingUtils: audio_tokens=getattr(response_api_usage.input_tokens_details, "audio_tokens", None), text_tokens=getattr(response_api_usage.input_tokens_details, "text_tokens", None), image_tokens=getattr(response_api_usage.input_tokens_details, "image_tokens", None), + cached_tokens_details=getattr( + response_api_usage.input_tokens_details, "cached_tokens_details", None + ), cache_write_tokens=getattr(response_api_usage.input_tokens_details, "cache_write_tokens", None), ) completion_tokens_details: CompletionTokensDetailsWrapper | None = None diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index b7c4371f32f..274747b4193 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -1284,9 +1284,16 @@ class OutputTokensDetails(BaseLiteLLMOpenAIResponseObject): model_config = {"extra": "allow"} +class CachedTokensDetails(BaseModel): + text_tokens: int | None = None + audio_tokens: int | None = None + image_tokens: int | None = None + + class InputTokensDetails(BaseLiteLLMOpenAIResponseObject): audio_tokens: int | None = None cached_tokens: int = 0 + cached_tokens_details: CachedTokensDetails | None = None text_tokens: int | None = None model_config = {"extra": "allow"} @@ -2204,10 +2211,17 @@ class OpenAIRealtimeInputAudioTranscriptionCompleted(TypedDict): transcript: ReadOnly[str] +class OpenAIRealtimeCachedTokensDetails(TypedDict, total=False): + text_tokens: ReadOnly[int] + audio_tokens: ReadOnly[int] + image_tokens: ReadOnly[int] + + class OpenAIRealtimeUsageTokenDetails(TypedDict): audio_tokens: ReadOnly[int] text_tokens: ReadOnly[int] cached_tokens: NotRequired[ReadOnly[int]] + cached_tokens_details: NotRequired[ReadOnly[OpenAIRealtimeCachedTokensDetails]] class OpenAIRealtimeResponseUsage(TypedDict): diff --git a/litellm/types/utils.py b/litellm/types/utils.py index ab0cc5f959c..39100031dcf 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -58,6 +58,7 @@ from .llms.base import HiddenParams from .llms.openai import ( AllMessageValues, Batch, + CachedTokensDetails, ChatCompletionAnnotation, ChatCompletionReasoningItem, ChatCompletionRedactedThinkingBlock, @@ -1707,6 +1708,9 @@ class PromptTokensDetailsWrapper( cache_creation_token_details: CacheCreationTokenDetails | None = None """Details of cache creation tokens sent to the model. Used for tracking 5m/1h cache creation tokens for Anthropic prompt caching.""" + cached_tokens_details: CachedTokensDetails | None = None + """Details of cached (cache-hit) tokens sent to the model. OpenAI realtime naming; carries the per-modality cache-read split.""" + def __setattr__(self, name: str, value: object) -> None: super().__setattr__(name, value) if name == "cache_write_tokens": @@ -1753,6 +1757,8 @@ class PromptTokensDetailsWrapper( del self.cache_creation_tokens if self.cache_creation_token_details is None: del self.cache_creation_token_details + if self.cached_tokens_details is None: + del self.cached_tokens_details class ServerToolUse(BaseModel): diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 1c0bd32d782..7f4ca991bd3 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -32502,6 +32502,7 @@ }, "gpt-realtime": { "cache_creation_input_audio_token_cost": 4e-07, + "cache_read_input_audio_token_cost": 4e-07, "cache_read_input_token_cost": 4e-07, "deprecation_date": "2027-01-20", "input_cost_per_audio_token": 3.2e-05, @@ -32535,6 +32536,7 @@ }, "gpt-realtime-1.5": { "cache_creation_input_audio_token_cost": 4e-07, + "cache_read_input_audio_token_cost": 4e-07, "cache_read_input_token_cost": 4e-07, "input_cost_per_audio_token": 3.2e-05, "input_cost_per_image_token": 5e-06, @@ -32702,6 +32704,7 @@ }, "gpt-realtime-2025-08-28": { "cache_creation_input_audio_token_cost": 4e-07, + "cache_read_input_audio_token_cost": 4e-07, "cache_read_input_token_cost": 4e-07, "deprecation_date": "2027-01-20", "input_cost_per_audio_token": 3.2e-05, diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index fbb9d178390..3709b526c3b 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -2648,6 +2648,7 @@ def test_cache_writing_cost_with_zero_creation_tokens_and_ephemeral_details(): prompt_tokens_details: PromptTokensDetailsResult = { "cache_hit_tokens": 0, + "cache_hit_audio_tokens": 0, "cache_creation_tokens": 0, "cache_creation_token_details": CacheCreationTokenDetails( ephemeral_5m_input_tokens=100, @@ -5147,3 +5148,70 @@ def test_generic_cost_per_token_bills_nested_reasoning_once_beside_audio_output( assert completion_cost == pytest.approx( 30 * info["output_cost_per_token"] + 70 * info["output_cost_per_audio_token"] ) + + +def test_cached_realtime_audio_tokens_billed_at_audio_cache_read_rate( + _local_model_cost_map: None, +) -> None: + usage = Usage( + prompt_tokens=283, + completion_tokens=0, + total_tokens=283, + prompt_tokens_details=PromptTokensDetailsWrapper( + text_tokens=116, + audio_tokens=167, + cached_tokens=192, + cached_tokens_details={"text_tokens": 64, "audio_tokens": 128}, + ), + ) + + prompt_cost, _ = generic_cost_per_token( + model="gpt-realtime-2", usage=usage, custom_llm_provider="openai" + ) + assert prompt_cost == pytest.approx(0.0015328) + + +def test_prompt_tokens_details_without_cached_tokens_details_unchanged( + _local_model_cost_map: None, +) -> None: + usage = Usage( + prompt_tokens=283, + completion_tokens=0, + total_tokens=283, + prompt_tokens_details=PromptTokensDetailsWrapper( + text_tokens=116, audio_tokens=167, cached_tokens=192 + ), + ) + + prompt_cost, _ = generic_cost_per_token( + model="gpt-realtime-2", usage=usage, custom_llm_provider="openai" + ) + assert prompt_cost == pytest.approx(0.0029888) + + +def test_cached_audio_tokens_fall_back_to_cache_read_input_token_cost() -> None: + model_info: ModelInfo = { + "input_cost_per_token": 4e-6, + "input_cost_per_audio_token": 32e-6, + "cache_read_input_token_cost": 5e-7, + } + usage = Usage( + prompt_tokens=283, + completion_tokens=0, + total_tokens=283, + prompt_tokens_details=PromptTokensDetailsWrapper( + text_tokens=116, + audio_tokens=167, + cached_tokens=192, + cached_tokens_details={"text_tokens": 64, "audio_tokens": 128}, + ), + ) + + prompt_cost, _ = generic_cost_per_token( + model="some-realtime-model", + usage=usage, + custom_llm_provider="openai", + model_info=model_info, + ) + expected = 52 * 4e-6 + 64 * 5e-7 + 39 * 32e-6 + 128 * 5e-7 + assert prompt_cost == pytest.approx(expected) diff --git a/tests/test_litellm/responses/test_responses_utils.py b/tests/test_litellm/responses/test_responses_utils.py index 9d9eefdceb3..4d06b5e7bdc 100644 --- a/tests/test_litellm/responses/test_responses_utils.py +++ b/tests/test_litellm/responses/test_responses_utils.py @@ -577,6 +577,47 @@ class TestResponseAPILoggingUtils: assert result.completion_tokens_details is not None assert result.completion_tokens_details.reasoning_tokens == 4 + def test_transform_realtime_usage_dict_keeps_cached_tokens_details(self): + usage = { + "input_tokens": 283, + "output_tokens": 0, + "total_tokens": 283, + "input_token_details": { + "text_tokens": 116, + "audio_tokens": 167, + "cached_tokens": 192, + "cached_tokens_details": {"text_tokens": 64, "audio_tokens": 128}, + }, + } + + result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage) + + assert result.prompt_tokens_details is not None + assert result.prompt_tokens_details.cached_tokens == 192 + assert result.prompt_tokens_details.cached_tokens_details is not None + assert result.prompt_tokens_details.cached_tokens_details.audio_tokens == 128 + assert result.prompt_tokens_details.cached_tokens_details.text_tokens == 64 + + def test_transform_response_api_usage_object_keeps_cached_tokens_details(self): + usage = ResponseAPIUsage( + input_tokens=283, + output_tokens=0, + total_tokens=283, + input_tokens_details={ + "text_tokens": 116, + "audio_tokens": 167, + "cached_tokens": 192, + "cached_tokens_details": {"text_tokens": 64, "audio_tokens": 128}, + }, + ) + + result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage) + + assert result.prompt_tokens_details is not None + assert result.prompt_tokens_details.cached_tokens_details is not None + assert result.prompt_tokens_details.cached_tokens_details.audio_tokens == 128 + assert result.prompt_tokens_details.cached_tokens_details.text_tokens == 64 + class TestResponsesAPIProviderSpecificParams: """ diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index f610821e06a..0b339e3d525 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -4768,3 +4768,64 @@ def test_collect_and_combine_realtime_usage_stores_partitioned_text_tokens() -> assert combined.completion_tokens_details.reasoning_tokens == 95 assert combined.completion_tokens_details.text_tokens == 38 assert combined.completion_tokens_details.audio_tokens == 0 + + +def test_realtime_combine_sums_nested_cached_tokens_details(): + results: OpenAIRealtimeStreamList = [ + { + "type": "response.done", + "response": { + "usage": { + "input_tokens": 283, + "output_tokens": 0, + "total_tokens": 283, + "input_token_details": { + "text_tokens": 116, + "audio_tokens": 167, + "cached_tokens": 192, + "cached_tokens_details": {"text_tokens": 64, "audio_tokens": 128}, + }, + } + }, + }, + { + "type": "response.done", + "response": { + "usage": { + "input_tokens": 150, + "output_tokens": 0, + "total_tokens": 150, + "input_token_details": { + "text_tokens": 50, + "audio_tokens": 100, + "cached_tokens": 100, + "cached_tokens_details": {"audio_tokens": 100}, + }, + } + }, + }, + ] + + combined = RealtimeAPITokenUsageProcessor.collect_and_combine_usage_from_realtime_stream_results( + results=results, + ) + + assert combined.prompt_tokens_details is not None + assert combined.prompt_tokens_details.cached_tokens == 292 + assert combined.prompt_tokens_details.cached_tokens_details is not None + assert combined.prompt_tokens_details.cached_tokens_details.audio_tokens == 228 + assert combined.prompt_tokens_details.cached_tokens_details.text_tokens == 64 + assert combined.prompt_tokens_details.cached_tokens_details.image_tokens is None + + +def test_usage_without_cached_tokens_details_omits_key(): + usage = Usage( + prompt_tokens=10, + completion_tokens=5, + total_tokens=15, + prompt_tokens_details=PromptTokensDetailsWrapper(text_tokens=10), + ) + + dumped = usage.prompt_tokens_details.model_dump() + assert "cached_tokens_details" not in dumped + assert "cached_tokens_details" not in usage.prompt_tokens_details.model_dump_json() From 67fc9e4e3dcb94035c4b4d07d63c3565939d9a3f Mon Sep 17 00:00:00 2001 From: shivam Date: Thu, 10 Sep 2026 22:10:06 +0000 Subject: [PATCH 02/11] fix(responses): only emit cache_write_tokens when reported Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/cost_calculator.py | 16 +++------- .../transformation.py | 30 +++++++------------ .../test_litellm_completion_responses.py | 2 ++ 3 files changed, 16 insertions(+), 32 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 7cd3ea8f303..8daa2de416b 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -2320,15 +2320,9 @@ def _combine_cached_tokens_details( return (current_value or 0) + (new_value or 0) return CachedTokensDetails( - text_tokens=_sum_optional( - current.text_tokens if current is not None else None, new.text_tokens - ), - audio_tokens=_sum_optional( - current.audio_tokens if current is not None else None, new.audio_tokens - ), - image_tokens=_sum_optional( - current.image_tokens if current is not None else None, new.image_tokens - ), + text_tokens=_sum_optional(current.text_tokens if current is not None else None, new.text_tokens), + audio_tokens=_sum_optional(current.audio_tokens if current is not None else None, new.audio_tokens), + image_tokens=_sum_optional(current.image_tokens if current is not None else None, new.image_tokens), ) @@ -2355,9 +2349,7 @@ def _combine_prompt_tokens_details(combined: Usage, usage: Usage) -> None: current_val + new_val, ) - new_cached_tokens_details: Final = getattr( - usage.prompt_tokens_details, "cached_tokens_details", None - ) + new_cached_tokens_details: Final = getattr(usage.prompt_tokens_details, "cached_tokens_details", None) if isinstance(new_cached_tokens_details, CachedTokensDetails): combined.prompt_tokens_details.cached_tokens_details = _combine_cached_tokens_details( getattr(combined.prompt_tokens_details, "cached_tokens_details", None), diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index cc84c68b0f5..e6f90b99b60 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -2683,30 +2683,20 @@ class LiteLLMCompletionResponsesConfig: if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details is not None: prompt_details: Final = usage.prompt_tokens_details cached_tokens_details: Final = getattr(prompt_details, "cached_tokens_details", None) - response_usage.input_tokens_details = InputTokensDetails( - cached_tokens=( - prompt_details.cached_tokens - if hasattr(prompt_details, "cached_tokens") and prompt_details.cached_tokens is not None - else 0 - ), - text_tokens=( - prompt_details.text_tokens - if hasattr(prompt_details, "text_tokens") and prompt_details.text_tokens is not None - else None - ), - audio_tokens=( - prompt_details.audio_tokens - if hasattr(prompt_details, "audio_tokens") and prompt_details.audio_tokens is not None - else None - ), - cache_write_tokens=( - getattr(prompt_details, "cache_write_tokens", None) - or getattr(prompt_details, "cache_creation_tokens", None) - ), + cache_write_tokens: Final = getattr(prompt_details, "cache_write_tokens", None) or getattr( + prompt_details, "cache_creation_tokens", None + ) + input_tokens_details: Final = InputTokensDetails( + cached_tokens=prompt_details.cached_tokens if prompt_details.cached_tokens is not None else 0, + text_tokens=prompt_details.text_tokens, + audio_tokens=prompt_details.audio_tokens, cached_tokens_details=( cached_tokens_details if isinstance(cached_tokens_details, CachedTokensDetails) else None ), ) + if cache_write_tokens is not None: + setattr(input_tokens_details, "cache_write_tokens", cache_write_tokens) + response_usage.input_tokens_details = input_tokens_details # Translate completion_tokens_details to output_tokens_details if hasattr(usage, "completion_tokens_details") and usage.completion_tokens_details is not None: diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 46249e50572..be96c2a4bf5 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -2605,6 +2605,7 @@ class TestUsageTransformation: assert response_usage.input_tokens_details is not None assert response_usage.input_tokens_details.cached_tokens == 5 assert response_usage.input_tokens_details.text_tokens == 8 + assert "cache_write_tokens" not in response_usage.input_tokens_details.model_dump() def test_transform_usage_with_cached_tokens_gemini(self): """Test that cached_tokens from Gemini are properly transformed to input_tokens_details""" @@ -2667,6 +2668,7 @@ class TestUsageTransformation: assert response_usage.input_tokens_details is not None assert response_usage.input_tokens_details.cached_tokens == 100 assert getattr(response_usage.input_tokens_details, "cache_write_tokens", None) == 800 + assert response_usage.input_tokens_details.model_dump()["cache_write_tokens"] == 800 def test_transform_usage_with_reasoning_tokens_gemini(self): """Test that reasoning_tokens from Gemini are properly transformed to output_tokens_details""" From 5737cab258b405689a55e1e6dcaec385673fa572 Mon Sep 17 00:00:00 2001 From: shivam Date: Thu, 10 Sep 2026 22:27:58 +0000 Subject: [PATCH 03/11] fix(cost): cap nested cached modality counts at cached_tokens Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/cost_calculator.py | 2 -- .../litellm_core_utils/llm_cost_calc/utils.py | 16 +++++++++++---- .../llm_cost_calc/test_llm_cost_calc_utils.py | 20 +++++++++++++++++++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 8daa2de416b..b865318f3af 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -2332,8 +2332,6 @@ def _combine_prompt_tokens_details(combined: Usage, usage: Usage) -> None: if not hasattr(combined, "prompt_tokens_details") or not combined.prompt_tokens_details: combined.prompt_tokens_details = PromptTokensDetailsWrapper() - # Check what keys exist in the model's prompt_tokens_details - # Access model_fields on the class, not the instance, to avoid Pydantic 2.11+ deprecation warnings for attr in _summable_prompt_token_fields(usage.prompt_tokens_details): if ( hasattr(usage.prompt_tokens_details, attr) diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index 18ef99597a0..dc689ca9618 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -806,9 +806,17 @@ def parse_prompt_tokens_details(usage: Usage) -> PromptTokensDetailsResult: or None ) cached_tokens_details: Final = getattr(usage.prompt_tokens_details, "cached_tokens_details", None) - cached_text_tokens: Final = _get_token_detail_value(cached_tokens_details, "text_tokens") or 0 - cached_audio_tokens: Final = _get_token_detail_value(cached_tokens_details, "audio_tokens") or 0 - cached_image_tokens: Final = _get_token_detail_value(cached_tokens_details, "image_tokens") or 0 + cached_audio_tokens: Final = min( + _get_token_detail_value(cached_tokens_details, "audio_tokens") or 0, cache_hit_tokens + ) + cached_text_tokens: Final = min( + _get_token_detail_value(cached_tokens_details, "text_tokens") or 0, + cache_hit_tokens - cached_audio_tokens, + ) + cached_image_tokens: Final = min( + _get_token_detail_value(cached_tokens_details, "image_tokens") or 0, + cache_hit_tokens - cached_audio_tokens - cached_text_tokens, + ) text_tokens: Final = max( ( cast(int | None, getattr(usage.prompt_tokens_details, "text_tokens", None)) @@ -852,7 +860,7 @@ def parse_prompt_tokens_details(usage: Usage) -> PromptTokensDetailsResult: return PromptTokensDetailsResult( cache_hit_tokens=cache_hit_tokens, - cache_hit_audio_tokens=min(cached_audio_tokens, cache_hit_tokens), + cache_hit_audio_tokens=cached_audio_tokens, cache_creation_tokens=cache_creation_tokens, cache_creation_token_details=cache_creation_token_details, text_tokens=text_tokens, diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index 3709b526c3b..33825c8dd01 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -5215,3 +5215,23 @@ def test_cached_audio_tokens_fall_back_to_cache_read_input_token_cost() -> None: ) expected = 52 * 4e-6 + 64 * 5e-7 + 39 * 32e-6 + 128 * 5e-7 assert prompt_cost == pytest.approx(expected) + + +def test_cached_audio_tokens_capped_at_cached_tokens(_local_model_cost_map: None) -> None: + """Nested cached_tokens_details exceeding cached_tokens must not over-subtract the audio bucket.""" + usage = Usage( + prompt_tokens=283, + completion_tokens=0, + total_tokens=283, + prompt_tokens_details=PromptTokensDetailsWrapper( + text_tokens=116, + audio_tokens=167, + cached_tokens=100, + cached_tokens_details={"audio_tokens": 128}, + ), + ) + + prompt_cost, _ = generic_cost_per_token( + model="gpt-realtime-2", usage=usage, custom_llm_provider="openai" + ) + assert prompt_cost == pytest.approx(116 * 4e-6 + (167 - 100) * 32e-6 + 100 * 4e-7) From 3c2342bfd32edddadfa3429ddefa159fdd9e32c2 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:05:41 -0700 Subject: [PATCH 04/11] refactor(cost): return a new prompt token details wrapper when combining usage --- litellm/cost_calculator.py | 55 +++++++++++++++++++------------------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index b865318f3af..50118af8a30 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -2326,33 +2326,29 @@ def _combine_cached_tokens_details( ) -def _combine_prompt_tokens_details(combined: Usage, usage: Usage) -> None: - if not (hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details): - return - if not hasattr(combined, "prompt_tokens_details") or not combined.prompt_tokens_details: - combined.prompt_tokens_details = PromptTokensDetailsWrapper() - - for attr in _summable_prompt_token_fields(usage.prompt_tokens_details): - if ( - hasattr(usage.prompt_tokens_details, attr) - and not attr.startswith("_") - and not callable(_attribute_value(usage.prompt_tokens_details, attr)) - ): - current_val = getattr(combined.prompt_tokens_details, attr, 0) or 0 - new_val = getattr(usage.prompt_tokens_details, attr, 0) or 0 - if new_val is not None and isinstance(new_val, (int, float)): - setattr( - combined.prompt_tokens_details, - attr, - current_val + new_val, - ) - - new_cached_tokens_details: Final = getattr(usage.prompt_tokens_details, "cached_tokens_details", None) - if isinstance(new_cached_tokens_details, CachedTokensDetails): - combined.prompt_tokens_details.cached_tokens_details = _combine_cached_tokens_details( - getattr(combined.prompt_tokens_details, "cached_tokens_details", None), - new_cached_tokens_details, - ) +def _combine_prompt_tokens_details( + current: PromptTokensDetailsWrapper | None, new: PromptTokensDetailsWrapper +) -> PromptTokensDetailsWrapper: + base: Final = current if current is not None else PromptTokensDetailsWrapper() + base_values: Final = MappingProxyType( + {attr: getattr(base, attr) for attr in type(base).model_fields if hasattr(base, attr)} + ) + summed: Final = MappingProxyType( + { + attr: (getattr(base, attr, 0) or 0) + (getattr(new, attr) or 0) + for attr in _summable_prompt_token_fields(new) + if hasattr(new, attr) and isinstance(getattr(new, attr) or 0, (int, float)) + } + ) + new_cached_tokens_details: Final = getattr(new, "cached_tokens_details", None) + cached_tokens_details: Final = ( + _combine_cached_tokens_details(getattr(base, "cached_tokens_details", None), new_cached_tokens_details) + if isinstance(new_cached_tokens_details, CachedTokensDetails) + else getattr(base, "cached_tokens_details", None) + ) + return PromptTokensDetailsWrapper( + **MappingProxyType({**base_values, **summed, "cached_tokens_details": cached_tokens_details}) + ) class BaseTokenUsageProcessor: @@ -2381,7 +2377,10 @@ class BaseTokenUsageProcessor: and isinstance(current_val, (int, float)) ): setattr(combined, attr, current_val + new_val) - _combine_prompt_tokens_details(combined, usage) + if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details: + combined.prompt_tokens_details = _combine_prompt_tokens_details( + getattr(combined, "prompt_tokens_details", None), usage.prompt_tokens_details + ) # Handle nested completion_tokens_details if hasattr(usage, "completion_tokens_details") and usage.completion_tokens_details: From e4b05883627555fd72db16f0ecf376b75a6d93a3 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:31:53 -0700 Subject: [PATCH 05/11] fix(cost): carry cache_read_input_audio_token_cost through get_model_info Every proxy and router cost lookup goes through get_model_info, which copies cost map keys explicitly, so the new audio cache-read branch always fell back to the text cache-read rate there. Copy the key so models whose audio cache-read rate differs from the text one bill cached audio correctly. --- litellm/types/utils.py | 1 + litellm/utils.py | 1 + .../llm_cost_calc/test_llm_cost_calc_utils.py | 17 +++++++++++++++++ tests/test_litellm/test_utils.py | 8 ++++++++ 4 files changed, 27 insertions(+) diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 9eb70e189a1..ef191d79177 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -251,6 +251,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False): cache_creation_input_token_cost_priority: float | None # OpenAI priority service tier pricing cache_creation_input_token_cost_ultrafast: ReadOnly[float | None] # OpenAI ultrafast service tier pricing cache_read_input_token_cost: float | None + cache_read_input_audio_token_cost: ReadOnly[float | None] cache_read_input_token_cost_flex: float | None # OpenAI flex service tier pricing cache_read_input_token_cost_priority: float | None # OpenAI priority service tier pricing cache_read_input_token_cost_ultrafast: ReadOnly[float | None] # OpenAI ultrafast service tier pricing diff --git a/litellm/utils.py b/litellm/utils.py index 1a77655a5a4..d048265ecd3 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -5866,6 +5866,7 @@ def _get_model_info_helper( "cache_creation_input_token_cost_ultrafast", None ), cache_read_input_token_cost=_model_info.get("cache_read_input_token_cost", None), + cache_read_input_audio_token_cost=_model_info.get("cache_read_input_audio_token_cost", None), prompt_cache_min_tokens=_model_info.get("prompt_cache_min_tokens", None), cache_read_input_token_cost_above_200k_tokens=_model_info.get( "cache_read_input_token_cost_above_200k_tokens", None diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index 5d55dfb14a3..5ff1ab62698 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -5235,3 +5235,20 @@ def test_cached_audio_tokens_capped_at_cached_tokens(_local_model_cost_map: None model="gpt-realtime-2", usage=usage, custom_llm_provider="openai" ) assert prompt_cost == pytest.approx(116 * 4e-6 + (167 - 100) * 32e-6 + 100 * 4e-7) + + +def test_cached_audio_tokens_billed_at_audio_cache_rate_through_model_info_lookup(_local_model_cost_map: None) -> None: + usage = Usage( + prompt_tokens=1000, + completion_tokens=0, + total_tokens=1000, + prompt_tokens_details=PromptTokensDetailsWrapper( + text_tokens=400, + audio_tokens=600, + cached_tokens=500, + cached_tokens_details={"text_tokens": 100, "audio_tokens": 400}, + ), + ) + + prompt_cost, _ = generic_cost_per_token(model="gpt-realtime-2.1-mini", usage=usage, custom_llm_provider="openai") + assert prompt_cost == pytest.approx(300 * 6e-7 + 100 * 6e-8 + 200 * 1e-5 + 400 * 3e-7) diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 19ed31c7b22..d9ca7d9ddee 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -6430,3 +6430,11 @@ def test_completion_finishes_response_metadata_before_handing_the_response_to_th assert snapshot["litellm_call_id"] assert snapshot["response_cost"] is not None assert snapshot["api_base"] + + +def test_get_model_info_carries_cache_read_input_audio_token_cost(monkeypatch): + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url="")) + info = litellm.get_model_info("gpt-realtime-2.1-mini", custom_llm_provider="openai") + assert info["cache_read_input_audio_token_cost"] == 3e-07 + assert info["cache_read_input_token_cost"] == 6e-08 From 305caa8260fb98f821aa69eeba466a162b69c1c4 Mon Sep 17 00:00:00 2001 From: shivam Date: Sat, 12 Sep 2026 23:01:41 +0000 Subject: [PATCH 06/11] test: drop unrelated reformatting from merge resolution Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/test_litellm/test_cost_calculator.py | 335 +++++++++++++++------ 1 file changed, 241 insertions(+), 94 deletions(-) diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index f2ee2cdbd9a..c23f5c08a70 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -1,3 +1,4 @@ + import json from pathlib import Path from typing import Final @@ -148,7 +149,9 @@ def test_jina_rerank_bills_total_tokens_at_input_rate_only(_local_model_cost_map 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(), @@ -204,9 +207,7 @@ def test_vertex_lyria_speech_cost( call_type=call_type, ) - expected: Final = ( - 0 if runtime_state == "custom_zero" else expected_cost * (2 if runtime_state == "custom_price" else 1) - ) + expected: Final = 0 if runtime_state == "custom_zero" else expected_cost * (2 if runtime_state == "custom_price" else 1) assert cost == pytest.approx(expected) @@ -333,12 +334,13 @@ def test_cost_calculator_with_usage(_local_model_cost_map, 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"] @@ -373,9 +375,12 @@ def test_cost_calculator_with_usage(_local_model_cost_map, 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"] ) @@ -385,11 +390,14 @@ def test_cost_calculator_with_usage(_local_model_cost_map, monkeypatch): def test_transcription_cost_uses_token_pricing(_local_model_cost_map): from litellm import completion_cost + usage = Usage( 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 @@ -433,6 +441,7 @@ def test_transcription_token_pricing_is_provider_aware(_local_model_cost_map): def test_transcription_cost_falls_back_to_duration(_local_model_cost_map): from litellm import completion_cost + response = TranscriptionResponse(text="demo text") response.duration = 10.0 @@ -453,6 +462,7 @@ def test_vertex_chirp_3_transcription_cost_from_duration(_local_model_cost_map): every transcription priced to $0.00 instead of using input_cost_per_second.""" from litellm import completion_cost + response = TranscriptionResponse(text="demo text") response.duration = 18.0 @@ -476,7 +486,9 @@ 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", @@ -507,7 +519,9 @@ 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" @@ -587,7 +601,14 @@ 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 @@ -661,7 +682,9 @@ 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, @@ -711,7 +734,9 @@ 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, @@ -723,7 +748,8 @@ 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", @@ -756,7 +782,9 @@ 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"}} + }, }, }, { @@ -771,7 +799,9 @@ 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=[], @@ -864,7 +894,9 @@ 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, @@ -945,7 +977,10 @@ 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" @@ -1108,7 +1143,9 @@ 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", @@ -1188,7 +1225,9 @@ def test_azure_realtime_cost_calculator(_local_model_cost_map): 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", @@ -1207,6 +1246,7 @@ def test_azure_audio_output_cost_calculation(_local_model_cost_map): """ from litellm.types.utils import Choices, CompletionTokensDetailsWrapper, Message + # Scenario from issue #19764: # Input: 17 text tokens, 0 audio tokens # Output: 110 text tokens, 482 audio tokens @@ -1262,10 +1302,14 @@ def test_azure_audio_output_cost_calculation(_local_model_cost_map): 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): @@ -1279,7 +1323,9 @@ def test_default_image_cost_calculator(monkeypatch): monkeypatch.setattr( litellm, "model_cost", - {"azure/bf9001cd7209f5734ecb4ab937a5a0e2ba5f119708bd68f184db362930f9dc7b": temp_object}, + { + "azure/bf9001cd7209f5734ecb4ab937a5a0e2ba5f119708bd68f184db362930f9dc7b": temp_object + }, ) args = { @@ -1495,7 +1541,9 @@ 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}") @@ -1566,7 +1614,9 @@ 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) @@ -1574,8 +1624,12 @@ 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, @@ -1583,23 +1637,31 @@ 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 @@ -1613,9 +1675,13 @@ 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}") @@ -1674,7 +1740,8 @@ 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 ) @@ -1698,6 +1765,7 @@ def test_azure_ai_cache_cost_calculation(_local_model_cost_map): from litellm.litellm_core_utils.llm_cost_calc.utils import generic_cost_per_token from litellm.types.utils import PromptTokensDetailsWrapper, Usage + # Register a custom azure_ai model with cache pricing test_model_id = "test-azure-ai-claude-model" litellm.register_model( @@ -1746,12 +1814,13 @@ def test_azure_ai_cache_cost_calculation(_local_model_cost_map): 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}" + AZURE_GPT_5_6_MAP_KEYS = ( @@ -1820,7 +1889,6 @@ def test_azure_gpt_5_6_rates_match_azure_price_page(_local_model_cost_map, model for key in token_cost_keys: assert entry[key] == pytest.approx(global_entry[key] * 1.1), key - def test_vertex_regional_deployment_costs_uplift_over_global(monkeypatch): """ Regression for https://github.com/BerriAI/litellm/issues/34393: two Vertex @@ -1903,6 +1971,7 @@ def test_cost_discount_vertex_ai(monkeypatch): from litellm import completion_cost from litellm.types.utils import Usage + # Create mock response (use a model that exists in model_prices_and_context_window.json) response = ModelResponse( id="test-id", @@ -1931,6 +2000,7 @@ def test_cost_discount_vertex_ai(monkeypatch): custom_llm_provider="vertex_ai", ) + # Verify discount is applied (5% off means 95% of original cost) expected_cost = cost_without_discount * 0.95 assert cost_with_discount == pytest.approx(expected_cost, rel=1e-9) @@ -1948,6 +2018,7 @@ def test_cost_discount_not_applied_to_other_providers(monkeypatch): from litellm import completion_cost from litellm.types.utils import Usage + # Create mock response for OpenAI response = ModelResponse( id="test-id", @@ -1976,6 +2047,7 @@ def test_cost_discount_not_applied_to_other_providers(monkeypatch): custom_llm_provider="openai", ) + # Costs should be the same (no discount applied to OpenAI) assert cost_with_selective_discount == cost_without_discount @@ -1991,6 +2063,7 @@ def test_cost_margin_percentage(monkeypatch): from litellm import completion_cost from litellm.types.utils import Usage + # Create mock response response = ModelResponse( id="test-id", @@ -2019,6 +2092,7 @@ def test_cost_margin_percentage(monkeypatch): custom_llm_provider="openai", ) + # Verify margin is applied (10% margin means 110% of original cost) expected_cost = cost_without_margin * 1.10 assert cost_with_margin == pytest.approx(expected_cost, rel=1e-9) @@ -2036,6 +2110,7 @@ def test_cost_margin_fixed_amount(monkeypatch): from litellm import completion_cost from litellm.types.utils import Usage + # Create mock response response = ModelResponse( id="test-id", @@ -2064,6 +2139,7 @@ def test_cost_margin_fixed_amount(monkeypatch): custom_llm_provider="openai", ) + # Verify fixed margin is applied expected_cost = cost_without_margin + 0.001 assert cost_with_margin == pytest.approx(expected_cost, rel=1e-9) @@ -2081,6 +2157,7 @@ def test_cost_margin_combined(monkeypatch): from litellm import completion_cost from litellm.types.utils import Usage + # Create mock response response = ModelResponse( id="test-id", @@ -2100,7 +2177,9 @@ def test_cost_margin_combined(monkeypatch): ) # Set 8% margin + $0.0005 fixed for openai - monkeypatch.setattr(litellm, "cost_margin_config", {"openai": {"percentage": 0.08, "fixed_amount": 0.0005}}) + monkeypatch.setattr(litellm, "cost_margin_config", { + "openai": {"percentage": 0.08, "fixed_amount": 0.0005} + }) # Calculate cost with margin cost_with_margin = completion_cost( @@ -2109,6 +2188,7 @@ def test_cost_margin_combined(monkeypatch): custom_llm_provider="openai", ) + # Verify combined margin is applied expected_cost = cost_without_margin * 1.08 + 0.0005 assert cost_with_margin == pytest.approx(expected_cost, rel=1e-9) @@ -2126,6 +2206,7 @@ def test_cost_margin_global(monkeypatch): from litellm import completion_cost from litellm.types.utils import Usage + # Create mock response response = ModelResponse( id="test-id", @@ -2154,6 +2235,7 @@ def test_cost_margin_global(monkeypatch): custom_llm_provider="openai", ) + # Verify global margin is applied expected_cost = cost_without_margin * 1.05 assert cost_with_global_margin == pytest.approx(expected_cost, rel=1e-9) @@ -2171,6 +2253,7 @@ def test_cost_margin_provider_overrides_global(monkeypatch): from litellm import completion_cost from litellm.types.utils import Usage + # Create mock response response = ModelResponse( id="test-id", @@ -2199,13 +2282,16 @@ def test_cost_margin_provider_overrides_global(monkeypatch): custom_llm_provider="openai", ) + # Verify provider-specific margin is used (not global) expected_cost = cost_without_margin * 1.10 # 10% from provider, not 5% from global assert cost_with_provider_margin == pytest.approx(expected_cost, rel=1e-9) 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}") @@ -2216,6 +2302,7 @@ def test_cost_margin_with_discount(monkeypatch): from litellm import completion_cost from litellm.types.utils import Usage + # Create mock response response = ModelResponse( id="test-id", @@ -2246,6 +2333,7 @@ def test_cost_margin_with_discount(monkeypatch): custom_llm_provider="openai", ) + # Verify: discount applied first, then margin # Base cost -> discount: base * 0.95 -> margin: (base * 0.95) * 1.10 expected_cost = base_cost * 0.95 * 1.10 @@ -2283,7 +2371,9 @@ 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, ), @@ -2313,6 +2403,7 @@ def test_completion_cost_extracts_service_tier_from_response(_local_model_cost_m """Test that completion_cost extracts service_tier from completion_response object.""" from litellm import completion_cost + # Test with gpt-5-nano which has flex pricing model = "gpt-5-nano" @@ -2353,18 +2444,23 @@ def test_completion_cost_extracts_service_tier_from_response(_local_model_cost_m 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(_local_model_cost_map): """Test that completion_cost extracts service_tier from usage object.""" from litellm import completion_cost + # Test with gpt-5-nano which has flex pricing 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") @@ -2382,7 +2478,9 @@ def test_completion_cost_extracts_service_tier_from_usage(_local_model_cost_map) ) # 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( @@ -2403,13 +2501,16 @@ def test_completion_cost_extracts_service_tier_from_usage(_local_model_cost_map) 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(_local_model_cost_map): """Test that service_tier extraction follows priority: optional_params > completion_response > usage.""" from litellm import completion_cost + # Test with gpt-5-nano which has flex pricing model = "gpt-5-nano" @@ -2458,13 +2559,16 @@ def test_completion_cost_service_tier_priority(_local_model_cost_map): 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(_local_model_cost_map): """Test that Bedrock cost calculation applies service_tier-specific pricing.""" from litellm import completion_cost + model = "bedrock/us-east-1/test-bedrock-service-tier-cost-model" litellm.register_model( model_cost={ @@ -2520,6 +2624,7 @@ def test_completion_cost_service_tier_for_anthropic(_local_model_cost_map): from litellm import completion_cost from litellm.llms.anthropic.chat.transformation import AnthropicConfig + model = "claude-test-service-tier-cost-model" litellm.register_model( model_cost={ @@ -2572,6 +2677,7 @@ def test_completion_cost_anthropic_auto_tier_uses_served_priority_rate(_local_mo from litellm import completion_cost from litellm.llms.anthropic.chat.transformation import AnthropicConfig + model = "claude-test-auto-tier-cost-model" litellm.register_model( model_cost={ @@ -2665,6 +2771,7 @@ def test_completion_cost_non_string_service_tier_defers_to_served_tier(_local_mo from litellm import completion_cost from litellm.llms.anthropic.chat.transformation import AnthropicConfig + model = "claude-test-non-string-tier-cost-model" litellm.register_model( model_cost={ @@ -2714,6 +2821,7 @@ def test_completion_cost_non_string_response_service_tier_defers_to_served_tier( from litellm import completion_cost from litellm.llms.anthropic.chat.transformation import AnthropicConfig + model = "claude-test-response-non-string-tier-cost-model" litellm.register_model( model_cost={ @@ -2736,7 +2844,9 @@ 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, @@ -2759,6 +2869,7 @@ def test_completion_cost_non_string_usage_service_tier_prices_standard(_local_mo """ from litellm import completion_cost + model = "claude-test-usage-non-string-tier-cost-model" litellm.register_model( model_cost={ @@ -2805,6 +2916,7 @@ def test_anthropic_cost_per_token_prices_cache_at_served_tier_with_multiplier(_l ) from litellm.types.utils import PromptTokensDetailsWrapper, Usage + model = "claude-test-priority-cache-fast-model" litellm.register_model( model_cost={ @@ -2830,7 +2942,9 @@ def test_anthropic_cost_per_token_prices_cache_at_served_tier_with_multiplier(_l ) 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" + ) expected_prompt = ((1000 - 200) * 6e-6 + 200 * 0.6e-6) * 2 expected_completion = 500 * 30e-6 * 2 @@ -2960,7 +3074,9 @@ def test_anthropic_fast_multiplier_only_on_models_with_fast_mode(_local_model_co "model", ["claude-sonnet-4-6", "claude-mythos-5", "claude-mythos-preview"], ) -def test_anthropic_us_data_residency_uplift_on_claude_4_6_and_later_models(_local_model_cost_map, monkeypatch, model): +def test_anthropic_us_data_residency_uplift_on_claude_4_6_and_later_models( + _local_model_cost_map, monkeypatch, model +): """ Anthropic bills every Claude 4.6+ model served with ``inference_geo="us"`` at 1.1x, and echoes that geo back in the response usage, so each of these real @@ -3025,26 +3141,28 @@ 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" - ) + 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") + print( + "✅ Issue #18750 fix verified: text_tokens is correctly calculated and non-negative" + ) def test_gemini_without_cache_tokens_details(): @@ -3112,18 +3230,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( @@ -3161,7 +3279,9 @@ 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(_local_model_cost_map): @@ -3175,6 +3295,7 @@ def test_additional_costs_only_for_azure_ai(_local_model_cost_map): """ from litellm.cost_calculator import _get_additional_costs + # Non-azure_ai providers should return None result = _get_additional_costs( model="gpt-4o", @@ -3317,7 +3438,12 @@ 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) @@ -3362,7 +3488,9 @@ 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) @@ -3402,7 +3530,10 @@ 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(): @@ -3444,7 +3575,12 @@ 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(): @@ -3571,6 +3707,7 @@ def test_completion_cost_logs_reasoning_and_cache_breakdown(_local_model_cost_ma from litellm.litellm_core_utils.litellm_logging import Logging from litellm.types.utils import Choices, CompletionTokensDetailsWrapper, Message + logging_obj = Logging( model="gemini-2.5-flash", messages=[{"role": "user", "content": "Hello"}], @@ -3597,8 +3734,12 @@ def test_completion_cost_logs_reasoning_and_cache_breakdown(_local_model_cost_ma 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 + ), ), ) @@ -3664,7 +3805,9 @@ def test_completion_cost_logs_the_rates_it_billed_at(monkeypatch): assert rates is not None assert rates.input_cost_per_token == pytest.approx(6e-6) assert rates.cache_read_input_token_cost == pytest.approx(6e-7) - assert logging_obj.cost_breakdown["cache_read_cost"] == pytest.approx(100_000 * rates.cache_read_input_token_cost) + assert logging_obj.cost_breakdown["cache_read_cost"] == pytest.approx( + 100_000 * rates.cache_read_input_token_cost + ) assert logging_obj.cost_breakdown["output_cost"] == pytest.approx(1_000 * rates.output_cost_per_token) @@ -3835,7 +3978,11 @@ def test_completion_cost_bills_interactions_api_response(): cost = completion_cost(completion_response=response, custom_llm_provider="gemini") reasoning_rate = model_info.get("output_cost_per_reasoning_token") or model_info["output_cost_per_token"] - expected = 100 * model_info["input_cost_per_token"] + 50 * model_info["output_cost_per_token"] + 25 * reasoning_rate + expected = ( + 100 * model_info["input_cost_per_token"] + + 50 * model_info["output_cost_per_token"] + + 25 * reasoning_rate + ) assert cost == pytest.approx(expected) assert cost > 0 @@ -4006,9 +4153,7 @@ def test_completion_cost_prices_anthropic_shaped_cache_read_tokens(_local_model_ assert cost == pytest.approx(3 * 4e-6 + 4014 * 4e-7 + 5 * 2e-5, rel=1e-9) -def _together_chat_response( - model: str, prompt_tokens: int, completion_tokens: int, cached_tokens: int -) -> ModelResponse: +def _together_chat_response(model: str, prompt_tokens: int, completion_tokens: int, cached_tokens: int) -> ModelResponse: return ModelResponse( id="chatcmpl-together-cache", choices=[{"finish_reason": "stop", "index": 0, "message": {"content": "acknowledged", "role": "assistant"}}], @@ -4076,8 +4221,6 @@ def test_completion_cost_together_metadata_only_model_still_uses_size_bucket(_lo ) assert cost == pytest.approx((23 + 15) * 8e-07, rel=1e-9) - - def test_select_model_name_strips_unregistered_alias_prefix(_local_model_cost_map): """A router-facing model_name alias containing "/" whose leading segment is NOT a registered provider must not be double-prefixed into a non-existent cost key. @@ -4318,7 +4461,9 @@ def test_every_one_hour_cache_write_rate_is_double_its_input_rate(): """Guard against pasting one model's 1h cache-write price onto another: every provider LiteLLM tracks (Anthropic, Bedrock, Vertex, Azure) publishes the 1h write at 2x input.""" - cost_map = json.loads((Path(__file__).parents[2] / "model_prices_and_context_window.json").read_text()) + cost_map = json.loads( + (Path(__file__).parents[2] / "model_prices_and_context_window.json").read_text() + ) one_hour_prefix = "cache_creation_input_token_cost_above_1hr" deviations = { (name, key): (entry["input_cost_per_token" + key[len(one_hour_prefix) :]], entry[key]) @@ -4524,7 +4669,9 @@ def test_batch_cost_calculator_gpt_6_astra_bills_half_the_standard_rate(_local_m usage = Usage(prompt_tokens=1000, completion_tokens=500, total_tokens=1500) - prompt_cost, completion_cost = batch_cost_calculator(usage=usage, model="gpt-6-astra", custom_llm_provider="openai") + prompt_cost, completion_cost = batch_cost_calculator( + usage=usage, model="gpt-6-astra", custom_llm_provider="openai" + ) assert prompt_cost == pytest.approx(1000 * 5e-6) assert completion_cost == pytest.approx(500 * 2.5e-5) From 59c4cf94397207fa0604d58dac31027b94b8467f Mon Sep 17 00:00:00 2001 From: shivam Date: Sat, 12 Sep 2026 23:37:07 +0000 Subject: [PATCH 07/11] refactor(responses): build InputTokensDetails without post-construction mutation Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../transformation.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index c75e5d0f5ea..14957a5e5aa 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -2748,17 +2748,20 @@ class LiteLLMCompletionResponsesConfig: cache_write_tokens: Final = getattr(prompt_details, "cache_write_tokens", None) or getattr( prompt_details, "cache_creation_tokens", None ) - input_tokens_details: Final = InputTokensDetails( + cache_write_extra: Final[Mapping[str, int]] = ( + MappingProxyType({"cache_write_tokens": cache_write_tokens}) + if cache_write_tokens is not None + else MappingProxyType({}) + ) + response_usage.input_tokens_details = InputTokensDetails( cached_tokens=prompt_details.cached_tokens if prompt_details.cached_tokens is not None else 0, text_tokens=prompt_details.text_tokens, audio_tokens=prompt_details.audio_tokens, cached_tokens_details=( cached_tokens_details if isinstance(cached_tokens_details, CachedTokensDetails) else None ), + **cache_write_extra, ) - if cache_write_tokens is not None: - setattr(input_tokens_details, "cache_write_tokens", cache_write_tokens) - response_usage.input_tokens_details = input_tokens_details # Translate completion_tokens_details to output_tokens_details if hasattr(usage, "completion_tokens_details") and usage.completion_tokens_details is not None: From 4b84c83788a8e9e4db02b0b85a5f43fe0c49ea30 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:59:23 -0700 Subject: [PATCH 08/11] fix(cost): split the cache read breakdown at the audio cache-read rate --- .../litellm_core_utils/llm_cost_calc/utils.py | 32 +++++++++++++++---- .../llm_cost_calc/test_llm_cost_calc_utils.py | 23 +++++++++++++ 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index dc689ca9618..8fc428b38ae 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -1355,6 +1355,7 @@ class BilledTokenRates: input_cost_per_token: float output_cost_per_token: float cache_read_input_token_cost: float + cache_read_input_audio_token_cost: float cache_creation_input_token_cost: float cache_creation_input_token_cost_above_1hr: float output_cost_per_reasoning_token: float @@ -1366,6 +1367,7 @@ class BilledTokenRates: input_cost_per_token=self.input_cost_per_token * multiplier, output_cost_per_token=self.output_cost_per_token * multiplier, cache_read_input_token_cost=self.cache_read_input_token_cost * multiplier, + cache_read_input_audio_token_cost=self.cache_read_input_audio_token_cost * multiplier, cache_creation_input_token_cost=self.cache_creation_input_token_cost * multiplier, cache_creation_input_token_cost_above_1hr=self.cache_creation_input_token_cost_above_1hr * multiplier, output_cost_per_reasoning_token=self.output_cost_per_reasoning_token * multiplier, @@ -1389,15 +1391,16 @@ def _reasoning_token_count(usage: Usage) -> int: return parsed or _coerce_token_count(getattr(usage, "reasoning_tokens", 0)) -def _cache_token_counts(usage: Usage) -> tuple[int, int, CacheCreationTokenDetails | None]: - """(cache read tokens, cache creation tokens, cache creation details): read from prompt_tokens_details - first, then the private top-level counters the Usage constructor mirrors cache tokens onto for - providers/callers that bypass the details.""" +def _cache_token_counts(usage: Usage) -> tuple[int, int, int, CacheCreationTokenDetails | None]: + """(cache read tokens, cached audio tokens, cache creation tokens, cache creation details): read from + prompt_tokens_details first, then the private top-level counters the Usage constructor mirrors cache + tokens onto for providers/callers that bypass the details.""" parsed: Final = parse_prompt_tokens_details(usage) if usage.prompt_tokens_details is not None else None parsed_read: Final = parsed["cache_hit_tokens"] if parsed is not None else 0 parsed_creation: Final = parsed["cache_creation_tokens"] if parsed is not None else 0 return ( parsed_read or _coerce_token_count(getattr(usage, "_cache_read_input_tokens", 0)), + parsed["cache_hit_audio_tokens"] if parsed is not None else 0, parsed_creation or _coerce_token_count(getattr(usage, "_cache_creation_input_tokens", 0)), parsed["cache_creation_token_details"] if parsed is not None else None, ) @@ -1408,11 +1411,13 @@ def _custom_pricing_rates(custom_cost_per_token: CostPerToken) -> BilledTokenRat cache rates (else the input rate) and reasoning at the output rate, as _cost_per_token_custom_pricing_helper does.""" input_rate: Final = custom_cost_per_token["input_cost_per_token"] output_rate: Final = custom_cost_per_token["output_cost_per_token"] + cache_read_rate: Final = custom_cost_per_token.get("cache_read_input_token_cost", input_rate) cache_creation_rate: Final = custom_cost_per_token.get("cache_creation_input_token_cost", input_rate) return BilledTokenRates( input_cost_per_token=input_rate, output_cost_per_token=output_rate, - cache_read_input_token_cost=custom_cost_per_token.get("cache_read_input_token_cost", input_rate), + cache_read_input_token_cost=cache_read_rate, + cache_read_input_audio_token_cost=cache_read_rate, cache_creation_input_token_cost=cache_creation_rate, cache_creation_input_token_cost_above_1hr=cache_creation_rate, output_cost_per_reasoning_token=output_rate, @@ -1449,6 +1454,11 @@ def _cost_map_billed_rates( completion_base_cost=completion_base_cost, current_time=billing_time, ) + audio_cache_read_rate: Final = _get_cost_per_unit( + model_info, + _get_service_tier_cost_key("cache_read_input_audio_token_cost", service_tier), + None, + ) multiplier: Final = ( _get_regional_uplift_multiplier(model_info, data_residency) * get_vertex_regional_endpoint_uplift(model_info, vertex_location) @@ -1458,6 +1468,9 @@ def _cost_map_billed_rates( input_cost_per_token=prompt_base_cost, output_cost_per_token=completion_base_cost, cache_read_input_token_cost=cache_read_cost_rate, + cache_read_input_audio_token_cost=( + audio_cache_read_rate if audio_cache_read_rate is not None else cache_read_cost_rate + ), cache_creation_input_token_cost=cache_creation_cost_rate, cache_creation_input_token_cost_above_1hr=cache_creation_cost_above_1hr_rate, output_cost_per_reasoning_token=reasoning_rate, @@ -1530,7 +1543,9 @@ def get_token_type_cost_breakdown( if rates is None: return TokenTypeCostBreakdown(0.0, 0.0, 0.0) - cache_read_tokens, cache_creation_tokens, cache_creation_token_details = _cache_token_counts(usage) + cache_read_tokens, cached_audio_tokens, cache_creation_tokens, cache_creation_token_details = _cache_token_counts( + usage + ) cache_creation_cost: Final = ( float(cache_creation_tokens) * rates.cache_creation_input_token_cost if custom_cost_per_token is not None @@ -1543,7 +1558,10 @@ def get_token_type_cost_breakdown( ) return TokenTypeCostBreakdown( reasoning_cost=float(_reasoning_token_count(usage)) * rates.output_cost_per_reasoning_token, - cache_read_cost=float(cache_read_tokens) * rates.cache_read_input_token_cost, + cache_read_cost=( + float(cache_read_tokens - cached_audio_tokens) * rates.cache_read_input_token_cost + + float(cached_audio_tokens) * rates.cache_read_input_audio_token_cost + ), cache_creation_cost=cache_creation_cost, rates=rates, ) diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index 5ff1ab62698..21b96127a74 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -4006,6 +4006,7 @@ def test_billed_token_rates_follow_the_token_tier_the_breakdown_bills_at(monkeyp input_cost_per_token=6e-6, output_cost_per_token=3e-5, cache_read_input_token_cost=6e-7, + cache_read_input_audio_token_cost=6e-7, cache_creation_input_token_cost=7.5e-6, cache_creation_input_token_cost_above_1hr=0.0, output_cost_per_reasoning_token=3e-5, @@ -5252,3 +5253,25 @@ def test_cached_audio_tokens_billed_at_audio_cache_rate_through_model_info_looku prompt_cost, _ = generic_cost_per_token(model="gpt-realtime-2.1-mini", usage=usage, custom_llm_provider="openai") assert prompt_cost == pytest.approx(300 * 6e-7 + 100 * 6e-8 + 200 * 1e-5 + 400 * 3e-7) + + +def test_cache_read_breakdown_splits_cached_audio_at_the_audio_cache_rate(_local_model_cost_map: None) -> None: + usage = Usage( + prompt_tokens=4863, + completion_tokens=1087, + total_tokens=5950, + prompt_tokens_details=PromptTokensDetailsWrapper( + text_tokens=1693, + audio_tokens=3170, + cached_tokens=2816, + cached_tokens_details={"text_tokens": 896, "audio_tokens": 1920}, + ), + ) + + breakdown = get_token_type_cost_breakdown(model="gpt-realtime-2.1-mini", custom_llm_provider="openai", usage=usage) + prompt_cost, _ = generic_cost_per_token(model="gpt-realtime-2.1-mini", usage=usage, custom_llm_provider="openai") + + assert breakdown.cache_read_cost == pytest.approx(896 * 6e-8 + 1920 * 3e-7) + assert breakdown.rates is not None + assert breakdown.rates.cache_read_input_audio_token_cost == pytest.approx(3e-7) + assert prompt_cost == pytest.approx((1693 - 896) * 6e-7 + (3170 - 1920) * 1e-5 + breakdown.cache_read_cost) From a94c060b84815438dc3a6dda379e0fb7fa60448f Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:48:00 -0700 Subject: [PATCH 09/11] fix(cost): fill the missing realtime cache-read rates --- ...odel_prices_and_context_window_backup.json | 9 ++++-- model_prices_and_context_window.json | 9 ++++-- .../llm_cost_calc/test_llm_cost_calc_utils.py | 28 +++++++++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index de9867f9eee..2ce8914ba5a 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -5513,7 +5513,8 @@ }, "azure/gpt-realtime-2025-08-28": { "cache_creation_input_audio_token_cost": 4e-06, - "cache_read_input_token_cost": 4e-06, + "cache_read_input_audio_token_cost": 4e-07, + "cache_read_input_token_cost": 4e-07, "deprecation_date": "2027-03-02", "input_cost_per_audio_token": 3.2e-05, "input_cost_per_image_token": 5e-06, @@ -5546,7 +5547,8 @@ }, "azure/gpt-realtime-1.5-2026-02-23": { "cache_creation_input_audio_token_cost": 4e-06, - "cache_read_input_token_cost": 4e-06, + "cache_read_input_audio_token_cost": 4e-07, + "cache_read_input_token_cost": 4e-07, "deprecation_date": "2027-08-24", "input_cost_per_audio_token": 3.2e-05, "input_cost_per_image_token": 5e-06, @@ -5683,6 +5685,7 @@ }, "azure/gpt-realtime-mini": { "cache_creation_input_audio_token_cost": 3e-07, + "cache_read_input_audio_token_cost": 3e-07, "cache_read_input_token_cost": 6e-08, "input_cost_per_audio_token": 1e-05, "input_cost_per_image_token": 8e-07, @@ -5715,6 +5718,7 @@ }, "azure/gpt-realtime-mini-2025-10-06": { "cache_creation_input_audio_token_cost": 3e-07, + "cache_read_input_audio_token_cost": 3e-07, "cache_read_input_token_cost": 6e-08, "input_cost_per_audio_token": 1e-05, "input_cost_per_image_token": 8e-07, @@ -32683,6 +32687,7 @@ "gpt-realtime-mini": { "cache_creation_input_audio_token_cost": 3e-07, "cache_read_input_audio_token_cost": 3e-07, + "cache_read_input_token_cost": 6e-08, "deprecation_date": "2027-01-20", "input_cost_per_audio_token": 1e-05, "input_cost_per_token": 6e-07, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index de9867f9eee..2ce8914ba5a 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -5513,7 +5513,8 @@ }, "azure/gpt-realtime-2025-08-28": { "cache_creation_input_audio_token_cost": 4e-06, - "cache_read_input_token_cost": 4e-06, + "cache_read_input_audio_token_cost": 4e-07, + "cache_read_input_token_cost": 4e-07, "deprecation_date": "2027-03-02", "input_cost_per_audio_token": 3.2e-05, "input_cost_per_image_token": 5e-06, @@ -5546,7 +5547,8 @@ }, "azure/gpt-realtime-1.5-2026-02-23": { "cache_creation_input_audio_token_cost": 4e-06, - "cache_read_input_token_cost": 4e-06, + "cache_read_input_audio_token_cost": 4e-07, + "cache_read_input_token_cost": 4e-07, "deprecation_date": "2027-08-24", "input_cost_per_audio_token": 3.2e-05, "input_cost_per_image_token": 5e-06, @@ -5683,6 +5685,7 @@ }, "azure/gpt-realtime-mini": { "cache_creation_input_audio_token_cost": 3e-07, + "cache_read_input_audio_token_cost": 3e-07, "cache_read_input_token_cost": 6e-08, "input_cost_per_audio_token": 1e-05, "input_cost_per_image_token": 8e-07, @@ -5715,6 +5718,7 @@ }, "azure/gpt-realtime-mini-2025-10-06": { "cache_creation_input_audio_token_cost": 3e-07, + "cache_read_input_audio_token_cost": 3e-07, "cache_read_input_token_cost": 6e-08, "input_cost_per_audio_token": 1e-05, "input_cost_per_image_token": 8e-07, @@ -32683,6 +32687,7 @@ "gpt-realtime-mini": { "cache_creation_input_audio_token_cost": 3e-07, "cache_read_input_audio_token_cost": 3e-07, + "cache_read_input_token_cost": 6e-08, "deprecation_date": "2027-01-20", "input_cost_per_audio_token": 1e-05, "input_cost_per_token": 6e-07, diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index 21b96127a74..7cd19c65887 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -5275,3 +5275,31 @@ def test_cache_read_breakdown_splits_cached_audio_at_the_audio_cache_rate(_local assert breakdown.rates is not None assert breakdown.rates.cache_read_input_audio_token_cost == pytest.approx(3e-7) assert prompt_cost == pytest.approx((1693 - 896) * 6e-7 + (3170 - 1920) * 1e-5 + breakdown.cache_read_cost) + + +@pytest.mark.parametrize( + ("model", "custom_llm_provider", "expected_prompt_cost"), + ( + pytest.param("azure/gpt-realtime-2025-08-28", "azure", 300 * 4e-6 + 100 * 4e-7 + 200 * 3.2e-5 + 400 * 4e-7, id="azure-gpt-realtime"), + pytest.param("azure/gpt-realtime-1.5-2026-02-23", "azure", 300 * 4e-6 + 100 * 4e-7 + 200 * 3.2e-5 + 400 * 4e-7, id="azure-gpt-realtime-1.5"), + pytest.param("azure/gpt-realtime-mini", "azure", 300 * 6e-7 + 100 * 6e-8 + 200 * 1e-5 + 400 * 3e-7, id="azure-gpt-realtime-mini"), + pytest.param("gpt-realtime-mini", "openai", 300 * 6e-7 + 100 * 6e-8 + 200 * 1e-5 + 400 * 3e-7, id="openai-gpt-realtime-mini"), + ), +) +def test_realtime_models_bill_cached_text_and_audio_at_their_cache_read_rates( + _local_model_cost_map: None, model: str, custom_llm_provider: str, expected_prompt_cost: float +) -> None: + usage = Usage( + prompt_tokens=1000, + completion_tokens=0, + total_tokens=1000, + prompt_tokens_details=PromptTokensDetailsWrapper( + text_tokens=400, + audio_tokens=600, + cached_tokens=500, + cached_tokens_details={"text_tokens": 100, "audio_tokens": 400}, + ), + ) + + prompt_cost, _ = generic_cost_per_token(model=model, usage=usage, custom_llm_provider=custom_llm_provider) + assert prompt_cost == pytest.approx(expected_prompt_cost) From 76ae35dfcdefbb6b09cc576909c69b54bae5999d Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:14:35 -0700 Subject: [PATCH 10/11] fix(types): break the CachedTokensDetails import cycle CodeQL flagged two module-level cyclic imports introduced by defining CachedTokensDetails in litellm.types.llms.openai and importing it from litellm.types.utils and litellm.cost_calculator. The class now lives in litellm.types.llms.base, which imports nothing from litellm, and every user imports it from there. Also pins that combining realtime usages where only one response.done carries cached_tokens_details keeps the earlier modality split in both orders, and commits the regenerated dashboard API types. --- litellm/cost_calculator.py | 2 +- .../transformation.py | 2 +- litellm/types/llms/base.py | 6 +++ litellm/types/llms/openai.py | 8 +--- litellm/types/utils.py | 2 +- tests/test_litellm/test_cost_calculator.py | 43 +++++++++++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 - 7 files changed, 53 insertions(+), 12 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index ce5c84907a1..f5319776213 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -97,6 +97,7 @@ from litellm.llms.vertex_ai.cost_calculator import cost_router as google_cost_ro from litellm.llms.xai.cost_calculator import cost_per_token as xai_cost_per_token from litellm.responses.utils import ResponseAPILoggingUtils from litellm.types.agents import LiteLLMSendMessageResponse +from litellm.types.llms.base import CachedTokensDetails from litellm.types.llms.openai import ( HttpxBinaryResponseContent, ImageGenerationRequestQuality, @@ -109,7 +110,6 @@ from litellm.types.llms.openai import ( ) from litellm.types.rerank import RerankBilledUnits, RerankResponse from litellm.types.utils import ( - CachedTokensDetails, CallTypesLiteral, LiteLLMRealtimeStreamLoggingObject, LlmProviders, diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 7a215cdbb12..64324c6cad8 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -43,9 +43,9 @@ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging from litellm.responses.litellm_completion_transformation.session_handler import ( ResponsesSessionHandler, ) +from litellm.types.llms.base import CachedTokensDetails from litellm.types.llms.openai import ( AllMessageValues, - CachedTokensDetails, ChatCompletionAssistantMessage, ChatCompletionImageObject, ChatCompletionImageUrlObject, diff --git a/litellm/types/llms/base.py b/litellm/types/llms/base.py index f09727ad92b..938aa8064c9 100644 --- a/litellm/types/llms/base.py +++ b/litellm/types/llms/base.py @@ -75,3 +75,9 @@ class HiddenParams(OpenAIObject): data: Final = super().model_dump(**kwargs) data["_response_ms"] = self._response_ms return data + + +class CachedTokensDetails(BaseModel): + text_tokens: int | None = None + audio_tokens: int | None = None + image_tokens: int | None = None diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 7a86c27efae..9bdad700d4b 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -82,7 +82,7 @@ from typing_extensions import ( override, ) -from litellm.types.llms.base import BaseLiteLLMOpenAIResponseObject +from litellm.types.llms.base import BaseLiteLLMOpenAIResponseObject, CachedTokensDetails from litellm.types.responses.main import ( CustomToolCallOutputItem, GenericResponseOutputItem, @@ -1285,12 +1285,6 @@ class OutputTokensDetails(BaseLiteLLMOpenAIResponseObject): model_config = {"extra": "allow"} -class CachedTokensDetails(BaseModel): - text_tokens: int | None = None - audio_tokens: int | None = None - image_tokens: int | None = None - - class InputTokensDetails(BaseLiteLLMOpenAIResponseObject): audio_tokens: int | None = None cached_tokens: int = 0 diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 07e6fc5838b..1d73542c9bb 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -48,6 +48,7 @@ from litellm._logging import verbose_logger from litellm._uuid import uuid from litellm.types.llms.base import ( BaseLiteLLMOpenAIResponseObject, + CachedTokensDetails, LiteLLMPydanticObjectBase, ) from litellm.types.mcp import MCPServerCostInfo @@ -60,7 +61,6 @@ from .llms.base import HiddenParams from .llms.openai import ( AllMessageValues, Batch, - CachedTokensDetails, ChatCompletionAnnotation, ChatCompletionReasoningItem, ChatCompletionRedactedThinkingBlock, diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index a16f1b8fe4a..68e9b6143a0 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -19,6 +19,7 @@ from litellm.cost_calculator import ( ) from litellm.litellm_core_utils.litellm_logging import Logging from litellm.llms.base_llm.ocr.transformation import OCRPage, OCRResponse, OCRUsageInfo +from litellm.types.llms.base import CachedTokensDetails from litellm.types.llms.openai import OpenAIRealtimeStreamList from litellm.types.rerank import RerankResponse from litellm.types.utils import ( @@ -4896,6 +4897,48 @@ def test_realtime_combine_sums_nested_cached_tokens_details(): assert combined.prompt_tokens_details.cached_tokens_details.image_tokens is None +@pytest.mark.parametrize("details_first", [True, False]) +def test_realtime_combine_keeps_cached_split_when_only_one_usage_has_details(details_first: bool): + with_details: Final = { + "type": "response.done", + "response": { + "usage": { + "input_tokens": 283, + "output_tokens": 0, + "total_tokens": 283, + "input_token_details": { + "text_tokens": 116, + "audio_tokens": 167, + "cached_tokens": 192, + "cached_tokens_details": {"text_tokens": 64, "audio_tokens": 128}, + }, + } + }, + } + without_details: Final = { + "type": "response.done", + "response": { + "usage": { + "input_tokens": 150, + "output_tokens": 0, + "total_tokens": 150, + "input_token_details": {"text_tokens": 50, "audio_tokens": 100, "cached_tokens": 100}, + } + }, + } + results: OpenAIRealtimeStreamList = ( + [with_details, without_details] if details_first else [without_details, with_details] + ) + + combined = RealtimeAPITokenUsageProcessor.collect_and_combine_usage_from_realtime_stream_results( + results=results, + ) + + assert combined.prompt_tokens_details is not None + assert combined.prompt_tokens_details.cached_tokens == 292 + assert combined.prompt_tokens_details.cached_tokens_details == CachedTokensDetails(text_tokens=64, audio_tokens=128) + + def test_usage_without_cached_tokens_details_omits_key(): usage = Usage( prompt_tokens=10, diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 7eadaa6c991..839aa52fa84 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -16781,7 +16781,6 @@ export interface paths { * - permissions: Optional[dict] - [Not Implemented Yet] User-specific permissions, eg. turning off pii masking. * - metadata: Optional[dict] - Metadata for user, store information for user. Example metadata = {"team": "core-infra", "app": "app2", "email": "ishaan@berri.ai" } * - max_parallel_requests: Optional[int] - Rate limit a user based on the number of parallel requests. Raises 429 error, if user's parallel requests > x. - * - soft_budget: Optional[float] - Get alerts when user crosses given budget, doesn't block requests. * - model_max_budget: Optional[dict] - Model-specific max budget for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-budgets-to-keys) * - budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}. * - model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys) @@ -16887,7 +16886,6 @@ export interface paths { * - permissions: Optional[dict] - [Not Implemented Yet] User-specific permissions, eg. turning off pii masking. * - metadata: Optional[dict] - Metadata for user, store information for user. Example metadata = {"team": "core-infra", "app": "app2", "email": "ishaan@berri.ai" } * - max_parallel_requests: Optional[int] - Rate limit a user based on the number of parallel requests. Raises 429 error, if user's parallel requests > x. - * - soft_budget: Optional[float] - Get alerts when user crosses given budget, doesn't block requests. * - model_max_budget: Optional[dict] - Model-specific max budget for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-budgets-to-keys) * - budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}. * - model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys) From 6f7882db34b4d15a0dec402b6218c59afc58c012 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:54:12 -0700 Subject: [PATCH 11/11] fix(types): import CachedTokensDetails on its own line in openai.py CodeQL resolves `from openai import Omit` in litellm/types/llms/openai.py to the module itself, so every importer of a name whose definition line is in the diff is reported as an unsafe cyclic import. 76ae35dfcd edited the line that defines BaseLiteLLMOpenAIResponseObject there and got two alerts at files this PR does not touch. That line is now byte-identical to main and CachedTokensDetails arrives through a relative import isort keeps separate. --- litellm/types/llms/openai.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 9bdad700d4b..e3eac9b9205 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -82,7 +82,7 @@ from typing_extensions import ( override, ) -from litellm.types.llms.base import BaseLiteLLMOpenAIResponseObject, CachedTokensDetails +from litellm.types.llms.base import BaseLiteLLMOpenAIResponseObject from litellm.types.responses.main import ( CustomToolCallOutputItem, GenericResponseOutputItem, @@ -91,6 +91,8 @@ from litellm.types.responses.main import ( OutputImageGenerationCall, ) +from .base import CachedTokensDetails + FileContent = IO[bytes] | bytes | PathLike FileTypes = (