mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
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>
This commit is contained in:
parent
d7dc5d7fa3
commit
dfb1aac8b1
4 changed files with 50 additions and 95 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue