diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index cc0f818b0a0..6354bf44943 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -1284,8 +1284,14 @@ def completion_cost( # noqa: PLR0915 elif call_type in _SPEECH_CALL_TYPES: prompt_characters = litellm.utils._count_characters(text=prompt) elif call_type in _TRANSCRIPTION_CALL_TYPES: - audio_transcription_file_duration = getattr( - completion_response, "duration", 0.0 + # Check _hidden_params first (duration stored there to + # avoid polluting the response body), then fall back to + # the response attribute (for verbose_json responses that + # naturally include duration from the provider). + _hidden = getattr(completion_response, "_hidden_params", {}) or {} + audio_transcription_file_duration = _hidden.get( + "audio_transcription_duration", + getattr(completion_response, "duration", 0.0), ) elif call_type in _RERANK_CALL_TYPES: if completion_response is not None and isinstance( diff --git a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py index a2b03d0eb6d..ae11b57a98f 100644 --- a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py +++ b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py @@ -760,6 +760,12 @@ def convert_to_model_response_object( # noqa: PLR0915 if hidden_params is not None: model_response_object._hidden_params = hidden_params + # Store internally-calculated duration in _hidden_params for cost + # tracking without exposing it in the response body. Must be set + # after hidden_params assignment to avoid being overwritten. + if "_audio_transcription_duration" in response_object: + model_response_object._hidden_params["audio_transcription_duration"] = response_object["_audio_transcription_duration"] + if _response_headers is not None: model_response_object._response_headers = _response_headers diff --git a/litellm/llms/azure/audio_transcriptions.py b/litellm/llms/azure/audio_transcriptions.py index 8519b1c35a5..70b2f1ccc08 100644 --- a/litellm/llms/azure/audio_transcriptions.py +++ b/litellm/llms/azure/audio_transcriptions.py @@ -158,7 +158,7 @@ class AzureAudioTranscription(AzureChatCompletion): else: stringified_response = TranscriptionResponse(text=response).model_dump() duration = extract_duration_from_srt_or_vtt(response) - stringified_response["duration"] = duration + stringified_response["_audio_transcription_duration"] = duration ## LOGGING logging_obj.post_call( diff --git a/litellm/llms/openai/transcriptions/handler.py b/litellm/llms/openai/transcriptions/handler.py index e241d2c1c7d..397b4c9956f 100644 --- a/litellm/llms/openai/transcriptions/handler.py +++ b/litellm/llms/openai/transcriptions/handler.py @@ -209,7 +209,7 @@ class OpenAIAudioTranscription(OpenAIChatCompletion): else: duration = extract_duration_from_srt_or_vtt(response) stringified_response = TranscriptionResponse(text=response).model_dump() - stringified_response["duration"] = duration + stringified_response["_audio_transcription_duration"] = duration ## LOGGING logging_obj.post_call( input=get_audio_file_name(audio_file), diff --git a/litellm/main.py b/litellm/main.py index 8b239c454f4..1adf790bf61 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -6240,18 +6240,20 @@ async def atranscription(*args, **kwargs) -> TranscriptionResponse: f"Invalid response from transcription provider, expected TranscriptionResponse, but got {type(response)}" ) - # Calculate and add duration if response is missing it + # Store duration in _hidden_params for cost calculation without + # exposing it in the response body. Adding duration to the response + # tricks the OpenAI SDK's "best match deserialization" into thinking + # a plain Transcription is a TranscriptionVerbose/Diarized type. if ( response is not None and not isinstance(response, Coroutine) and file is not None ): - # Check if response is missing duration existing_duration = getattr(response, "duration", None) if existing_duration is None: calculated_duration = calculate_request_duration(file) if calculated_duration is not None: - setattr(response, "duration", calculated_duration) + response._hidden_params["audio_transcription_duration"] = calculated_duration return response except Exception as e: @@ -6467,14 +6469,14 @@ def transcription( shared_session=shared_session, ) - # Calculate and add duration if response is missing it + # Store duration in _hidden_params for cost calculation without + # exposing it in the response body (see sync path comment above). if response is not None and not isinstance(response, Coroutine): - # Check if response is missing duration existing_duration = getattr(response, "duration", None) if existing_duration is None: calculated_duration = calculate_request_duration(file) if calculated_duration is not None: - setattr(response, "duration", calculated_duration) + response._hidden_params["audio_transcription_duration"] = calculated_duration if response is None: raise ValueError("Unmapped provider passed in. Unable to get the response.") diff --git a/tests/test_litellm/llms/openai/transcriptions/test_transcription_duration_hidden.py b/tests/test_litellm/llms/openai/transcriptions/test_transcription_duration_hidden.py new file mode 100644 index 00000000000..2b287e456a1 --- /dev/null +++ b/tests/test_litellm/llms/openai/transcriptions/test_transcription_duration_hidden.py @@ -0,0 +1,153 @@ +""" +Tests that audio transcription duration is stored in _hidden_params +instead of the response body. + +Adding duration to the response body tricks the OpenAI SDK's "best match +deserialization" into thinking a plain Transcription is a +TranscriptionVerbose/Diarized type. +""" + +from unittest.mock import patch + +from litellm.cost_calculator import completion_cost +from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import ( + convert_to_model_response_object, +) +from litellm.types.utils import TranscriptionResponse + + +class TestTranscriptionDurationNotInResponseBody: + """Duration calculated internally should be in _hidden_params, not in the response body.""" + + def test_convert_dict_stores_internal_duration_in_hidden_params(self): + """ + When the response dict contains _audio_transcription_duration (set by + the handler for internally-calculated durations), it should be stored + in _hidden_params and NOT appear in the response body. + """ + response_object = { + "text": "Hello world", + "_audio_transcription_duration": 12.5, + } + + result = convert_to_model_response_object( + response_object=response_object, + model_response_object=TranscriptionResponse(), + response_type="audio_transcription", + ) + + assert result._hidden_params["audio_transcription_duration"] == 12.5 + assert not hasattr(result, "_audio_transcription_duration") + + def test_convert_dict_preserves_provider_duration(self): + """ + When the provider returns duration naturally (e.g. verbose_json format), + it should still appear in the response body as normal. + """ + response_object = { + "text": "Hello world", + "language": "en", + "duration": 42.7, + "segments": [], + } + + result = convert_to_model_response_object( + response_object=response_object, + model_response_object=TranscriptionResponse(), + response_type="audio_transcription", + ) + + assert result.duration == 42.7 + + def test_plain_json_response_has_no_duration(self): + """ + A plain json transcription response (no verbose_json) should not have + a duration attribute in the response body. + """ + response_object = { + "text": "Four score and seven years ago", + } + + result = convert_to_model_response_object( + response_object=response_object, + model_response_object=TranscriptionResponse(), + response_type="audio_transcription", + ) + + duration = getattr(result, "duration", None) + assert duration is None + + +class TestCostCalculatorReadsDurationFromHiddenParams: + """The cost calculator should read duration from _hidden_params via completion_cost().""" + + @patch("litellm.cost_calculator.openai_cost_per_second") + def test_completion_cost_uses_hidden_params_duration(self, mock_cost_fn): + """ + completion_cost() should pass the duration from _hidden_params to + openai_cost_per_second when calculating transcription costs. + """ + mock_cost_fn.return_value = (0.001, 0.0) + + response = TranscriptionResponse(text="test") + response._hidden_params = { + "audio_transcription_duration": 17.5, + "model": "whisper-1", + "custom_llm_provider": "openai", + } + + completion_cost( + completion_response=response, + model="whisper-1", + call_type="atranscription", + ) + + mock_cost_fn.assert_called_once() + _, kwargs = mock_cost_fn.call_args + assert kwargs["duration"] == 17.5 + + @patch("litellm.cost_calculator.openai_cost_per_second") + def test_completion_cost_falls_back_to_response_duration(self, mock_cost_fn): + """ + When _hidden_params doesn't have duration (e.g. verbose_json response + where the provider returned it), fall back to response.duration. + """ + mock_cost_fn.return_value = (0.001, 0.0) + + response = TranscriptionResponse(text="test") + response._hidden_params = { + "model": "whisper-1", + "custom_llm_provider": "openai", + } + response.duration = 42.7 # type: ignore + + completion_cost( + completion_response=response, + model="whisper-1", + call_type="atranscription", + ) + + mock_cost_fn.assert_called_once() + _, kwargs = mock_cost_fn.call_args + assert kwargs["duration"] == 42.7 + + @patch("litellm.cost_calculator.openai_cost_per_second") + def test_completion_cost_defaults_to_zero_duration(self, mock_cost_fn): + """When neither hidden params nor response has duration, use 0.0.""" + mock_cost_fn.return_value = (0.0, 0.0) + + response = TranscriptionResponse(text="test") + response._hidden_params = { + "model": "whisper-1", + "custom_llm_provider": "openai", + } + + completion_cost( + completion_response=response, + model="whisper-1", + call_type="atranscription", + ) + + mock_cost_fn.assert_called_once() + _, kwargs = mock_cost_fn.call_args + assert kwargs["duration"] == 0.0