From dfb1aac8b1c2a8b6e80d0ff7449049693563f69f Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Mon, 9 Nov 2026 13:37:55 -0800 Subject: [PATCH] fix(cost)!: bill transcriptions by the seconds the provider reports (#50727) * fix(cost): bill transcriptions by the seconds the provider reports * Update litellm/cost_calculator.py sure Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- litellm/cost_calculator.py | 40 ++------ .../transcriptions/test_gpt_transcribe.py | 4 +- .../test_transcription_duration_hidden.py | 4 +- tests/unit/test_cost_calculator.py | 97 ++++++++----------- 4 files changed, 50 insertions(+), 95 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 5f7d646e150..58947f9d0c2 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -103,9 +103,6 @@ from litellm.llms.openai.cost_calculation import ( from litellm.llms.openai.cost_calculation import ( video_output_cost_per_second, ) -from litellm.llms.openai.transcriptions.gpt_transformation import ( - OpenAIGPTAudioTranscriptionConfig, -) from litellm.llms.perplexity.cost_calculator import ( cost_per_token as perplexity_cost_per_token, ) @@ -381,38 +378,15 @@ def _transcription_uses_token_pricing( return (prompt_tokens_val > 0) or (completion_tokens_val > 0) -def _bills_transcription_usage_duration(pricing_model: str | None, custom_llm_provider: str | None) -> bool: - if pricing_model is None or custom_llm_provider is None: - return False - transcription_info: Final = _lookup_model_info_or_none(pricing_model, custom_llm_provider) - transcription_metadata: Final = ( - as_str_mapping(transcription_info.get("provider_specific_entry")) if transcription_info is not None else None - ) - return transcription_metadata is not None and transcription_metadata.get("transcription_usage_duration") == 1 - - -def _transcription_duration_for_cost( - completion_response: object, pricing_model: str | None, model: str | None, custom_llm_provider: str | None -) -> float: +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")) ) - if measured_duration is not None and measured_duration > 0: - return measured_duration - provider_duration: Final = _get_transcription_usage_duration(completion_response) - if ( - provider_duration is not None - and provider_duration > 0 - and any(_bills_transcription_usage_duration(name, custom_llm_provider) for name in (pricing_model, model)) - ): - return provider_duration - if model is None or custom_llm_provider != LlmProviders.OPENAI.value: - return 0.0 - transcription_config: Final = ProviderConfigManager.get_provider_audio_transcription_config( - model=model.removeprefix(f"{LlmProviders.OPENAI.value}/"), provider=LlmProviders.OPENAI - ) - return (provider_duration or 0.0) if isinstance(transcription_config, OpenAIGPTAudioTranscriptionConfig) else 0.0 + 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"] @@ -2132,9 +2106,7 @@ def completion_cost( prompt, ) elif call_type in _TRANSCRIPTION_CALL_TYPES: - audio_transcription_file_duration = _transcription_duration_for_cost( - completion_response, model, requested_model, custom_llm_provider - ) + 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/tests/unit/llms/openai/transcriptions/test_gpt_transcribe.py b/tests/unit/llms/openai/transcriptions/test_gpt_transcribe.py index 9bb5fb6d300..4eff0651941 100644 --- a/tests/unit/llms/openai/transcriptions/test_gpt_transcribe.py +++ b/tests/unit/llms/openai/transcriptions/test_gpt_transcribe.py @@ -885,7 +885,7 @@ async def _spend_for_one_second_wav( @pytest.mark.asyncio -async def test_custom_named_gpt_transcribe_deployment_bills_the_measured_upload_over_reported_seconds( +async def test_custom_named_gpt_transcribe_deployment_bills_the_reported_seconds_over_the_measured_upload( local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch ) -> None: router: Final = litellm.Router( @@ -902,7 +902,7 @@ async def test_custom_named_gpt_transcribe_deployment_bills_the_measured_upload_ monkeypatch, {"text": "hello", "usage": {"type": "duration", "seconds": 2}}, router ) - assert spend.cost == pytest.approx(_gpt_transcribe_cost_per_second()) + assert spend.cost == pytest.approx(2 * _gpt_transcribe_cost_per_second()) @pytest.mark.asyncio 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 61798b49be0..9c0b9bcec13 100644 --- a/tests/unit/llms/openai/transcriptions/test_transcription_duration_hidden.py +++ b/tests/unit/llms/openai/transcriptions/test_transcription_duration_hidden.py @@ -129,7 +129,7 @@ class TestCostCalculatorReadsDurationFromHiddenParams: @patch( # test-quality-ok: isolates duration selection from the model pricing table "litellm.cost_calculator.openai_cost_per_second" ) - def test_completion_cost_prefers_measured_duration_over_provider_usage_duration(self, mock_cost_fn): + 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", @@ -148,7 +148,7 @@ class TestCostCalculatorReadsDurationFromHiddenParams: mock_cost_fn.assert_called_once() _, kwargs = mock_cost_fn.call_args - assert kwargs["duration"] == 17.5 + assert kwargs["duration"] == 60.0 @pytest.mark.parametrize( ("model", "provider"), diff --git a/tests/unit/test_cost_calculator.py b/tests/unit/test_cost_calculator.py index 49d51e99790..1d9f79aafc6 100644 --- a/tests/unit/test_cost_calculator.py +++ b/tests/unit/test_cost_calculator.py @@ -7532,82 +7532,65 @@ def test_whisper_azure(): assert round(cost, 5) == round(expected_cost, 5) -@pytest.mark.parametrize( - ("provider", "requested_model", "response_extras", "expected_seconds"), - [ - ("openai", "openai/gpt-fake-transcribe", {}, 2.0), - ("openai", "openai/gpt-fake-transcribe", {"_audio_transcription_duration": 1.35}, 1.35), - ("openai", "openai/gpt-fake-transcribe", {"duration": 1.34}, 1.34), - ("openai", "openai/whisper-fake", {}, 0.0), - ("azure", "gpt-fake-transcribe", {}, 0.0), - ], - ids=[ - "undecodable_upload_bills_provider_seconds", - "measured_duration_still_wins", - "provider_duration_still_wins", - "whisper_models_never_bill_provider_seconds", - "azure_never_bills_provider_seconds", - ], -) -def test_transcription_cost_falls_back_to_provider_billed_seconds( - monkeypatch: pytest.MonkeyPatch, - provider: str, - requested_model: str, - response_extras: Mapping[str, float], - expected_seconds: float, -) -> None: - model: Final = requested_model.removeprefix(f"{provider}/") +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"}, ) - transcription: Final = convert_to_model_response_object( - response_object={ - "text": "What is the weather in Paris?", - "usage": {"type": "duration", "seconds": 2}, - **response_extras, - }, + 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", ) - cost: Final = completion_cost( - completion_response=transcription, model=requested_model, call_type="atranscription" + +@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} ) - assert cost == pytest.approx(0.01 * expected_seconds) + cost: Final = completion_cost( + completion_response=transcription, model=f"{provider}/{model}", call_type="atranscription" + ) + + assert cost == pytest.approx(0.01 * 3) @pytest.mark.parametrize( - ("provider", "measured_seconds", "expected_seconds"), - [("openai", 1.0, 1.0), ("azure", 1.0, 1.0), ("azure", None, 7.0)], - ids=["openai_measured_upload_wins", "azure_measured_upload_wins", "unmeasured_upload_bills_provider_seconds"], + ("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_priced_by_provider_duration_still_bills_the_measured_upload( - monkeypatch: pytest.MonkeyPatch, provider: str, measured_seconds: float | None, expected_seconds: float +def test_transcription_cost_without_provider_seconds_bills_the_measured_length( + monkeypatch: pytest.MonkeyPatch, measured: Mapping[str, float], expected_seconds: float ) -> None: - monkeypatch.setitem( - litellm.model_cost, - "gpt-fake-transcribe", - { - "input_cost_per_second": 0.01, - "litellm_provider": provider, - "mode": "audio_transcription", - "provider_specific_entry": {"transcription_usage_duration": 1}, - }, - ) - measured: Final = {} if measured_seconds is None else {"audio_transcription_duration": measured_seconds} - transcription: Final = convert_to_model_response_object( - response_object={"text": "What is the weather in Paris?", "usage": {"type": "duration", "seconds": 7}}, - model_response_object=TranscriptionResponse(), - hidden_params={"model": "gpt-fake-transcribe", "custom_llm_provider": provider, **measured}, - response_type="audio_transcription", - ) + transcription: Final = _per_second_transcription(monkeypatch, "openai", "whisper-fake", measured) cost: Final = completion_cost( - completion_response=transcription, model=f"{provider}/gpt-fake-transcribe", call_type="atranscription" + completion_response=transcription, model="openai/whisper-fake", call_type="atranscription" ) assert cost == pytest.approx(0.01 * expected_seconds)