Merge pull request #50860 from BerriAI/litellm_backport_transcription_usage_seconds_lts_1_108_x

fix(cost): backport usage.seconds billing and the unsized FLAC guard to lts/1.108.x
This commit is contained in:
yuneng-jiang 2026-11-10 10:23:09 -08:00 • committed by GitHub
commit 5ef9d2a895
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 209 additions and 13 deletions

View file

@ -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

View file

@ -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,19 @@ 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
LIBSNDFILE_UNKNOWN_FRAME_COUNT: Final = 2**63 - 1
@dataclass
class ProcessedAudioFile:
"""
@ -323,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:
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:

View file

@ -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 (
@ -513,7 +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:
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

View file

@ -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,

View file

@ -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):

View file

@ -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)

View file

@ -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"""

View file

@ -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):
"""

View file

@ -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")

View file

@ -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,