fix(transcription): store duration in _hidden_params to avoid OpenAI SDK deserialization issues

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.
This commit is contained in:
Chesars 2026-02-26 16:06:08 -03:00
parent adba088df2
commit 7dd4f17021
5 changed files with 162 additions and 9 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

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