From 76539f9156af3130f9d4dc47a219c67a7aa59c5e Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 06:21:04 +0000 Subject: [PATCH 1/5] fix(cost): bill transcriptions by the seconds the provider reports Line port of the usage.seconds billing slice onto lts/1.108.x: normalized_audio_duration_seconds and _get_transcription_usage_duration from #35600 (ecb8ef5ca8), and the provider-seconds-first _transcription_duration_for_cost from #50727 (dfb1aac8b1), with their unit tests. The rest of #35600 stays on main. --- litellm/cost_calculator.py | 41 ++++++++--- .../litellm_core_utils/audio_utils/utils.py | 11 +++ .../test_transcription_duration_hidden.py | 22 ++++++ tests/unit/test_cost_calculator.py | 73 +++++++++++++++++++ 4 files changed, 137 insertions(+), 10 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index dcd9b905c9b..bd8c74571d7 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -22,7 +22,8 @@ from litellm.constants import ( OPENAI_LIVE_TERMINAL_RESPONSE_EVENT_TYPES, REALTIME_METERED_USAGE_KEY, ) -from litellm.litellm_core_utils.core_helpers import as_str_mapping +from litellm.litellm_core_utils.audio_utils.utils import normalized_audio_duration_seconds +from litellm.litellm_core_utils.core_helpers import as_str_mapping, attribute_or_none from litellm.litellm_core_utils.cost_discount import resolve_cost_discount from litellm.litellm_core_utils.decisions_pricing import decisions_pricing_fields, is_decisions_call_type from litellm.litellm_core_utils.hidden_params import HIDDEN_PARAMS_ATTR @@ -332,6 +333,34 @@ def _transcription_usage_has_token_details( return (prompt_tokens_val > 0) or (completion_tokens_val > 0) +def _get_transcription_usage_duration(completion_response: object) -> float | None: + response_fields: Final = as_str_mapping(completion_response) + usage_object: Final = ( + response_fields.get("usage") if response_fields is not None else attribute_or_none(completion_response, "usage") + ) + usage_fields: Final = as_str_mapping(usage_object) + usage_type: Final = ( + usage_fields.get("type") if usage_fields is not None else attribute_or_none(usage_object, "type") + ) + if usage_type != "duration": + return None + seconds: Final = ( + usage_fields.get("seconds") if usage_fields is not None else attribute_or_none(usage_object, "seconds") + ) + return normalized_audio_duration_seconds(seconds) + + +def _transcription_duration_for_cost(completion_response: object) -> float: + billed_duration: Final = _get_transcription_usage_duration(completion_response) + if billed_duration is not None and billed_duration > 0: + return billed_duration + hidden_params: Final = as_str_mapping(attribute_or_none(completion_response, "_hidden_params")) or {} + measured_duration: Final = normalized_audio_duration_seconds( + hidden_params.get("audio_transcription_duration", attribute_or_none(completion_response, "duration")) + ) + return measured_duration if measured_duration is not None and measured_duration > 0 else 0.0 + + OCRPricingField = Literal["ocr_cost_per_page", "ocr_cost_per_credit", "annotation_cost_per_page"] @@ -1825,15 +1854,7 @@ def completion_cost( elif call_type in _SPEECH_CALL_TYPES: prompt_characters = litellm.utils.count_characters(text=prompt) elif call_type in _TRANSCRIPTION_CALL_TYPES: - # 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), - ) + audio_transcription_file_duration = _transcription_duration_for_cost(completion_response) elif call_type in _RERANK_CALL_TYPES: if completion_response is not None and isinstance(completion_response, RerankResponse): meta_obj = completion_response.meta diff --git a/litellm/litellm_core_utils/audio_utils/utils.py b/litellm/litellm_core_utils/audio_utils/utils.py index dab3e48f91a..9fdfce057d9 100644 --- a/litellm/litellm_core_utils/audio_utils/utils.py +++ b/litellm/litellm_core_utils/audio_utils/utils.py @@ -3,6 +3,7 @@ Utils used for litellm.transcription() and litellm.atranscription() """ import hashlib +import math import os from dataclasses import dataclass from typing import Final @@ -17,6 +18,16 @@ from litellm.types.files import ( from litellm.types.utils import FileTypes +def normalized_audio_duration_seconds(value: object) -> float | None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + try: + seconds: Final = float(value) + except OverflowError: + return None + return seconds if math.isfinite(seconds) and seconds >= 0 else None + + @dataclass class ProcessedAudioFile: """ diff --git a/tests/unit/llms/openai/transcriptions/test_transcription_duration_hidden.py b/tests/unit/llms/openai/transcriptions/test_transcription_duration_hidden.py index 703fa13cbc9..36ae853831e 100644 --- a/tests/unit/llms/openai/transcriptions/test_transcription_duration_hidden.py +++ b/tests/unit/llms/openai/transcriptions/test_transcription_duration_hidden.py @@ -126,6 +126,28 @@ class TestTranscriptionDurationNotInResponseBody: class TestCostCalculatorReadsDurationFromHiddenParams: """The cost calculator should read duration from _hidden_params via completion_cost().""" + @patch( # test-quality-ok: isolates duration selection from the model pricing table + "litellm.cost_calculator.openai_cost_per_second" + ) + def test_completion_cost_prefers_provider_usage_duration_over_measured_duration(self, mock_cost_fn): + mock_cost_fn.return_value = (0.001, 0.0) + response = TranscriptionResponse(text="test") + response.usage = TranscriptionUsageDurationObject(type="duration", seconds=60.0) + response._hidden_params = { + "audio_transcription_duration": 17.5, + "custom_llm_provider": "openai", + } + + completion_cost( + completion_response=response, + model="gpt-transcribe", + call_type="atranscription", + ) + + mock_cost_fn.assert_called_once() + _, kwargs = mock_cost_fn.call_args + assert kwargs["duration"] == 60.0 + @patch("litellm.cost_calculator.openai_cost_per_second") def test_completion_cost_uses_hidden_params_duration(self, mock_cost_fn): """ diff --git a/tests/unit/test_cost_calculator.py b/tests/unit/test_cost_calculator.py index 71baf6067a1..706f5eabbb7 100644 --- a/tests/unit/test_cost_calculator.py +++ b/tests/unit/test_cost_calculator.py @@ -1022,6 +1022,15 @@ def test_transcription_usage_cost_returns_zero_for_unknown_type(): assert _transcription_usage_cost({}, {}) == 0.0 +@pytest.mark.parametrize("malformed_seconds", [-1.0, float("nan"), float("inf"), 10**1000, True]) +def test_transcription_ignores_invalid_provider_duration(malformed_seconds: float | int | bool) -> None: + from litellm.cost_calculator import _get_transcription_usage_duration + + response: Final = SimpleNamespace(usage={"type": "duration", "seconds": malformed_seconds}) + + assert _get_transcription_usage_duration(response) is None + + def test_get_transcription_model_falls_back_to_session_model(monkeypatch): """session.model is used when transcription-specific model fields are absent.""" monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") @@ -6775,6 +6784,70 @@ def test_whisper_azure(): assert round(cost, 5) == round(expected_cost, 5) +def _per_second_transcription( + monkeypatch: pytest.MonkeyPatch, provider: str, model: str, response_object: Mapping[str, object] +) -> TranscriptionResponse: + monkeypatch.setitem( + litellm.model_cost, + model, + {"input_cost_per_second": 0.01, "litellm_provider": provider, "mode": "audio_transcription"}, + ) + return convert_to_model_response_object( + response_object={"text": "What is the weather in Paris?", **response_object}, + model_response_object=TranscriptionResponse(), + hidden_params={"model": model, "custom_llm_provider": provider}, + response_type="audio_transcription", + ) + + +@pytest.mark.parametrize( + ("provider", "model", "measured"), + [ + ("openai", "whisper-fake", {}), + ("openai", "whisper-fake", {"_audio_transcription_duration": 2.4}), + ("openai", "whisper-fake", {"duration": 2.4}), + ("openai", "gpt-fake-transcribe", {"_audio_transcription_duration": 2.4}), + ("azure", "whisper-fake", {"_audio_transcription_duration": 2.4}), + ], + ids=[ + "unmeasured_upload", + "measured_upload", + "verbose_json_duration", + "gpt_transcribe_model", + "azure", + ], +) +def test_transcription_cost_bills_the_seconds_the_provider_billed( + monkeypatch: pytest.MonkeyPatch, provider: str, model: str, measured: Mapping[str, float] +) -> None: + transcription: Final = _per_second_transcription( + monkeypatch, provider, model, {"usage": {"type": "duration", "seconds": 3}, **measured} + ) + + cost: Final = completion_cost( + completion_response=transcription, model=f"{provider}/{model}", call_type="atranscription" + ) + + assert cost == pytest.approx(0.01 * 3) + + +@pytest.mark.parametrize( + ("measured", "expected_seconds"), + [({"_audio_transcription_duration": 2.4}, 2.4), ({"duration": 2.4}, 2.4), ({}, 0.0)], + ids=["measured_upload", "reply_duration", "nothing_known"], +) +def test_transcription_cost_without_provider_seconds_bills_the_measured_length( + monkeypatch: pytest.MonkeyPatch, measured: Mapping[str, float], expected_seconds: float +) -> None: + transcription: Final = _per_second_transcription(monkeypatch, "openai", "whisper-fake", measured) + + cost: Final = completion_cost( + completion_response=transcription, model="openai/whisper-fake", call_type="atranscription" + ) + + assert cost == pytest.approx(0.01 * expected_seconds) + + def test_gpt_image_2_azure_cost_tracking(): azure_image_generation_response: Final = { "created": 1758585600, From 70f388a8ab78a732a92c5bf53254e1f4a8349a79 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 06:21:04 +0000 Subject: [PATCH 2/5] fix(transcription): treat libsndfile's unknown frame count as no duration Backport of #50842 to lts/1.108.x. Cherry-picked from e6a0534785 (litellm_transcription_unknown_frame_count). --- litellm/litellm_core_utils/audio_utils/utils.py | 10 ++++++++-- .../transcribe_passthrough_logging_handler.py | 3 ++- .../integration/cost_calculation/cases/openai.py | 14 ++++++++++++++ .../cost_calculation/cost_tracking_case.py | 8 +++++++- .../cost_calculation/test_cost_tracking.py | 15 +++++++++++++++ tests/unit/litellm_core_utils/test_audio_utils.py | 13 +++++++++++++ ...test_transcribe_passthrough_logging_handler.py | 10 ++++++++++ 7 files changed, 69 insertions(+), 4 deletions(-) diff --git a/litellm/litellm_core_utils/audio_utils/utils.py b/litellm/litellm_core_utils/audio_utils/utils.py index 9fdfce057d9..24cec85797a 100644 --- a/litellm/litellm_core_utils/audio_utils/utils.py +++ b/litellm/litellm_core_utils/audio_utils/utils.py @@ -28,6 +28,13 @@ def normalized_audio_duration_seconds(value: object) -> float | None: return seconds if math.isfinite(seconds) and seconds >= 0 else None +LIBSNDFILE_UNKNOWN_FRAME_COUNT: Final = 2**63 - 1 + + +def soundfile_duration_seconds(frames: int, samplerate: int) -> float | None: + return None if frames >= LIBSNDFILE_UNKNOWN_FRAME_COUNT else frames / samplerate + + @dataclass class ProcessedAudioFile: """ @@ -334,8 +341,7 @@ def calculate_request_duration(file: FileTypes) -> float | None: # Extract duration using soundfile file_object: Final = io.BytesIO(file_content) with sf.SoundFile(file_object) as audio: - duration: Final = len(audio) / audio.samplerate - return duration + return soundfile_duration_seconds(len(audio), audio.samplerate) except Exception: # Silently fail if duration extraction fails diff --git a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/transcribe_passthrough_logging_handler.py b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/transcribe_passthrough_logging_handler.py index 79f6dee90f7..dc62b31ca39 100644 --- a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/transcribe_passthrough_logging_handler.py +++ b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/transcribe_passthrough_logging_handler.py @@ -29,6 +29,7 @@ from litellm.constants import ( TRANSCRIBE_MEDIA_FETCH_ATTEMPTS, TRANSCRIBE_MEDIA_LAST_MODIFIED_TOLERANCE_SECONDS, ) +from litellm.litellm_core_utils.audio_utils.utils import soundfile_duration_seconds from litellm.litellm_core_utils.aws_partition import get_aws_dns_suffix from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.litellm_core_utils.litellm_logging import ( @@ -513,7 +514,7 @@ async def write_media_within_limit(response: httpx.Response, media_file: IO[byte def media_file_seconds(path: Path) -> float | None: try: with soundfile.SoundFile(str(path)) as audio: - return len(audio) / audio.samplerate + return soundfile_duration_seconds(len(audio), audio.samplerate) except (RuntimeError, ValueError, OSError) as e: verbose_proxy_logger.warning("Transcribe media could not be decoded for its duration: %s", e) return None diff --git a/tests/integration/cost_calculation/cases/openai.py b/tests/integration/cost_calculation/cases/openai.py index cfb7fda4d71..af155d710cc 100644 --- a/tests/integration/cost_calculation/cases/openai.py +++ b/tests/integration/cost_calculation/cases/openai.py @@ -19,6 +19,7 @@ from integration.cost_calculation.cost_tracking_case import ( RecountExpected, RecountRates, SseResponse, + UnsizedFlacUpload, WavUpload, ) from pydantic import JsonValue @@ -7743,6 +7744,18 @@ WHISPER_NEXT_TRANSCRIPTIONS_PER_SECOND: Final = CostTrackingTestCase( ) +WHISPER_NEXT_TRANSCRIPTIONS_UNSIZED_FLAC_NO_USAGE: Final = CostTrackingTestCase( + name="whisper-next-transcriptions-unsized-flac-no-usage", + covers="quota_management.spend_tracking.cost_matrix.logs_cost", + model="whisper-next", + endpoint="/v1/audio/transcriptions", + upload=UnsizedFlacUpload(kind="unsized_flac"), + request={"response_format": "json"}, + response=JsonResponse(content_type="application/json", body={"text": "hello"}), + expected=ExactExpected(spend=0, input_cost=0, output_cost=0, prompt_tokens=0, completion_tokens=0), +) + + # whisper-verbose-next WHISPER_VERBOSE_NEXT_TRANSCRIPTIONS_DURATION: Final = CostTrackingTestCase( name="whisper-verbose-next-transcriptions-duration", @@ -11391,6 +11404,7 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = ( GPT_5_6_UPSTREAM_500_ZERO_SPEND, GPT_5_6_UPSTREAM_429_ZERO_SPEND, WHISPER_NEXT_TRANSCRIPTIONS_PER_SECOND, + WHISPER_NEXT_TRANSCRIPTIONS_UNSIZED_FLAC_NO_USAGE, WHISPER_VERBOSE_NEXT_TRANSCRIPTIONS_DURATION, GPT_4O_TRANSCRIBE_NEXT_TRANSCRIPTIONS_TOKENS, TTS_NEXT_SPEECH_PER_CHARACTER, diff --git a/tests/integration/cost_calculation/cost_tracking_case.py b/tests/integration/cost_calculation/cost_tracking_case.py index 038c8d6b9e3..b46e87b73f4 100644 --- a/tests/integration/cost_calculation/cost_tracking_case.py +++ b/tests/integration/cost_calculation/cost_tracking_case.py @@ -168,7 +168,13 @@ class PngUpload(BaseModel): kind: Literal["png"] -Upload: TypeAlias = Annotated[WavUpload | PngUpload, Field(discriminator="kind")] +class UnsizedFlacUpload(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + kind: Literal["unsized_flac"] + + +Upload: TypeAlias = Annotated[WavUpload | PngUpload | UnsizedFlacUpload, Field(discriminator="kind")] class JsonResponse(BaseModel): diff --git a/tests/integration/cost_calculation/test_cost_tracking.py b/tests/integration/cost_calculation/test_cost_tracking.py index 9989b9e94e3..d54c59fbbaa 100644 --- a/tests/integration/cost_calculation/test_cost_tracking.py +++ b/tests/integration/cost_calculation/test_cost_tracking.py @@ -72,6 +72,19 @@ def _wav_bytes(seconds: float) -> bytes: return output.getvalue() +def _unsized_flac_bytes() -> bytes: + """A FLAC whose STREAMINFO total-samples field is 0 (unknown), as an encoder writing to a pipe leaves it.""" + sample_rate: Final = 16000 + bits_per_sample: Final = 16 + streaminfo: Final = ( + struct.pack(">HH", 4096, 4096) + + bytes(6) + + ((sample_rate << 44) | ((bits_per_sample - 1) << 36)).to_bytes(8, "big") + + bytes(16) + ) + return b"fLaC" + b"\x80" + len(streaminfo).to_bytes(3, "big") + streaminfo + + def _png_bytes() -> bytes: def chunk(kind: bytes, payload: bytes) -> bytes: return ( @@ -97,6 +110,8 @@ def _multipart_request(gateway: Gateway, case: CostTrackingTestCase, model_name: } if case.upload.kind == "wav": files: Final = {"file": ("audio.wav", _wav_bytes(case.upload.seconds), "audio/wav")} + elif case.upload.kind == "unsized_flac": + files = {"file": ("audio.flac", _unsized_flac_bytes(), "audio/flac")} else: files = {"image": ("image.png", _png_bytes(), "image/png")} return gateway.request_multipart(case.endpoint, fields, files, key=key) diff --git a/tests/unit/litellm_core_utils/test_audio_utils.py b/tests/unit/litellm_core_utils/test_audio_utils.py index 155f6680416..b714dfc4783 100644 --- a/tests/unit/litellm_core_utils/test_audio_utils.py +++ b/tests/unit/litellm_core_utils/test_audio_utils.py @@ -4,7 +4,9 @@ Test the audio utils functionality in litellm_core_utils/audio_utils/utils.py import io import os +import struct import tempfile +from typing import Final from unittest.mock import mock_open, patch import pytest @@ -277,6 +279,17 @@ class TestCalculateRequestDuration: ), "File position should be restored to original position" +def _unsized_flac() -> bytes: + streaminfo: Final = ( + struct.pack(">HH", 4096, 4096) + bytes(6) + ((16000 << 44) | (15 << 36)).to_bytes(8, "big") + bytes(16) + ) + return b"fLaC" + b"\x80" + len(streaminfo).to_bytes(3, "big") + streaminfo + + +def test_duration_is_unknown_for_a_flac_that_does_not_state_its_length() -> None: + assert calculate_request_duration(_unsized_flac()) is None + + class TestGetAudioFileContentHash: """Test the get_audio_file_content_hash function for cache key generation""" diff --git a/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_transcribe_passthrough_logging_handler.py b/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_transcribe_passthrough_logging_handler.py index 481533fd7d4..44b0e710a02 100644 --- a/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_transcribe_passthrough_logging_handler.py +++ b/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_transcribe_passthrough_logging_handler.py @@ -1,6 +1,7 @@ import asyncio import io import json +import struct import wave from datetime import datetime from pathlib import Path @@ -453,6 +454,15 @@ class TestMediaFileSeconds: assert media_file_seconds(media) == pytest.approx(1.5) + def test_flac_that_does_not_state_its_length_yields_no_duration(self, tmp_path: Path): + streaminfo = ( + struct.pack(">HH", 4096, 4096) + bytes(6) + ((16000 << 44) | (15 << 36)).to_bytes(8, "big") + bytes(16) + ) + media = tmp_path / "a.flac" + _ = media.write_bytes(b"fLaC" + b"\x80" + len(streaminfo).to_bytes(3, "big") + streaminfo) + + assert media_file_seconds(media) is None + def test_undecodable_media_yields_no_duration(self, tmp_path: Path): media = tmp_path / "a.wav" _ = media.write_bytes(b"not audio at all") From 3f50c8734e67ff51ced1b31c4b46b18605e9c25e Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 06:21:04 +0000 Subject: [PATCH 3/5] refactor(transcription): inline the unknown frame count check Backport of #50842 to lts/1.108.x. Cherry-picked from b6d25f2752 (litellm_transcription_unknown_frame_count). --- litellm/litellm_core_utils/audio_utils/utils.py | 9 ++++----- .../transcribe_passthrough_logging_handler.py | 6 ++++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/litellm/litellm_core_utils/audio_utils/utils.py b/litellm/litellm_core_utils/audio_utils/utils.py index 24cec85797a..4f47f93859f 100644 --- a/litellm/litellm_core_utils/audio_utils/utils.py +++ b/litellm/litellm_core_utils/audio_utils/utils.py @@ -31,10 +31,6 @@ def normalized_audio_duration_seconds(value: object) -> float | None: LIBSNDFILE_UNKNOWN_FRAME_COUNT: Final = 2**63 - 1 -def soundfile_duration_seconds(frames: int, samplerate: int) -> float | None: - return None if frames >= LIBSNDFILE_UNKNOWN_FRAME_COUNT else frames / samplerate - - @dataclass class ProcessedAudioFile: """ @@ -341,7 +337,10 @@ def calculate_request_duration(file: FileTypes) -> float | None: # Extract duration using soundfile file_object: Final = io.BytesIO(file_content) with sf.SoundFile(file_object) as audio: - return soundfile_duration_seconds(len(audio), audio.samplerate) + if len(audio) >= LIBSNDFILE_UNKNOWN_FRAME_COUNT: + return None + duration: Final = len(audio) / audio.samplerate + return duration except Exception: # Silently fail if duration extraction fails diff --git a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/transcribe_passthrough_logging_handler.py b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/transcribe_passthrough_logging_handler.py index dc62b31ca39..bc2d15d3418 100644 --- a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/transcribe_passthrough_logging_handler.py +++ b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/transcribe_passthrough_logging_handler.py @@ -29,7 +29,7 @@ from litellm.constants import ( TRANSCRIBE_MEDIA_FETCH_ATTEMPTS, TRANSCRIBE_MEDIA_LAST_MODIFIED_TOLERANCE_SECONDS, ) -from litellm.litellm_core_utils.audio_utils.utils import soundfile_duration_seconds +from litellm.litellm_core_utils.audio_utils.utils import LIBSNDFILE_UNKNOWN_FRAME_COUNT from litellm.litellm_core_utils.aws_partition import get_aws_dns_suffix from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.litellm_core_utils.litellm_logging import ( @@ -514,7 +514,9 @@ async def write_media_within_limit(response: httpx.Response, media_file: IO[byte def media_file_seconds(path: Path) -> float | None: try: with soundfile.SoundFile(str(path)) as audio: - return soundfile_duration_seconds(len(audio), audio.samplerate) + if len(audio) >= LIBSNDFILE_UNKNOWN_FRAME_COUNT: + return None + return len(audio) / audio.samplerate except (RuntimeError, ValueError, OSError) as e: verbose_proxy_logger.warning("Transcribe media could not be decoded for its duration: %s", e) return None From 0e5241c50496c3e4c2e015cbe2f0a164b7b02ab5 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 06:21:04 +0000 Subject: [PATCH 4/5] fix(transcription): read the frame count once to keep the type gate flat Backport of #50842 to lts/1.108.x. Cherry-picked from 54dcd9230f (litellm_transcription_unknown_frame_count). --- litellm/litellm_core_utils/audio_utils/utils.py | 5 +++-- .../transcribe_passthrough_logging_handler.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/litellm/litellm_core_utils/audio_utils/utils.py b/litellm/litellm_core_utils/audio_utils/utils.py index 4f47f93859f..7781b6bc634 100644 --- a/litellm/litellm_core_utils/audio_utils/utils.py +++ b/litellm/litellm_core_utils/audio_utils/utils.py @@ -337,9 +337,10 @@ def calculate_request_duration(file: FileTypes) -> float | None: # Extract duration using soundfile file_object: Final = io.BytesIO(file_content) with sf.SoundFile(file_object) as audio: - if len(audio) >= LIBSNDFILE_UNKNOWN_FRAME_COUNT: + frames: Final = len(audio) + if frames >= LIBSNDFILE_UNKNOWN_FRAME_COUNT: return None - duration: Final = len(audio) / audio.samplerate + duration: Final = frames / audio.samplerate return duration except Exception: diff --git a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/transcribe_passthrough_logging_handler.py b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/transcribe_passthrough_logging_handler.py index bc2d15d3418..359ed01fcf5 100644 --- a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/transcribe_passthrough_logging_handler.py +++ b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/transcribe_passthrough_logging_handler.py @@ -514,9 +514,10 @@ async def write_media_within_limit(response: httpx.Response, media_file: IO[byte def media_file_seconds(path: Path) -> float | None: try: with soundfile.SoundFile(str(path)) as audio: - if len(audio) >= LIBSNDFILE_UNKNOWN_FRAME_COUNT: + frames: Final = len(audio) + if frames >= LIBSNDFILE_UNKNOWN_FRAME_COUNT: return None - return len(audio) / audio.samplerate + return frames / audio.samplerate except (RuntimeError, ValueError, OSError) as e: verbose_proxy_logger.warning("Transcribe media could not be decoded for its duration: %s", e) return None From 06337306783f1972f7b749768b03a1c2df6d52c5 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 06:21:04 +0000 Subject: [PATCH 5/5] test(transcription): annotate the new passthrough test's locals as Final Backport of #50842 to lts/1.108.x. Cherry-picked from eb0f251b47 (litellm_transcription_unknown_frame_count). --- .../test_transcribe_passthrough_logging_handler.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_transcribe_passthrough_logging_handler.py b/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_transcribe_passthrough_logging_handler.py index 44b0e710a02..067e2cfc25f 100644 --- a/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_transcribe_passthrough_logging_handler.py +++ b/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_transcribe_passthrough_logging_handler.py @@ -5,6 +5,7 @@ import struct import wave from datetime import datetime from pathlib import Path +from typing import Final from unittest.mock import MagicMock import httpx @@ -455,10 +456,10 @@ class TestMediaFileSeconds: assert media_file_seconds(media) == pytest.approx(1.5) def test_flac_that_does_not_state_its_length_yields_no_duration(self, tmp_path: Path): - streaminfo = ( + streaminfo: Final = ( struct.pack(">HH", 4096, 4096) + bytes(6) + ((16000 << 44) | (15 << 36)).to_bytes(8, "big") + bytes(16) ) - media = tmp_path / "a.flac" + media: Final = tmp_path / "a.flac" _ = media.write_bytes(b"fLaC" + b"\x80" + len(streaminfo).to_bytes(3, "big") + streaminfo) assert media_file_seconds(media) is None