diff --git a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/openai_websocket_passthrough_logging_handler.py b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/openai_websocket_passthrough_logging_handler.py index 27abb333a36..eb2b4edf2fe 100644 --- a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/openai_websocket_passthrough_logging_handler.py +++ b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/openai_websocket_passthrough_logging_handler.py @@ -22,12 +22,15 @@ WebsocketMessages: TypeAlias = Sequence[Mapping[str, object]] OPENAI_WEBSOCKET_PROVIDER: Final = LlmProviders.OPENAI.value _SESSION_CREATED: Final = "session.created" +_SESSION_UPDATED: Final = "session.updated" _RESPONSE_CREATED: Final = "response.created" _ERROR: Final = "error" _REALTIME_RESPONSE_DONE: Final = "response.done" +_TRANSCRIPTION_COMPLETED: Final = "conversation.item.input_audio_transcription.completed" _RESPONSES_TURN_ENDS: Final = frozenset({"response.completed", "response.incomplete", "response.failed"}) _TURN_ENDS: Final = _RESPONSES_TURN_ENDS | {_REALTIME_RESPONSE_DONE} -_MODEL_NAMING_EVENTS: Final = _TURN_ENDS | {_SESSION_CREATED, _RESPONSE_CREATED} +_MODEL_NAMING_EVENTS: Final = _TURN_ENDS | {_SESSION_CREATED, _SESSION_UPDATED, _RESPONSE_CREATED} +_REALTIME_BILLED_EVENTS: Final = frozenset({_REALTIME_RESPONSE_DONE, _TRANSCRIPTION_COMPLETED}) _RESPONSE_COST_HEADER: Final = "llm_provider-x-litellm-response-cost" @@ -37,10 +40,29 @@ class _NamedModel(BaseModel): model: str | None = None +class _AudioInput(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + transcription: _NamedModel | None = None + + +class _SessionAudio(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + input: _AudioInput | None = None + + +class _NamedSession(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + model: str | None = None + audio: _SessionAudio | None = None + + class _ModelNamingEvent(BaseModel): model_config = ConfigDict(extra="ignore", frozen=True) - session: _NamedModel | None = None + session: _NamedSession | None = None response: _NamedModel | None = None @@ -75,14 +97,11 @@ def _event_model(event: Mapping[str, object]) -> str | None: naming: Final = _MODEL_NAMING_EVENT.validate_python(event) except ValidationError: return None - return next( - ( - container.model - for container in (naming.session, naming.response) - if container is not None and container.model - ), - None, - ) + session: Final = naming.session or _NamedSession() + response: Final = naming.response or _NamedModel() + audio_input: Final = (session.audio or _SessionAudio()).input or _AudioInput() + transcription: Final = audio_input.transcription or _NamedModel() + return session.model or response.model or transcription.model or None def observed_websocket_model(messages: WebsocketMessages) -> str | None: @@ -103,7 +122,7 @@ def openai_websocket_frame_kind(frame: Mapping[str, object]) -> WebsocketUpstrea def _realtime_billing(messages: WebsocketMessages, model: str, logging_obj: LiteLLMLoggingObj) -> _Billing | None: - if not any(event.get("type") == _REALTIME_RESPONSE_DONE for event in messages): + if not any(event.get("type") in _REALTIME_BILLED_EVENTS for event in messages): return None results: Final = cast( # cast-ok: the realtime cost helpers read raw provider frames through their TypedDict view OpenAIRealtimeStreamList, tuple(event for event in messages if "type" in event) diff --git a/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_openai_websocket_passthrough_logging_handler.py b/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_openai_websocket_passthrough_logging_handler.py index 6d2e22fbfcd..baf52af112b 100644 --- a/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_openai_websocket_passthrough_logging_handler.py +++ b/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_openai_websocket_passthrough_logging_handler.py @@ -19,6 +19,7 @@ from litellm.proxy.pass_through_endpoints.llm_provider_handlers.openai_websocket REALTIME_MODEL: Final = "gpt-realtime-2.1" RESPONSES_MODEL: Final = "gpt-5.4-mini" REASONING_MODEL: Final = "gpt-5.4" +TRANSCRIPTION_MODEL: Final = "gpt-4o-mini-transcribe" SESSION_START: Final = datetime(2026, 1, 1, 12, 0, 0) @@ -308,6 +309,83 @@ def test_a_turn_the_caller_walked_out_of_still_names_the_model_it_ran(): assert logging_obj.model_call_details["model"] == REASONING_MODEL +def _transcription_session(model: str) -> tuple[dict[str, object], ...]: + return ( + {"type": "session.created", "session": {"type": "transcription", "audio": {"input": {"transcription": None}}}}, + { + "type": "session.updated", + "session": {"type": "transcription", "audio": {"input": {"transcription": {"model": model}}}}, + }, + ) + + +def _transcription_completed(usage: Mapping[str, object]) -> dict[str, object]: + return { + "type": "conversation.item.input_audio_transcription.completed", + "item_id": "item_fake", + "transcript": "hello", + "usage": usage, + } + + +@pytest.mark.parametrize( + ("usage", "billed_units"), + [ + ({"type": "duration", "seconds": 3}, ((3, "input_cost_per_second"),)), + ( + { + "type": "tokens", + "input_tokens": 30, + "output_tokens": 11, + "input_token_details": {"text_tokens": 0, "audio_tokens": 30}, + }, + ((30, "input_cost_per_audio_token"), (11, "output_cost_per_token")), + ), + ], +) +def test_a_transcription_session_is_billed_for_every_clip_it_transcribed(usage, billed_units): + handled: Final = _handle( + [ + *_transcription_session(TRANSCRIPTION_MODEL), + _transcription_completed(usage), + _transcription_completed(usage), + ], + url_route="/openai_passthrough/v1/realtime", + ) + + logged_cost: Final = litellm.response_cost_calculator( + response_object=handled["result"], + model=handled["kwargs"]["model"], + custom_llm_provider="openai", + call_type="completion", + optional_params={}, + ) + + prices: Final = litellm.model_cost[TRANSCRIPTION_MODEL] + expected_cost: Final = 2 * sum(units * prices[rate] for units, rate in billed_units) + assert expected_cost > 0 + assert handled["kwargs"]["model"] == TRANSCRIPTION_MODEL + assert handled["kwargs"]["response_cost"] == pytest.approx(expected_cost) + assert logged_cost == pytest.approx(expected_cost) + + +def test_a_conversation_that_also_transcribes_the_caller_is_logged_on_its_own_model(): + handled: Final = _handle( + [ + { + "type": "session.created", + "session": { + "model": REALTIME_MODEL, + "audio": {"input": {"transcription": {"model": TRANSCRIPTION_MODEL}}}, + }, + }, + _realtime_response_done(text_in=10, audio_in=0, text_out=5, audio_out=0), + ] + ) + + assert handled["kwargs"]["model"] == REALTIME_MODEL + + @pytest.mark.parametrize("event", ["response.incomplete", "response.failed"]) def test_a_responses_turn_that_ended_early_is_billed_for_the_tokens_it_used(event): """