fix(realtime): reject malformed provider durations

This commit is contained in:
Emerson Gomes 2026-09-23 03:40:09 -05:00
parent 8f23d20367
commit 275bc4c524
No known key found for this signature in database
GPG key ID: D3DF28AB5D1B5E17
6 changed files with 85 additions and 28 deletions

View file

@ -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

View file

@ -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:
"""

View file

@ -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

View file

@ -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.

View file

@ -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()

View file

@ -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"])