diff --git a/litellm/litellm_core_utils/audio_utils/utils.py b/litellm/litellm_core_utils/audio_utils/utils.py index a89ab5b0c6c..dee5f0cc0e7 100644 --- a/litellm/litellm_core_utils/audio_utils/utils.py +++ b/litellm/litellm_core_utils/audio_utils/utils.py @@ -31,6 +31,9 @@ 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 + + @dataclass class ProcessedAudioFile: """ @@ -340,7 +343,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: - duration: Final = len(audio) / audio.samplerate + frames: Final = len(audio) + if frames >= LIBSNDFILE_UNKNOWN_FRAME_COUNT: + return None + 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 cd90d5c1a13..ec189b83195 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 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 ( @@ -523,7 +524,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: - return len(audio) / audio.samplerate + frames: Final = len(audio) + if frames >= LIBSNDFILE_UNKNOWN_FRAME_COUNT: + return None + 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 diff --git a/tests/integration/cost_calculation/cases/openai.py b/tests/integration/cost_calculation/cases/openai.py index 270bb6e105d..a3d5527daad 100644 --- a/tests/integration/cost_calculation/cases/openai.py +++ b/tests/integration/cost_calculation/cases/openai.py @@ -20,6 +20,7 @@ from integration.cost_calculation.cost_tracking_case import ( RecountExpected, RecountRates, SseResponse, + UnsizedFlacUpload, WavUpload, ) from pydantic import JsonValue @@ -8103,6 +8104,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", @@ -12206,6 +12219,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 95363e92366..d633e837b1c 100644 --- a/tests/integration/cost_calculation/cost_tracking_case.py +++ b/tests/integration/cost_calculation/cost_tracking_case.py @@ -180,7 +180,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 520eeab12e9..9d264fb980d 100644 --- a/tests/unit/litellm_core_utils/test_audio_utils.py +++ b/tests/unit/litellm_core_utils/test_audio_utils.py @@ -5,6 +5,7 @@ Test the audio utils functionality in litellm_core_utils/audio_utils/utils.py import base64 import io import os +import struct import tempfile from pathlib import Path from typing import Final @@ -338,6 +339,17 @@ class TestCalculateRequestDuration: assert file_obj.tell() == len(wav_header), "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..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 @@ -1,9 +1,11 @@ import asyncio import io import json +import struct import wave from datetime import datetime from pathlib import Path +from typing import Final from unittest.mock import MagicMock import httpx @@ -453,6 +455,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: Final = ( + struct.pack(">HH", 4096, 4096) + bytes(6) + ((16000 << 44) | (15 << 36)).to_bytes(8, "big") + bytes(16) + ) + 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 + def test_undecodable_media_yields_no_duration(self, tmp_path: Path): media = tmp_path / "a.wav" _ = media.write_bytes(b"not audio at all")