mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
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:
parent
adba088df2
commit
7dd4f17021
5 changed files with 162 additions and 9 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue