fix(audio): bill finalized streams and authorize transcription models

This commit is contained in:
Emerson Gomes 2026-09-15 13:31:02 -05:00
parent caea679de0
commit 8783a3b301
No known key found for this signature in database
GPG key ID: D3DF28AB5D1B5E17
7 changed files with 73 additions and 16 deletions

View file

@ -628,7 +628,25 @@ def cost_per_token(
data_residency=data_residency,
)
elif call_type == "atranscription" or call_type == "transcription":
if _transcription_usage_has_token_details(usage_block):
transcription_model_info: Final = _cached_get_model_info_helper(
model=model_without_prefix, custom_llm_provider=custom_llm_provider
)
has_token_pricing: Final = any(
transcription_model_info.get(field)
for field in (
"input_cost_per_token",
"output_cost_per_token",
"input_cost_per_audio_token",
"output_cost_per_audio_token",
)
)
if _transcription_usage_has_token_details(usage_block) and (
has_token_pricing
or (
transcription_model_info.get("input_cost_per_second") is None
and transcription_model_info.get("output_cost_per_second") is None
)
):
return generic_cost_per_token(
model=model_without_prefix,
usage=usage_block,

View file

@ -3286,7 +3286,12 @@ class Logging(LiteLLMLoggingBaseClass):
if "async_complete_streaming_response" in self.model_call_details:
return # break out of this.
complete_streaming_response: Final[
ModelResponse | TextCompletionResponse | ResponsesAPIResponse | InteractionsAPIResponse | None
ModelResponse
| TextCompletionResponse
| ResponsesAPIResponse
| InteractionsAPIResponse
| TranscriptionResponse
| None
] = self._get_assembled_streaming_response(
result=result,
start_time=start_time,
@ -4105,10 +4110,17 @@ class Logging(LiteLLMLoggingBaseClass):
end_time: datetime.datetime,
is_async: bool,
streaming_chunks: list[object],
) -> ModelResponse | TextCompletionResponse | ResponsesAPIResponse | InteractionsAPIResponse | None:
) -> (
ModelResponse
| TextCompletionResponse
| ResponsesAPIResponse
| InteractionsAPIResponse
| TranscriptionResponse
| None
):
if self.stream is not True:
return None
if isinstance(result, ModelResponse) or isinstance(result, TextCompletionResponse):
if isinstance(result, (ModelResponse, TextCompletionResponse, TranscriptionResponse)):
return result
elif isinstance(
result,

View file

@ -31,9 +31,13 @@ class TranscriptionUsageObjectTransformation:
prompt_tokens=usage_object.input_tokens,
completion_tokens=usage_object.output_tokens,
total_tokens=usage_object.total_tokens,
prompt_tokens_details=PromptTokensDetailsWrapper(
text_tokens=usage_object.input_token_details.text_tokens,
audio_tokens=usage_object.input_token_details.audio_tokens,
prompt_tokens_details=(
PromptTokensDetailsWrapper(
text_tokens=usage_object.input_token_details.text_tokens,
audio_tokens=usage_object.input_token_details.audio_tokens,
)
if usage_object.input_token_details is not None
else None
),
)
return None

View file

@ -675,6 +675,13 @@ async def create_realtime_transcription_session(
)
transcription_session: Final = {k: v for k, v in body.items() if k != "model"}
await _authorize_and_bind_nested_transcription_models(
session_data=transcription_session,
user_api_key_dict=user_api_key_dict,
llm_model_list=llm_model_list,
llm_router=llm_router,
)
_set_transcription_model_on_session(session=transcription_session, model=model, create_if_missing=True)
data = {"model": model, "transcription_session": transcription_session}
data = await add_litellm_data_to_request(

View file

@ -3468,7 +3468,7 @@ def get_optional_params_transcription(
from litellm.constants import OPENAI_TRANSCRIPTION_PARAMS
# retrieve all parameters passed to the function
passed_params: Final = locals()
passed_params: Final = locals().copy()
passed_params.pop("OPENAI_TRANSCRIPTION_PARAMS")
passed_params.pop("model")

View file

@ -661,8 +661,14 @@ def test_transcription_sessions_requires_auth(proxy_app):
@pytest.mark.asyncio
@pytest.mark.parametrize("body", [
{"input_audio_transcription": {"model": "gpt-realtime-whisper"}},
{"model": "gpt-4o-realtime-preview", "audio": {"input": {"transcription": {"model": "gpt-realtime-whisper"}}}},
{"input_audio_transcription": {"model": "gpt-4o-realtime-preview"}, "audio": {"input": {"transcription": {"model": "gpt-realtime-whisper"}}}},
{"model": "gpt-4o-realtime-preview", "input_audio_transcription": {"model": "gpt-realtime-whisper"}},
])
async def test_transcription_sessions_rejects_disallowed_resolved_model(
proxy_app,
proxy_app, body,
):
proxy_app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_id="test-user",
@ -679,7 +685,7 @@ async def test_transcription_sessions_rejects_disallowed_resolved_model(
response = client.post(
"/v1/realtime/transcription_sessions",
headers={"Authorization": "Bearer sk-test-master-key"},
json={"input_audio_transcription": {"model": "gpt-realtime-whisper"}},
json=body,
)
assert response.status_code == 403
@ -1032,6 +1038,7 @@ async def test_transcription_sessions_encrypts_client_secret(
async def _capturing_route(*args, **kwargs):
captured_route_type["route_type"] = kwargs.get("route_type")
captured_route_type["session"] = kwargs["data"]["transcription_session"]
return await mock_route_request_transcription_sessions(*args, **kwargs)
try:
@ -1054,6 +1061,8 @@ async def test_transcription_sessions_encrypts_client_secret(
"/v1/realtime/transcription_sessions",
headers={"Authorization": "Bearer sk-test-master-key"},
json={
"model": "gpt-realtime-whisper",
"audio": {"input": {"transcription": {"model": "gpt-live-transcribe", "language": "en"}}},
"input_audio_format": "pcm16",
"input_audio_transcription": {"model": "gpt-realtime-whisper"},
},
@ -1072,6 +1081,10 @@ async def test_transcription_sessions_encrypts_client_secret(
assert "upstream_ephemeral_key" in decrypted
# Routed through the dedicated transcription_sessions route type.
assert captured_route_type["route_type"] == "acreate_realtime_transcription_session"
assert captured_route_type["session"]["input_audio_transcription"]["model"] == "gpt-realtime-whisper"
assert captured_route_type["session"]["audio"]["input"]["transcription"] == {
"model": "gpt-realtime-whisper", "language": "en",
}
finally:
proxy_app.dependency_overrides.pop(user_api_key_auth, None)

View file

@ -143,10 +143,12 @@ async def test_atranscription_stream_preserves_duration_for_callback_cost():
http_client = httpx.AsyncClient(transport=httpx.MockTransport(send_response))
openai_client = AsyncOpenAI(api_key="sk-test", base_url="https://example.com/v1", http_client=http_client)
logging_obj = MagicMock()
logging_obj.model_call_details = {}
logging_obj.async_success_handler = AsyncMock()
logging_obj.async_failure_handler = AsyncMock()
from litellm.litellm_core_utils.litellm_logging import Logging
logging_obj = Logging(
model="gpt-transcribe", messages=[], stream=True, call_type="atranscription",
start_time=datetime.now(), litellm_call_id="transcription-cost-test", function_id="transcription-cost-test",
)
audio_file = io.BytesIO()
with wave.open(audio_file, "wb") as wav_file:
wav_file.setnchannels(1)
@ -167,8 +169,9 @@ async def test_atranscription_stream_preserves_duration_for_callback_cost():
await openai_client.close()
assert [event.type for event in received] == ["transcript.text.delta", "transcript.text.done"]
logging_obj.async_success_handler.assert_awaited_once()
logged_response = logging_obj.async_success_handler.await_args.kwargs["result"]
logged_response = logging_obj.model_call_details["async_complete_streaming_response"]
assert logging_obj.model_call_details["response_cost"] == pytest.approx(0.000075)
assert logging_obj.model_call_details["standard_logging_object"]["response_cost"] == pytest.approx(0.000075)
assert logged_response._hidden_params["audio_transcription_duration"] == pytest.approx(1.0)