mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
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:
commit
ec6a55c6db
6 changed files with 177 additions and 10 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,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
|
||||
Loading…
Add table
Reference in a new issue