diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 98af08f6472..951e336b766 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -18,6 +18,7 @@ from litellm.constants import ( DEFAULT_MAX_LRU_CACHE_SIZE, DEFAULT_REPLICATE_GPU_PRICE_PER_SECOND, ) +from litellm.litellm_core_utils.audio_utils.utils import normalized_audio_duration_seconds from litellm.litellm_core_utils.llm_cost_calc.tool_call_cost_tracking import ( StandardBuiltInToolCostTracking, ) @@ -3030,12 +3031,14 @@ def handle_realtime_translation_cost_calculation( if isinstance(usage := result.get("usage"), dict) ) input_seconds: Final = sum( - float(usage["input_seconds"]) for usage in usage_events if isinstance(usage.get("input_seconds"), (int, float)) + seconds + for usage in usage_events + if (seconds := normalized_audio_duration_seconds(usage.get("input_seconds"))) is not None ) output_seconds: Final = sum( - float(usage["output_seconds"]) + seconds for usage in usage_events - if isinstance(usage.get("output_seconds"), (int, float)) + if (seconds := normalized_audio_duration_seconds(usage.get("output_seconds"))) is not None ) if input_seconds <= 0 and output_seconds <= 0: return 0.0 diff --git a/litellm/litellm_core_utils/audio_utils/utils.py b/litellm/litellm_core_utils/audio_utils/utils.py index dab3e48f91a..9fdfce057d9 100644 --- a/litellm/litellm_core_utils/audio_utils/utils.py +++ b/litellm/litellm_core_utils/audio_utils/utils.py @@ -3,6 +3,7 @@ Utils used for litellm.transcription() and litellm.atranscription() """ import hashlib +import math import os from dataclasses import dataclass from typing import Final @@ -17,6 +18,16 @@ from litellm.types.files import ( from litellm.types.utils import FileTypes +def normalized_audio_duration_seconds(value: object) -> float | None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + try: + seconds: Final = float(value) + except OverflowError: + return None + return seconds if math.isfinite(seconds) and seconds >= 0 else None + + @dataclass class ProcessedAudioFile: """ diff --git a/litellm/litellm_core_utils/realtime_streaming.py b/litellm/litellm_core_utils/realtime_streaming.py index 25fc063a578..a8284a20ad7 100644 --- a/litellm/litellm_core_utils/realtime_streaming.py +++ b/litellm/litellm_core_utils/realtime_streaming.py @@ -12,6 +12,7 @@ from typing_extensions import ReadOnly import litellm from litellm._logging import redact_internal_details_from_client_message, verbose_logger from litellm.constants import REALTIME_SESSION_FAILURE_LOGGED_KEY, REALTIME_SESSION_SUCCESS_LOGGED_KEY +from litellm.litellm_core_utils.audio_utils.utils import normalized_audio_duration_seconds from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER from litellm.llms.base_llm.realtime.transformation import BaseRealtimeConfig, RealtimeBackend from litellm.types.llms.openai import ( @@ -439,19 +440,21 @@ class RealTimeStreaming: return if event_obj.get("type") == "session.closed": usage: Final = event_obj.get("usage") - output_seconds: Final = usage.get("output_seconds") if isinstance(usage, dict) else None - input_seconds: Final = usage.get("input_seconds") if isinstance(usage, dict) else None + output_seconds: Final = ( + normalized_audio_duration_seconds(usage.get("output_seconds")) if isinstance(usage, dict) else None + ) + input_seconds: Final = ( + normalized_audio_duration_seconds(usage.get("input_seconds")) if isinstance(usage, dict) else None + ) synthetic_output_seconds: Final = ( self._translation_output_audio_bytes / self._translation_output_bytes_per_second if self._translation_output_audio_bytes > 0 else None ) - resolved_output_seconds: Final = ( - output_seconds if isinstance(output_seconds, (int, float)) else synthetic_output_seconds - ) - if isinstance(input_seconds, (int, float)) or resolved_output_seconds is not None: + resolved_output_seconds: Final = output_seconds if output_seconds is not None else synthetic_output_seconds + if input_seconds is not None or resolved_output_seconds is not None: if self._should_store_message(event_obj): - if not isinstance(output_seconds, (int, float)) and synthetic_output_seconds is not None: + if output_seconds is None and synthetic_output_seconds is not None: self.messages.append( OpenAIRealtimeTranslationClosedEvent( type="session.closed", @@ -464,10 +467,10 @@ class RealTimeStreaming: normalized_usage: Final = ( OpenAIRealtimeTranslationDurationUsage( type="duration", - input_seconds=float(input_seconds), + input_seconds=input_seconds, output_seconds=float(resolved_output_seconds or 0.0), ) - if isinstance(input_seconds, (int, float)) + if input_seconds is not None else OpenAIRealtimeTranslationDurationUsage( type="duration", output_seconds=float(resolved_output_seconds or 0.0) ) @@ -522,9 +525,9 @@ class RealTimeStreaming: if event.get("type") != "session.closed": continue event_usage = event.get("usage") # rebind-ok: each close event carries independent usage - if isinstance(event_usage, dict) and ( - isinstance(event_usage.get("input_seconds"), (int, float)) - or isinstance(event_usage.get("output_seconds"), (int, float)) + if ( + isinstance(event_usage, dict) + and normalized_audio_duration_seconds(event_usage.get("output_seconds")) is not None ): self._translation_usage_finalized = True return diff --git a/litellm/realtime_api/README.md b/litellm/realtime_api/README.md index d9699f57da7..d810de2f24f 100644 --- a/litellm/realtime_api/README.md +++ b/litellm/realtime_api/README.md @@ -6,16 +6,4 @@ Supported endpoints: Supported providers: OpenAI, Azure OpenAI, Bedrock, Vertex AI, xAI. -Billing visibility: -- WebSocket sessions pass provider usage events through LiteLLM and support local spend tracking -- Client-secret and SDP call endpoints only proxy session setup; subsequent WebRTC media and usage events travel over the peer connection, so LiteLLM cannot record inference spend or enforce spend-based budgets for those sessions -- Use the proxied WebSocket transport when LiteLLM spend logs and budgets must include Realtime inference - -Non-billable Realtime protocols are disabled by default. Operators who accept the billing and budget-enforcement limitation can opt in: - -```yaml -general_settings: - allow_non_billable_realtime_protocols: true -``` - -For user-facing documentation and usage examples, see the litellm-docs repo. +For user-facing documentation and usage examples, see the litellm-docs repo. \ No newline at end of file diff --git a/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py b/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py index 205e18e26fe..6dd582e2cec 100644 --- a/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py +++ b/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py @@ -3049,6 +3049,27 @@ def test_translation_retained_input_only_close_event_bills_captured_output(monke assert sum(usage.get("output_seconds", 0.0) for usage in usage_events) == 1.0 +@pytest.mark.parametrize("malformed_output", [-1.0, float("nan"), float("inf"), 10**1000]) +def test_translation_malformed_provider_duration_uses_captured_audio(malformed_output: float | int) -> None: + streaming = RealTimeStreaming( + websocket=MagicMock(), + backend_ws=MagicMock(), + logging_obj=MagicMock(), + model="gpt-realtime-translate", + translation_session=True, + ) + streaming._translation_output_audio_bytes = 48000 + + streaming._capture_translation_output_audio( + {"type": "session.closed", "usage": {"type": "duration", "input_seconds": 0.25, "output_seconds": malformed_output}} + ) + streaming._finalize_translation_usage() + + close_events: Final = tuple(event for event in streaming.messages if event.get("type") == "session.closed") + assert len(close_events) == 1 + assert close_events[0]["usage"] == {"type": "duration", "input_seconds": 0.25, "output_seconds": 1.0} + + @pytest.mark.asyncio async def test_audio_delta_frame_parsed_at_most_once(): client_ws = _beta_client_ws() diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index 31529a72a6e..cf2e43a2540 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -4715,3 +4715,34 @@ def test_realtime_translation_duration_cost_includes_provider_input_usage( + (output_seconds or 0) * litellm.model_cost[model]["output_cost_per_second"] ) assert actual == pytest.approx(expected) + + +@pytest.mark.parametrize("malformed_duration", [-1.0, float("nan"), float("inf"), 10**1000, True]) +def test_realtime_translation_duration_cost_ignores_malformed_provider_usage( + _local_model_cost_map, malformed_duration: float | int | bool +) -> None: + from litellm.cost_calculator import handle_realtime_translation_cost_calculation + + model: Final = "gpt-realtime-translate" + input_invalid_events: Final[OpenAIRealtimeStreamList] = [ + { + "type": "session.closed", + "usage": {"type": "duration", "input_seconds": malformed_duration, "output_seconds": 1.5}, + } + ] + output_invalid_events: Final[OpenAIRealtimeStreamList] = [ + { + "type": "session.closed", + "usage": {"type": "duration", "input_seconds": 2.0, "output_seconds": malformed_duration}, + } + ] + + input_invalid_cost: Final = handle_realtime_translation_cost_calculation( + results=input_invalid_events, custom_llm_provider="openai", litellm_model_name=model + ) + output_invalid_cost: Final = handle_realtime_translation_cost_calculation( + results=output_invalid_events, custom_llm_provider="openai", litellm_model_name=model + ) + + assert input_invalid_cost == pytest.approx(1.5 * litellm.model_cost[model]["output_cost_per_second"]) + assert output_invalid_cost == pytest.approx(2.0 * litellm.model_cost[model]["input_cost_per_second"])