mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
fix(realtime): reject malformed provider durations
This commit is contained in:
parent
8f23d20367
commit
275bc4c524
6 changed files with 85 additions and 28 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue