From 7dd4f17021d32cf70a908f6e2e464d8c2cb74baa Mon Sep 17 00:00:00 2001 From: Chesars Date: Thu, 26 Feb 2026 16:06:08 -0300 Subject: [PATCH 1/3] fix(transcription): store duration in _hidden_params to avoid OpenAI SDK deserialization issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LiteLLM was adding a `duration` field to audio transcription responses for internal cost tracking. The OpenAI Python SDK uses "best match deserialization" to determine the response type from present fields — seeing `duration` caused it to incorrectly match plain Transcription responses as TranscriptionVerbose/TranscriptionDiarized types. Move the internally-calculated duration to `_hidden_params` so it remains available for cost calculation without polluting the response body. Provider-returned duration (e.g. from verbose_json format) is still preserved in the response as expected. --- litellm/cost_calculator.py | 10 +- .../convert_dict_to_response.py | 6 + litellm/llms/openai/transcriptions/handler.py | 2 +- litellm/main.py | 14 +- .../test_transcription_duration_hidden.py | 139 ++++++++++++++++++ 5 files changed, 162 insertions(+), 9 deletions(-) create mode 100644 tests/test_litellm/llms/openai/transcriptions/test_transcription_duration_hidden.py diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index cc0f818b0a..6354bf4494 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 a2b03d0eb6..ae11b57a98 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/openai/transcriptions/handler.py b/litellm/llms/openai/transcriptions/handler.py index e241d2c1c7..397b4c9956 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 8b239c454f..1adf790bf6 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 0000000000..5b369fe084 --- /dev/null +++ b/tests/test_litellm/llms/openai/transcriptions/test_transcription_duration_hidden.py @@ -0,0 +1,139 @@ +""" +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. +""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +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", + ) + + # Duration should be in _hidden_params + assert result._hidden_params["audio_transcription_duration"] == 12.5 + # Duration should NOT be a visible attribute on the response + 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", + ) + + # Provider-returned duration should be in the response body + 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", + ) + + # No duration should be set + duration = getattr(result, "duration", None) + assert duration is None + + +class TestCostCalculatorReadsDurationFromHiddenParams: + """The cost calculator should read duration from _hidden_params first.""" + + def test_cost_calculator_reads_hidden_params_duration(self): + """ + When _hidden_params has audio_transcription_duration, the cost + calculator should use it instead of looking for response.duration. + """ + response = TranscriptionResponse(text="test") + response._hidden_params = { + "audio_transcription_duration": 17.5, + "model": "gpt-4o-transcribe", + "custom_llm_provider": "openai", + } + + # Simulate what cost_calculator.py does + _hidden = getattr(response, "_hidden_params", {}) or {} + duration = _hidden.get( + "audio_transcription_duration", + getattr(response, "duration", 0.0), + ) + + assert duration == 17.5 + + def test_cost_calculator_falls_back_to_response_duration(self): + """ + When _hidden_params doesn't have duration (e.g. verbose_json response), + fall back to response.duration. + """ + response = TranscriptionResponse(text="test") + response._hidden_params = {} + response.duration = 42.7 # type: ignore + + _hidden = getattr(response, "_hidden_params", {}) or {} + duration = _hidden.get( + "audio_transcription_duration", + getattr(response, "duration", 0.0), + ) + + assert duration == 42.7 + + def test_cost_calculator_returns_zero_when_no_duration(self): + """When neither hidden params nor response has duration, return 0.0.""" + response = TranscriptionResponse(text="test") + response._hidden_params = {} + + _hidden = getattr(response, "_hidden_params", {}) or {} + duration = _hidden.get( + "audio_transcription_duration", + getattr(response, "duration", 0.0), + ) + + assert duration == 0.0 From 5f957add18d0faa128890d861f7997ad9156c181 Mon Sep 17 00:00:00 2001 From: Chesars Date: Thu, 26 Feb 2026 16:16:41 -0300 Subject: [PATCH 2/3] fix(azure): apply same duration hidden_params fix to Azure transcription handler --- litellm/llms/azure/audio_transcriptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/llms/azure/audio_transcriptions.py b/litellm/llms/azure/audio_transcriptions.py index 8519b1c35a..70b2f1ccc0 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( From 121669090d6406107f8511e3dcb6317403647a9c Mon Sep 17 00:00:00 2001 From: Chesars Date: Thu, 26 Feb 2026 16:24:48 -0300 Subject: [PATCH 3/3] test: use real completion_cost() instead of duplicating inline logic --- .../test_transcription_duration_hidden.py | 86 +++++++++++-------- 1 file changed, 50 insertions(+), 36 deletions(-) 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 index 5b369fe084..2b287e456a 100644 --- a/tests/test_litellm/llms/openai/transcriptions/test_transcription_duration_hidden.py +++ b/tests/test_litellm/llms/openai/transcriptions/test_transcription_duration_hidden.py @@ -7,11 +7,9 @@ deserialization" into thinking a plain Transcription is a TranscriptionVerbose/Diarized type. """ -import json -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest +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, ) @@ -38,9 +36,7 @@ class TestTranscriptionDurationNotInResponseBody: response_type="audio_transcription", ) - # Duration should be in _hidden_params assert result._hidden_params["audio_transcription_duration"] == 12.5 - # Duration should NOT be a visible attribute on the response assert not hasattr(result, "_audio_transcription_duration") def test_convert_dict_preserves_provider_duration(self): @@ -61,7 +57,6 @@ class TestTranscriptionDurationNotInResponseBody: response_type="audio_transcription", ) - # Provider-returned duration should be in the response body assert result.duration == 42.7 def test_plain_json_response_has_no_duration(self): @@ -79,61 +74,80 @@ class TestTranscriptionDurationNotInResponseBody: response_type="audio_transcription", ) - # No duration should be set duration = getattr(result, "duration", None) assert duration is None class TestCostCalculatorReadsDurationFromHiddenParams: - """The cost calculator should read duration from _hidden_params first.""" + """The cost calculator should read duration from _hidden_params via completion_cost().""" - def test_cost_calculator_reads_hidden_params_duration(self): + @patch("litellm.cost_calculator.openai_cost_per_second") + def test_completion_cost_uses_hidden_params_duration(self, mock_cost_fn): """ - When _hidden_params has audio_transcription_duration, the cost - calculator should use it instead of looking for response.duration. + 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": "gpt-4o-transcribe", + "model": "whisper-1", "custom_llm_provider": "openai", } - # Simulate what cost_calculator.py does - _hidden = getattr(response, "_hidden_params", {}) or {} - duration = _hidden.get( - "audio_transcription_duration", - getattr(response, "duration", 0.0), + completion_cost( + completion_response=response, + model="whisper-1", + call_type="atranscription", ) - assert duration == 17.5 + mock_cost_fn.assert_called_once() + _, kwargs = mock_cost_fn.call_args + assert kwargs["duration"] == 17.5 - def test_cost_calculator_falls_back_to_response_duration(self): + @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), - fall back to response.duration. + 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 = {} + response._hidden_params = { + "model": "whisper-1", + "custom_llm_provider": "openai", + } response.duration = 42.7 # type: ignore - _hidden = getattr(response, "_hidden_params", {}) or {} - duration = _hidden.get( - "audio_transcription_duration", - getattr(response, "duration", 0.0), + completion_cost( + completion_response=response, + model="whisper-1", + call_type="atranscription", ) - assert duration == 42.7 + 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) - def test_cost_calculator_returns_zero_when_no_duration(self): - """When neither hidden params nor response has duration, return 0.0.""" response = TranscriptionResponse(text="test") - response._hidden_params = {} + response._hidden_params = { + "model": "whisper-1", + "custom_llm_provider": "openai", + } - _hidden = getattr(response, "_hidden_params", {}) or {} - duration = _hidden.get( - "audio_transcription_duration", - getattr(response, "duration", 0.0), + completion_cost( + completion_response=response, + model="whisper-1", + call_type="atranscription", ) - assert duration == 0.0 + mock_cost_fn.assert_called_once() + _, kwargs = mock_cost_fn.call_args + assert kwargs["duration"] == 0.0