Merge pull request #22208 from Chesars/fix/transcription-duration-hidden-params

fix(transcription): move duration to _hidden_params to match OpenAI response spec
This commit is contained in:
Cesar Garcia 2026-02-26 16:51:24 -03:00 committed by GitHub
commit ec6a55c6db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 177 additions and 10 deletions

View file

@ -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(

View file

@ -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

View file

@ -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(

View file

@ -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),

View file

@ -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.")

View file

@ -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