From 8783a3b3011c2904d147a656102ed32f08cd2829 Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Tue, 15 Sep 2026 13:31:02 -0500 Subject: [PATCH] fix(audio): bill finalized streams and authorize transcription models --- litellm/cost_calculator.py | 20 ++++++++++++++++++- litellm/litellm_core_utils/litellm_logging.py | 18 ++++++++++++++--- .../usage_object_transformation.py | 10 +++++++--- litellm/proxy/realtime_endpoints/endpoints.py | 7 +++++++ litellm/utils.py | 2 +- .../test_realtime_webrtc_endpoints.py | 17 ++++++++++++++-- .../transcriptions/test_gpt_transcribe.py | 15 ++++++++------ 7 files changed, 73 insertions(+), 16 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 2e4448475a7..82acd10c186 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -628,7 +628,25 @@ def cost_per_token( data_residency=data_residency, ) elif call_type == "atranscription" or call_type == "transcription": - if _transcription_usage_has_token_details(usage_block): + transcription_model_info: Final = _cached_get_model_info_helper( + model=model_without_prefix, custom_llm_provider=custom_llm_provider + ) + has_token_pricing: Final = any( + transcription_model_info.get(field) + for field in ( + "input_cost_per_token", + "output_cost_per_token", + "input_cost_per_audio_token", + "output_cost_per_audio_token", + ) + ) + if _transcription_usage_has_token_details(usage_block) and ( + has_token_pricing + or ( + transcription_model_info.get("input_cost_per_second") is None + and transcription_model_info.get("output_cost_per_second") is None + ) + ): return generic_cost_per_token( model=model_without_prefix, usage=usage_block, diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 8e28a0d543d..3eba410b4aa 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -3286,7 +3286,12 @@ class Logging(LiteLLMLoggingBaseClass): if "async_complete_streaming_response" in self.model_call_details: return # break out of this. complete_streaming_response: Final[ - ModelResponse | TextCompletionResponse | ResponsesAPIResponse | InteractionsAPIResponse | None + ModelResponse + | TextCompletionResponse + | ResponsesAPIResponse + | InteractionsAPIResponse + | TranscriptionResponse + | None ] = self._get_assembled_streaming_response( result=result, start_time=start_time, @@ -4105,10 +4110,17 @@ class Logging(LiteLLMLoggingBaseClass): end_time: datetime.datetime, is_async: bool, streaming_chunks: list[object], - ) -> ModelResponse | TextCompletionResponse | ResponsesAPIResponse | InteractionsAPIResponse | None: + ) -> ( + ModelResponse + | TextCompletionResponse + | ResponsesAPIResponse + | InteractionsAPIResponse + | TranscriptionResponse + | None + ): if self.stream is not True: return None - if isinstance(result, ModelResponse) or isinstance(result, TextCompletionResponse): + if isinstance(result, (ModelResponse, TextCompletionResponse, TranscriptionResponse)): return result elif isinstance( result, diff --git a/litellm/litellm_core_utils/llm_cost_calc/usage_object_transformation.py b/litellm/litellm_core_utils/llm_cost_calc/usage_object_transformation.py index a02c40b7611..a34e4de2dca 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/usage_object_transformation.py +++ b/litellm/litellm_core_utils/llm_cost_calc/usage_object_transformation.py @@ -31,9 +31,13 @@ class TranscriptionUsageObjectTransformation: prompt_tokens=usage_object.input_tokens, completion_tokens=usage_object.output_tokens, total_tokens=usage_object.total_tokens, - prompt_tokens_details=PromptTokensDetailsWrapper( - text_tokens=usage_object.input_token_details.text_tokens, - audio_tokens=usage_object.input_token_details.audio_tokens, + prompt_tokens_details=( + PromptTokensDetailsWrapper( + text_tokens=usage_object.input_token_details.text_tokens, + audio_tokens=usage_object.input_token_details.audio_tokens, + ) + if usage_object.input_token_details is not None + else None ), ) return None diff --git a/litellm/proxy/realtime_endpoints/endpoints.py b/litellm/proxy/realtime_endpoints/endpoints.py index bee2a55f5b5..eb3a8430923 100644 --- a/litellm/proxy/realtime_endpoints/endpoints.py +++ b/litellm/proxy/realtime_endpoints/endpoints.py @@ -675,6 +675,13 @@ async def create_realtime_transcription_session( ) transcription_session: Final = {k: v for k, v in body.items() if k != "model"} + await _authorize_and_bind_nested_transcription_models( + session_data=transcription_session, + user_api_key_dict=user_api_key_dict, + llm_model_list=llm_model_list, + llm_router=llm_router, + ) + _set_transcription_model_on_session(session=transcription_session, model=model, create_if_missing=True) data = {"model": model, "transcription_session": transcription_session} data = await add_litellm_data_to_request( diff --git a/litellm/utils.py b/litellm/utils.py index d39d045468e..ba00ae4c6d3 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -3468,7 +3468,7 @@ def get_optional_params_transcription( from litellm.constants import OPENAI_TRANSCRIPTION_PARAMS # retrieve all parameters passed to the function - passed_params: Final = locals() + passed_params: Final = locals().copy() passed_params.pop("OPENAI_TRANSCRIPTION_PARAMS") passed_params.pop("model") diff --git a/tests/test_litellm/proxy/realtime_endpoints/test_realtime_webrtc_endpoints.py b/tests/test_litellm/proxy/realtime_endpoints/test_realtime_webrtc_endpoints.py index 10c2543021e..19242d7bdd2 100644 --- a/tests/test_litellm/proxy/realtime_endpoints/test_realtime_webrtc_endpoints.py +++ b/tests/test_litellm/proxy/realtime_endpoints/test_realtime_webrtc_endpoints.py @@ -661,8 +661,14 @@ def test_transcription_sessions_requires_auth(proxy_app): @pytest.mark.asyncio +@pytest.mark.parametrize("body", [ + {"input_audio_transcription": {"model": "gpt-realtime-whisper"}}, + {"model": "gpt-4o-realtime-preview", "audio": {"input": {"transcription": {"model": "gpt-realtime-whisper"}}}}, + {"input_audio_transcription": {"model": "gpt-4o-realtime-preview"}, "audio": {"input": {"transcription": {"model": "gpt-realtime-whisper"}}}}, + {"model": "gpt-4o-realtime-preview", "input_audio_transcription": {"model": "gpt-realtime-whisper"}}, +]) async def test_transcription_sessions_rejects_disallowed_resolved_model( - proxy_app, + proxy_app, body, ): proxy_app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( user_id="test-user", @@ -679,7 +685,7 @@ async def test_transcription_sessions_rejects_disallowed_resolved_model( response = client.post( "/v1/realtime/transcription_sessions", headers={"Authorization": "Bearer sk-test-master-key"}, - json={"input_audio_transcription": {"model": "gpt-realtime-whisper"}}, + json=body, ) assert response.status_code == 403 @@ -1032,6 +1038,7 @@ async def test_transcription_sessions_encrypts_client_secret( async def _capturing_route(*args, **kwargs): captured_route_type["route_type"] = kwargs.get("route_type") + captured_route_type["session"] = kwargs["data"]["transcription_session"] return await mock_route_request_transcription_sessions(*args, **kwargs) try: @@ -1054,6 +1061,8 @@ async def test_transcription_sessions_encrypts_client_secret( "/v1/realtime/transcription_sessions", headers={"Authorization": "Bearer sk-test-master-key"}, json={ + "model": "gpt-realtime-whisper", + "audio": {"input": {"transcription": {"model": "gpt-live-transcribe", "language": "en"}}}, "input_audio_format": "pcm16", "input_audio_transcription": {"model": "gpt-realtime-whisper"}, }, @@ -1072,6 +1081,10 @@ async def test_transcription_sessions_encrypts_client_secret( assert "upstream_ephemeral_key" in decrypted # Routed through the dedicated transcription_sessions route type. assert captured_route_type["route_type"] == "acreate_realtime_transcription_session" + assert captured_route_type["session"]["input_audio_transcription"]["model"] == "gpt-realtime-whisper" + assert captured_route_type["session"]["audio"]["input"]["transcription"] == { + "model": "gpt-realtime-whisper", "language": "en", + } finally: proxy_app.dependency_overrides.pop(user_api_key_auth, None) diff --git a/tests/unit/llms/openai/transcriptions/test_gpt_transcribe.py b/tests/unit/llms/openai/transcriptions/test_gpt_transcribe.py index 6ccbeaf690a..35bed209400 100644 --- a/tests/unit/llms/openai/transcriptions/test_gpt_transcribe.py +++ b/tests/unit/llms/openai/transcriptions/test_gpt_transcribe.py @@ -143,10 +143,12 @@ async def test_atranscription_stream_preserves_duration_for_callback_cost(): http_client = httpx.AsyncClient(transport=httpx.MockTransport(send_response)) openai_client = AsyncOpenAI(api_key="sk-test", base_url="https://example.com/v1", http_client=http_client) - logging_obj = MagicMock() - logging_obj.model_call_details = {} - logging_obj.async_success_handler = AsyncMock() - logging_obj.async_failure_handler = AsyncMock() + from litellm.litellm_core_utils.litellm_logging import Logging + + logging_obj = Logging( + model="gpt-transcribe", messages=[], stream=True, call_type="atranscription", + start_time=datetime.now(), litellm_call_id="transcription-cost-test", function_id="transcription-cost-test", + ) audio_file = io.BytesIO() with wave.open(audio_file, "wb") as wav_file: wav_file.setnchannels(1) @@ -167,8 +169,9 @@ async def test_atranscription_stream_preserves_duration_for_callback_cost(): await openai_client.close() assert [event.type for event in received] == ["transcript.text.delta", "transcript.text.done"] - logging_obj.async_success_handler.assert_awaited_once() - logged_response = logging_obj.async_success_handler.await_args.kwargs["result"] + logged_response = logging_obj.model_call_details["async_complete_streaming_response"] + assert logging_obj.model_call_details["response_cost"] == pytest.approx(0.000075) + assert logging_obj.model_call_details["standard_logging_object"]["response_cost"] == pytest.approx(0.000075) assert logged_response._hidden_params["audio_transcription_duration"] == pytest.approx(1.0)