From 535f882a73beaef701d5e6db0fa68e3ca6806535 Mon Sep 17 00:00:00 2001 From: Haquant <76264891+Steeve-Crypto@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:18:03 -0400 Subject: [PATCH 1/3] fix(mistral): bill transcription on provider duration --- .../litellm_core_utils/audio_utils/utils.py | 13 ++- .../audio_transcription/transformation.py | 7 ++ litellm/main.py | 10 +-- .../litellm_core_utils/test_audio_utils.py | 25 ++++++ ...tral_audio_transcription_transformation.py | 81 +++++++++++++++++++ 5 files changed, 129 insertions(+), 7 deletions(-) diff --git a/litellm/litellm_core_utils/audio_utils/utils.py b/litellm/litellm_core_utils/audio_utils/utils.py index dab3e48f91a..28026643244 100644 --- a/litellm/litellm_core_utils/audio_utils/utils.py +++ b/litellm/litellm_core_utils/audio_utils/utils.py @@ -16,6 +16,9 @@ from litellm.types.files import ( ) from litellm.types.utils import FileTypes +# libsndfile uses SF_COUNT_MAX (2**63 - 1) for "unknown length"; no real upload gets near 2**62 frames +_MAX_PLAUSIBLE_AUDIO_FRAMES: Final = 2**62 + @dataclass class ProcessedAudioFile: @@ -323,13 +326,19 @@ 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 + frames: Final = len(audio) + samplerate: Final = audio.samplerate except Exception: # Silently fail if duration extraction fails return None + # libsndfile reports an unknown length (e.g. a FLAC with no sample count + # in STREAMINFO) as SF_COUNT_MAX, which would bill as ~2^63 frames + if frames >= _MAX_PLAUSIBLE_AUDIO_FRAMES or samplerate <= 0: + return None + return frames / samplerate + DEFAULT_SPEECH_MEDIA_TYPE: Final = "audio/mpeg" diff --git a/litellm/llms/mistral/audio_transcription/transformation.py b/litellm/llms/mistral/audio_transcription/transformation.py index 4fce7a95350..269cebfb8f5 100644 --- a/litellm/llms/mistral/audio_transcription/transformation.py +++ b/litellm/llms/mistral/audio_transcription/transformation.py @@ -148,4 +148,11 @@ class MistralAudioTranscriptionConfig(BaseAudioTranscriptionConfig): response["language"] = response_json["language"] response.hidden_params = response_json + # Mistral bills transcription on usage.prompt_audio_seconds, so cost uses it + # instead of measuring the uploaded file locally + usage: Final = response_json.get("usage") + if isinstance(usage, dict): + prompt_audio_seconds: Final = usage.get("prompt_audio_seconds") + if isinstance(prompt_audio_seconds, (int, float)) and not isinstance(prompt_audio_seconds, bool): + response.hidden_params["audio_transcription_duration"] = float(prompt_audio_seconds) return response diff --git a/litellm/main.py b/litellm/main.py index 37b290f51ba..3a6f86fffb0 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -7940,7 +7940,7 @@ async def atranscription(*args, **kwargs) -> TranscriptionResponse: # a plain Transcription is a TranscriptionVerbose/Diarized type. if response is not None and not isinstance(response, Coroutine) and file is not None: existing_duration: Final = getattr(response, "duration", None) - if existing_duration is None: + if existing_duration is None and "audio_transcription_duration" not in response.hidden_params: calculated_duration: Final = calculate_request_duration(file) if calculated_duration is not None: response.hidden_params["audio_transcription_duration"] = calculated_duration @@ -8213,12 +8213,12 @@ def transcription( # exposing it in the response body (see sync path comment above). if response is not None and not isinstance(response, Coroutine): existing_duration: Final = getattr(response, "duration", None) - if existing_duration is None: + response_hidden_params: Final = cast( # cast-ok: preserve dynamic mapping behavior + dict[str, object], getattr(response, HIDDEN_PARAMS_ATTR) + ) + if existing_duration is None and "audio_transcription_duration" not in response_hidden_params: calculated_duration: Final = calculate_request_duration(file) if calculated_duration is not None: - response_hidden_params: Final = cast( # cast-ok: preserve dynamic mapping behavior - dict[str, object], getattr(response, HIDDEN_PARAMS_ATTR) - ) response_hidden_params["audio_transcription_duration"] = calculated_duration if response is None: diff --git a/tests/unit/litellm_core_utils/test_audio_utils.py b/tests/unit/litellm_core_utils/test_audio_utils.py index 155f6680416..3ed77122b9e 100644 --- a/tests/unit/litellm_core_utils/test_audio_utils.py +++ b/tests/unit/litellm_core_utils/test_audio_utils.py @@ -276,6 +276,31 @@ class TestCalculateRequestDuration: wav_header ), "File position should be restored to original position" + @pytest.mark.skipif( + os.environ.get("SKIP_AUDIO_TESTS") == "true", + reason="Skipping audio tests - soundfile may not be available", + ) + def test_flac_without_sample_count_returns_none(self): + """ + A FLAC whose STREAMINFO has no total sample count (what ffmpeg writes to a + pipe) makes libsndfile report SF_COUNT_MAX frames. That must not turn into + a ~2^63 frame duration that gets billed + """ + np = pytest.importorskip("numpy") + sf = pytest.importorskip("soundfile") + + buffer = io.BytesIO() + sf.write(buffer, np.zeros(1600, dtype="int16"), 16000, format="FLAC") + known_length = buffer.getvalue() + assert calculate_request_duration(known_length) == pytest.approx(0.1) + + # STREAMINFO starts at byte 8; the low 36 bits of bytes 18..25 hold the total sample count + unknown_length = bytearray(known_length) + packed = int.from_bytes(unknown_length[18:26], "big") & ~((1 << 36) - 1) + unknown_length[18:26] = packed.to_bytes(8, "big") + + assert calculate_request_duration(bytes(unknown_length)) is None + class TestGetAudioFileContentHash: """Test the get_audio_file_content_hash function for cache key generation""" diff --git a/tests/unit/llms/mistral/audio_transcription/test_mistral_audio_transcription_transformation.py b/tests/unit/llms/mistral/audio_transcription/test_mistral_audio_transcription_transformation.py index 68875ff6d32..0216c89ea94 100644 --- a/tests/unit/llms/mistral/audio_transcription/test_mistral_audio_transcription_transformation.py +++ b/tests/unit/llms/mistral/audio_transcription/test_mistral_audio_transcription_transformation.py @@ -2,11 +2,14 @@ import os from unittest.mock import MagicMock import httpx +import pytest + import litellm from litellm.llms.base_llm.audio_transcription.transformation import ( BaseAudioTranscriptionConfig, ) +from litellm.llms.custom_httpx.http_handler import HTTPHandler from litellm.llms.mistral.audio_transcription.transformation import ( MistralAudioTranscriptionConfig, ) @@ -193,3 +196,81 @@ def test_mistral_audio_transcription_response_transform_empty(): assert isinstance(response, TranscriptionResponse) assert response.text == "" + + +def test_mistral_audio_transcription_response_uses_prompt_audio_seconds_for_cost(): + """Mistral bills on usage.prompt_audio_seconds, so that is the duration cost reads""" + config = MistralAudioTranscriptionConfig() + + mock_response = MagicMock(spec=httpx.Response) + mock_response.json.return_value = { + "text": "hello", + "usage": {"prompt_audio_seconds": 600, "total_tokens": 10}, + } + + response = config.transform_audio_transcription_response(mock_response) + + assert response.hidden_params["audio_transcription_duration"] == 600.0 + assert response.hidden_params["usage"] == {"prompt_audio_seconds": 600, "total_tokens": 10} + assert getattr(response, "duration", None) is None + + +def test_mistral_audio_transcription_response_without_usage_has_no_duration(): + config = MistralAudioTranscriptionConfig() + + mock_response = MagicMock(spec=httpx.Response) + mock_response.json.return_value = {"text": "hello", "usage": {"prompt_audio_seconds": None}} + + response = config.transform_audio_transcription_response(mock_response) + + assert "audio_transcription_duration" not in response.hidden_params + + +def test_mistral_transcription_cost_uses_provider_billed_seconds(): + """ + End to end through litellm.transcription: the seconds Mistral reports win over the + local file measurement (about 17s for this wav), so spend matches what Mistral bills + """ + billed_seconds = 30 + wav_path = os.path.join( + os.path.dirname(__file__), + "../../../../..", + "tests", + "llm_translation", + "gettysburg.wav", + ) + with open(wav_path, "rb") as f: + audio_bytes = f.read() + + def respond(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "model": "voxtral-mini-2602", + "text": "a tone", + "language": None, + "segments": [], + "usage": {"prompt_audio_seconds": billed_seconds, "total_tokens": 3}, + }, + request=request, + ) + + client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(respond))) + + response = litellm.transcription( + model="mistral/voxtral-mini-2602", + file=("gettysburg.wav", audio_bytes, "audio/wav"), + api_key="fake-key", + client=client, + ) + + assert response.hidden_params["audio_transcription_duration"] == float(billed_seconds) + + cost_per_second = litellm.get_model_info("mistral/voxtral-mini-2602")["input_cost_per_second"] + assert cost_per_second + cost = litellm.completion_cost( + completion_response=response, + model="mistral/voxtral-mini-2602", + call_type="transcription", + ) + assert cost == pytest.approx(billed_seconds * cost_per_second) From 6b3d08e1f492599a97844a0f20c26452dab94d2d Mon Sep 17 00:00:00 2001 From: Haquant <76264891+Steeve-Crypto@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:58:17 -0400 Subject: [PATCH 2/3] fix: keep basedpyright unknown counts flat --- .../litellm_core_utils/audio_utils/utils.py | 4 +-- .../audio_transcription/transformation.py | 33 +++++++++++++++---- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/litellm/litellm_core_utils/audio_utils/utils.py b/litellm/litellm_core_utils/audio_utils/utils.py index 28026643244..f7d84024233 100644 --- a/litellm/litellm_core_utils/audio_utils/utils.py +++ b/litellm/litellm_core_utils/audio_utils/utils.py @@ -326,8 +326,8 @@ 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: - frames: Final = len(audio) - samplerate: Final = audio.samplerate + frames: Final[int] = len(audio) + samplerate: Final[int] = audio.samplerate except Exception: # Silently fail if duration extraction fails diff --git a/litellm/llms/mistral/audio_transcription/transformation.py b/litellm/llms/mistral/audio_transcription/transformation.py index 269cebfb8f5..843ef3f89ad 100644 --- a/litellm/llms/mistral/audio_transcription/transformation.py +++ b/litellm/llms/mistral/audio_transcription/transformation.py @@ -7,6 +7,7 @@ API reference: https://docs.mistral.ai/api/#tag/audio/operation/audio_transcript from typing import Final import httpx +from pydantic import BaseModel, ConfigDict, ValidationError from litellm.litellm_core_utils.audio_utils.utils import process_audio_file from litellm.llms.base_llm.audio_transcription.transformation import ( @@ -26,6 +27,26 @@ class MistralAudioTranscriptionException(BaseLLMException): pass +class _MistralTranscriptionUsage(BaseModel): + model_config = ConfigDict(frozen=True) + + prompt_audio_seconds: float | None = None + + +class _MistralTranscriptionBody(BaseModel): + model_config = ConfigDict(frozen=True) + + usage: _MistralTranscriptionUsage | None = None + + +def _billed_audio_seconds(body: object) -> float | None: + try: + parsed: Final = _MistralTranscriptionBody.model_validate(body) + except ValidationError: + return None + return parsed.usage.prompt_audio_seconds if parsed.usage is not None else None + + class MistralAudioTranscriptionConfig(BaseAudioTranscriptionConfig): def get_supported_openai_params(self, model: str) -> list[OpenAIAudioTranscriptionOptionalParams]: return [ @@ -147,12 +168,12 @@ class MistralAudioTranscriptionConfig(BaseAudioTranscriptionConfig): if "language" in response_json: response["language"] = response_json["language"] - response.hidden_params = response_json # Mistral bills transcription on usage.prompt_audio_seconds, so cost uses it # instead of measuring the uploaded file locally - usage: Final = response_json.get("usage") - if isinstance(usage, dict): - prompt_audio_seconds: Final = usage.get("prompt_audio_seconds") - if isinstance(prompt_audio_seconds, (int, float)) and not isinstance(prompt_audio_seconds, bool): - response.hidden_params["audio_transcription_duration"] = float(prompt_audio_seconds) + billed_seconds: Final = _billed_audio_seconds(response_json) + response.hidden_params = ( + response_json + if billed_seconds is None + else {**response_json, "audio_transcription_duration": billed_seconds} + ) return response From 3777d81112f34fb48918167d1f9c105dce737280 Mon Sep 17 00:00:00 2001 From: Haquant <76264891+Steeve-Crypto@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:28:37 -0400 Subject: [PATCH 3/3] fix: keep fallback, decode unknown-length audio --- .../litellm_core_utils/audio_utils/utils.py | 46 +++++++++++-- litellm/main.py | 4 +- .../litellm_core_utils/test_audio_utils.py | 8 +-- ...tral_audio_transcription_transformation.py | 12 +++- ...test_soniox_audio_transcription_handler.py | 66 +++++++++++++++++++ 5 files changed, 122 insertions(+), 14 deletions(-) diff --git a/litellm/litellm_core_utils/audio_utils/utils.py b/litellm/litellm_core_utils/audio_utils/utils.py index f7d84024233..5acee9014e9 100644 --- a/litellm/litellm_core_utils/audio_utils/utils.py +++ b/litellm/litellm_core_utils/audio_utils/utils.py @@ -4,8 +4,9 @@ Utils used for litellm.transcription() and litellm.atranscription() import hashlib import os +from collections.abc import Sized from dataclasses import dataclass -from typing import Final +from typing import Final, Protocol from litellm.types.files import ( AUDIO_FILE_TYPES, @@ -18,6 +19,37 @@ from litellm.types.utils import FileTypes # libsndfile uses SF_COUNT_MAX (2**63 - 1) for "unknown length"; no real upload gets near 2**62 frames _MAX_PLAUSIBLE_AUDIO_FRAMES: Final = 2**62 +_DECODE_BLOCK_FRAMES: Final = 1 << 16 + + +class _ReadableAudio(Protocol): + def read(self, frames: int, dtype: str) -> Sized: ... + + def close(self) -> None: ... + + +def _open_forward_only(file_content: bytes) -> _ReadableAudio: + import io + + import soundfile as sf + + class _ForwardOnlySoundFile(sf.SoundFile): # pyright: ignore[reportUntypedBaseClass, reportUnknownMemberType] # soundfile ships no type stubs + # soundfile sizes seekable reads from tell(), which libsndfile can't answer for an + # unknown-length stream, so read it front to back instead + def seekable(self) -> bool: + return False + + return _ForwardOnlySoundFile(io.BytesIO(file_content)) + + +def _decoded_frame_count(file_content: bytes) -> int: + """Count frames by decoding, for audio whose header carries no length""" + audio: Final = _open_forward_only(file_content) + try: + # iter() stops at the first empty block, i.e. the end of the stream + return sum(iter(lambda: len(audio.read(_DECODE_BLOCK_FRAMES, "int16")), 0)) + finally: + audio.close() @dataclass @@ -326,17 +358,19 @@ 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: - frames: Final[int] = len(audio) + reported_frames: Final[int] = len(audio) samplerate: Final[int] = audio.samplerate + # libsndfile reports an unknown length (e.g. a FLAC with no sample count + # in STREAMINFO) as SF_COUNT_MAX, which would bill as ~2^63 frames, so + # count the real frames by decoding instead of trusting the header + frames: Final = ( + reported_frames if reported_frames < _MAX_PLAUSIBLE_AUDIO_FRAMES else _decoded_frame_count(file_content) + ) except Exception: # Silently fail if duration extraction fails return None - # libsndfile reports an unknown length (e.g. a FLAC with no sample count - # in STREAMINFO) as SF_COUNT_MAX, which would bill as ~2^63 frames - if frames >= _MAX_PLAUSIBLE_AUDIO_FRAMES or samplerate <= 0: - return None return frames / samplerate diff --git a/litellm/main.py b/litellm/main.py index 3a6f86fffb0..adc30f76ba2 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -7940,7 +7940,7 @@ async def atranscription(*args, **kwargs) -> TranscriptionResponse: # a plain Transcription is a TranscriptionVerbose/Diarized type. if response is not None and not isinstance(response, Coroutine) and file is not None: existing_duration: Final = getattr(response, "duration", None) - if existing_duration is None and "audio_transcription_duration" not in response.hidden_params: + if existing_duration is None and response.hidden_params.get("audio_transcription_duration") is None: calculated_duration: Final = calculate_request_duration(file) if calculated_duration is not None: response.hidden_params["audio_transcription_duration"] = calculated_duration @@ -8216,7 +8216,7 @@ def transcription( response_hidden_params: Final = cast( # cast-ok: preserve dynamic mapping behavior dict[str, object], getattr(response, HIDDEN_PARAMS_ATTR) ) - if existing_duration is None and "audio_transcription_duration" not in response_hidden_params: + if existing_duration is None and response_hidden_params.get("audio_transcription_duration") is None: calculated_duration: Final = calculate_request_duration(file) if calculated_duration is not None: response_hidden_params["audio_transcription_duration"] = calculated_duration diff --git a/tests/unit/litellm_core_utils/test_audio_utils.py b/tests/unit/litellm_core_utils/test_audio_utils.py index 3ed77122b9e..69dfa66844d 100644 --- a/tests/unit/litellm_core_utils/test_audio_utils.py +++ b/tests/unit/litellm_core_utils/test_audio_utils.py @@ -280,11 +280,11 @@ class TestCalculateRequestDuration: os.environ.get("SKIP_AUDIO_TESTS") == "true", reason="Skipping audio tests - soundfile may not be available", ) - def test_flac_without_sample_count_returns_none(self): + def test_flac_without_sample_count_counts_decoded_frames(self): """ A FLAC whose STREAMINFO has no total sample count (what ffmpeg writes to a - pipe) makes libsndfile report SF_COUNT_MAX frames. That must not turn into - a ~2^63 frame duration that gets billed + pipe) makes libsndfile report SF_COUNT_MAX frames. The duration must come + from the decoded audio, not a ~2^63 frame count and not a free 0 or None """ np = pytest.importorskip("numpy") sf = pytest.importorskip("soundfile") @@ -299,7 +299,7 @@ class TestCalculateRequestDuration: packed = int.from_bytes(unknown_length[18:26], "big") & ~((1 << 36) - 1) unknown_length[18:26] = packed.to_bytes(8, "big") - assert calculate_request_duration(bytes(unknown_length)) is None + assert calculate_request_duration(bytes(unknown_length)) == pytest.approx(0.1) class TestGetAudioFileContentHash: diff --git a/tests/unit/llms/mistral/audio_transcription/test_mistral_audio_transcription_transformation.py b/tests/unit/llms/mistral/audio_transcription/test_mistral_audio_transcription_transformation.py index 0216c89ea94..69e3a1346f1 100644 --- a/tests/unit/llms/mistral/audio_transcription/test_mistral_audio_transcription_transformation.py +++ b/tests/unit/llms/mistral/audio_transcription/test_mistral_audio_transcription_transformation.py @@ -215,11 +215,19 @@ def test_mistral_audio_transcription_response_uses_prompt_audio_seconds_for_cost assert getattr(response, "duration", None) is None -def test_mistral_audio_transcription_response_without_usage_has_no_duration(): +@pytest.mark.parametrize( + "usage", + [ + {"prompt_audio_seconds": None}, + {"prompt_audio_seconds": "not-a-number"}, + "not-an-object", + ], +) +def test_mistral_audio_transcription_response_without_usable_usage_has_no_duration(usage): config = MistralAudioTranscriptionConfig() mock_response = MagicMock(spec=httpx.Response) - mock_response.json.return_value = {"text": "hello", "usage": {"prompt_audio_seconds": None}} + mock_response.json.return_value = {"text": "hello", "usage": usage} response = config.transform_audio_transcription_response(mock_response) diff --git a/tests/unit/llms/soniox/audio_transcription/test_soniox_audio_transcription_handler.py b/tests/unit/llms/soniox/audio_transcription/test_soniox_audio_transcription_handler.py index d2d7d2247f1..5b97c6cee0a 100644 --- a/tests/unit/llms/soniox/audio_transcription/test_soniox_audio_transcription_handler.py +++ b/tests/unit/llms/soniox/audio_transcription/test_soniox_audio_transcription_handler.py @@ -1056,3 +1056,69 @@ class TestSpendTracking: litellm.model_cost = original_model_cost litellm.get_model_info.cache_clear() + @staticmethod + def _wav_bytes() -> bytes: + import os + + wav_path = os.path.join( + os.path.dirname(__file__), "../../../../..", "tests", "llm_translation", "gettysburg.wav" + ) + with open(wav_path, "rb") as f: + return f.read() + + @staticmethod + def _responses_without_duration() -> Dict[str, List[httpx.Response]]: + # Soniox omits audio_duration_ms, so the handler stores a None duration + return { + "POST https://api.soniox.com/v1/files": [_make_response({"id": "file_1"})], + "POST https://api.soniox.com/v1/transcriptions": [_make_response({"id": "tx_1"})], + "GET https://api.soniox.com/v1/transcriptions/tx_1": [_make_response({"status": "completed"})], + "GET https://api.soniox.com/v1/transcriptions/tx_1/transcript": [ + _make_response({"text": "four score", "tokens": []}) + ], + "DELETE https://api.soniox.com/v1/transcriptions/tx_1": [_make_response({})], + "DELETE https://api.soniox.com/v1/files/file_1": [_make_response({})], + } + + def test_should_fall_back_to_local_duration_when_soniox_omits_it(self, monkeypatch): + import litellm + from litellm.litellm_core_utils.audio_utils.utils import calculate_request_duration + + monkeypatch.setattr("time.sleep", lambda *_: None) + audio = self._wav_bytes() + local_duration = calculate_request_duration(audio) + assert local_duration is not None and local_duration > 0 + + resp = litellm.transcription( + model="soniox/stt-async-v4", + file=("gettysburg.wav", audio, "audio/wav"), + api_key="sk-test", + client=_MockSyncClient(self._responses_without_duration()), + ) + + assert resp._hidden_params["audio_transcription_duration"] == pytest.approx(local_duration) + cost = litellm.completion_cost(completion_response=resp, model="soniox/stt-async-v4", call_type="transcription") + assert cost > 0 + + def test_should_fall_back_to_local_duration_when_soniox_omits_it_async(self, monkeypatch): + import litellm + from litellm.litellm_core_utils.audio_utils.utils import calculate_request_duration + + async def _no_sleep(*_a, **_kw): + return None + + monkeypatch.setattr(asyncio, "sleep", _no_sleep) + audio = self._wav_bytes() + local_duration = calculate_request_duration(audio) + assert local_duration is not None and local_duration > 0 + + resp = asyncio.new_event_loop().run_until_complete( + litellm.atranscription( + model="soniox/stt-async-v4", + file=("gettysburg.wav", audio, "audio/wav"), + api_key="sk-test", + client=_MockAsyncClient(self._responses_without_duration()), + ) + ) + + assert resp._hidden_params["audio_transcription_duration"] == pytest.approx(local_duration)