diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 6251bb26276..e66be233be4 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -1554,12 +1554,66 @@ def _breakdown_total(litellm_logging_obj: LitellmLoggingObject | None) -> float return float(total) if isinstance(total, (int, float)) and not isinstance(total, bool) else None +def _cost_map_priced_input_output( + model: str | None, + custom_llm_provider: str | None, + usage: Usage | None, + call_type: CallTypesLiteral | None, + service_tier: str | None, + data_residency: str | None, +) -> tuple[float, float]: + if not model or usage is None: + return 0.0, 0.0 + try: + return cost_per_token( + model=model, + custom_llm_provider=custom_llm_provider, + usage_object=usage, + call_type=call_type or "completion", + service_tier=service_tier, + data_residency=data_residency, + ) + except Exception as pricing_error: # noqa: BLE001 # cost_per_token raises plain Exception for an unmapped model + verbose_logger.debug("No cost map price for reported-cost split of %s: %s", model, pricing_error) + return 0.0, 0.0 + + +def split_reported_cost_by_cost_map_ratio( + reported_total: float, + model: str | None, + custom_llm_provider: str | None, + usage: Usage | None, + call_type: CallTypesLiteral | None, + service_tier: str | None, + data_residency: str | None, +) -> tuple[float, float]: + """Apportions a provider-reported total between input and output in the ratio litellm's own cost map would + price the same usage, so a per-input-token model books the whole total on input. Unmapped or zero-priced + usage keeps the whole total on output.""" + priced_input, priced_output = _cost_map_priced_input_output( + model=model, + custom_llm_provider=custom_llm_provider, + usage=usage, + call_type=call_type, + service_tier=service_tier, + data_residency=data_residency, + ) + priced_total: Final = priced_input + priced_output + if priced_total <= 0: + return 0.0, reported_total + input_cost: Final = reported_total * (priced_input / priced_total) + return input_cost, reported_total - input_cost + + def _adjusted_provider_billed_cost( reported_cost: float, custom_llm_provider: str | None, + pricing_provider: str | None, model: str | None, region_name: str | None, litellm_logging_obj: LitellmLoggingObject | None, + usage: Usage | None, + call_type: CallTypesLiteral | None, service_tier: str | None, data_residency: str | None, ) -> float: @@ -1569,10 +1623,19 @@ def _adjusted_provider_billed_cost( adjusted_cost, margin_percent, margin_fixed_amount, margin_total_amount = _apply_cost_margin( base_cost=discounted_cost, custom_llm_provider=custom_llm_provider ) + input_cost, output_cost = split_reported_cost_by_cost_map_ratio( + reported_total=adjusted_cost, + model=model, + custom_llm_provider=pricing_provider, + usage=usage, + call_type=call_type, + service_tier=service_tier, + data_residency=data_residency, + ) _store_cost_breakdown_in_logging_obj( litellm_logging_obj=litellm_logging_obj, - prompt_tokens_cost_usd_dollar=0.0, - completion_tokens_cost_usd_dollar=adjusted_cost, + prompt_tokens_cost_usd_dollar=input_cost, + completion_tokens_cost_usd_dollar=output_cost, cost_for_built_in_tools_cost_usd_dollar=0.0, total_cost_usd_dollar=adjusted_cost, original_cost=reported_cost, @@ -2080,9 +2143,16 @@ def completion_cost( return _adjusted_provider_billed_cost( reported_cost=reported_cost, custom_llm_provider=custom_llm_provider, + pricing_provider=_pricing_provider_for_cost_candidate( + candidate_provider=candidate_pricing_provider, + request_provider=request_pricing_provider, + current_provider=custom_llm_provider, + ), model=model_for_discount, region_name=region_name if isinstance(region_name, str) else None, litellm_logging_obj=litellm_logging_obj, + usage=cost_per_token_usage_object, + call_type=call_type, service_tier=service_tier, data_residency=data_residency, ) @@ -2387,10 +2457,28 @@ def response_cost_calculator( provider_response_cost: Final = get_response_cost_from_hidden_params(hidden_params) if provider_response_cost is not None and not custom_pricing: if _breakdown_total(litellm_logging_obj) != provider_response_cost: + header_priced_model: Final = select_model_name_for_cost_calc( + model=model, + completion_response=response_object, + custom_llm_provider=custom_llm_provider, + custom_pricing=custom_pricing, + base_model=base_model, + router_model_id=router_model_id, + region_name=region_name, + ) + header_input_cost, header_output_cost = split_reported_cost_by_cost_map_ratio( + reported_total=provider_response_cost, + model=header_priced_model or model, + custom_llm_provider=pricing_provider or custom_llm_provider, + usage=get_usage_object(response_object), + call_type=call_type, + service_tier=service_tier, + data_residency=data_residency, + ) _store_cost_breakdown_in_logging_obj( litellm_logging_obj=litellm_logging_obj, - prompt_tokens_cost_usd_dollar=0.0, - completion_tokens_cost_usd_dollar=provider_response_cost, + prompt_tokens_cost_usd_dollar=header_input_cost, + completion_tokens_cost_usd_dollar=header_output_cost, cost_for_built_in_tools_cost_usd_dollar=0.0, total_cost_usd_dollar=provider_response_cost, original_cost=provider_response_cost, diff --git a/tests/integration/cost_calculation/cases/misc.py b/tests/integration/cost_calculation/cases/misc.py index 760f77f91cc..7743af61e8c 100644 --- a/tests/integration/cost_calculation/cases/misc.py +++ b/tests/integration/cost_calculation/cases/misc.py @@ -493,8 +493,8 @@ OPENROUTER_ANTHROPIC_CLAUDE_SONNET_5_PROVIDER_REPORTED_COST: Final = CostTrackin ), expected=ExactExpected( spend=0.0421, - input_cost=0.0, - output_cost=0.0421, + input_cost=0.0421 * (1840 * 3.2e-06) / (1840 * 3.2e-06 + 412 * 1.6e-05), + output_cost=0.0421 * (412 * 1.6e-05) / (1840 * 3.2e-06 + 412 * 1.6e-05), prompt_tokens=1840, completion_tokens=412, breakdown_persisted=False, diff --git a/tests/integration/cost_calculation/cases/openai.py b/tests/integration/cost_calculation/cases/openai.py index 1443cc5ab59..bc7fb3486be 100644 --- a/tests/integration/cost_calculation/cases/openai.py +++ b/tests/integration/cost_calculation/cases/openai.py @@ -7787,8 +7787,8 @@ GPT_5_6_UPSTREAM_COST_HEADER_BILLS_HEADER_VALUE: Final = CostTrackingTestCase( ), expected=ExactExpected( spend=0.000413, - input_cost=0.0, - output_cost=0.000413, + input_cost=0.000413 * (10 * 1.75e-06) / (10 * 1.75e-06 + 253 * 1.4e-05), + output_cost=0.000413 * (253 * 1.4e-05) / (10 * 1.75e-06 + 253 * 1.4e-05), prompt_tokens=10, completion_tokens=253, breakdown_persisted=False, @@ -11885,7 +11885,7 @@ GPT_5_6_TERRA_MESSAGES_BRIDGED_STREAM: Final = GPT_5_6_TERRA_MESSAGES_BRIDGED_JS # replies alike. A deployment the operator priced keeps the operator's rates on both. _GATEWAY_REPORTED_USAGE: Final = {"prompt_tokens": 100, "completion_tokens": 20, "total_tokens": 120, "cost": 0.037} _GATEWAY_REPORTED_EXPECTED: Final = ExactExpected( - spend=0.037, input_cost=0.0, output_cost=0.037, prompt_tokens=100, completion_tokens=20 + spend=0.037, input_cost=0.023125, output_cost=0.013875, prompt_tokens=100, completion_tokens=20 ) _GATEWAY_OPERATOR_PRICED: Final = Deployment(input_cost_per_token=2e-06, output_cost_per_token=6e-06) _GATEWAY_OPERATOR_PRICED_EXPECTED: Final = ExactExpected( diff --git a/tests/integration/cost_calculation/cost_map.py b/tests/integration/cost_calculation/cost_map.py index da4c2b6e859..8f11b3ce2f1 100644 --- a/tests/integration/cost_calculation/cost_map.py +++ b/tests/integration/cost_calculation/cost_map.py @@ -157,7 +157,7 @@ COST_MAP_ENTRIES: Final[dict[str, dict[str, JsonValue]]] = { "openrouter/typesafe/jev-1.13": { "litellm_provider": "openrouter", "mode": "evaluation", - "input_cost_per_token": 0.0, + "input_cost_per_token": 4.2e-08, "output_cost_per_token": 0.0, }, "perplexity/pplx-decider-v1-27b": { diff --git a/tests/unit/llms/openai_like/test_kyma_provider.py b/tests/unit/llms/openai_like/test_kyma_provider.py index e834e0fc0e9..c4bb36ef011 100644 --- a/tests/unit/llms/openai_like/test_kyma_provider.py +++ b/tests/unit/llms/openai_like/test_kyma_provider.py @@ -410,8 +410,12 @@ def test_cost_headers_show_the_discount_and_margin_taken_from_the_cost_kyma_repo assert float(headers["x-litellm-response-cost-discount-amount"] or "nan") == pytest.approx(0.004) assert float(headers["x-litellm-response-cost-margin-amount"] or "nan") == pytest.approx(0.0004) assert float(headers["x-litellm-response-cost"] or "nan") == pytest.approx(0.0044) - assert float(headers["x-litellm-response-cost-input"] or "nan") == 0.0 - assert float(headers["x-litellm-response-cost-output"] or "nan") == pytest.approx(0.0044) + row: Final = litellm.get_model_info("sonar", custom_llm_provider="kyma") + priced_input: Final = 4 * row["input_cost_per_token"] + priced_output: Final = 3 * row["output_cost_per_token"] + input_share: Final = priced_input / (priced_input + priced_output) + assert float(headers["x-litellm-response-cost-input"] or "nan") == pytest.approx(0.0044 * input_share) + assert float(headers["x-litellm-response-cost-output"] or "nan") == pytest.approx(0.0044 * (1 - input_share)) @pytest.mark.parametrize("thinking", ({"type": "enabled", "budget_tokens": 1024}, {"type": "adaptive"})) diff --git a/tests/unit/test_cost_calculator.py b/tests/unit/test_cost_calculator.py index c536522e736..cca138e0bb5 100644 --- a/tests/unit/test_cost_calculator.py +++ b/tests/unit/test_cost_calculator.py @@ -10935,3 +10935,176 @@ def test_bedrock_registered_cache_prices_preserve_dynamic_threshold_rates( assert entry is not None assert entry[1]["input_cost_per_token_above_300k_tokens"] == 0.03 assert entry[1][tier_field] == (tier_override if tier_override is not None else 0.007) + + +_REPORTED_TOTAL: Final = 0.037 + + +def _reported_cost_breakdown( + monkeypatch: pytest.MonkeyPatch, + model: str, + call_type: Literal["completion", "decisions"], + cost_map_row: Mapping[str, object] | None, +) -> Mapping[str, object]: + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url="")) + if cost_map_row is not None: + litellm.register_model(model_cost={model: dict(cost_map_row)}) + logging_obj: Final = Logging( + model=model, + messages=[{"role": "user", "content": "reported cost"}], + stream=False, + call_type=call_type, + start_time=datetime.datetime.now(), + litellm_call_id="reported-cost-split", + function_id="f", + ) + response: Final = ModelResponse( + model=model, + choices=[Choices(message=Message(content="ok"))], + usage=Usage(prompt_tokens=100, completion_tokens=20, total_tokens=120, cost=_REPORTED_TOTAL), + ) + spend: Final = completion_cost( + completion_response=response, + model=model, + custom_llm_provider="openai", + call_type=call_type, + litellm_logging_obj=logging_obj, + ) + assert spend == _REPORTED_TOTAL + breakdown: Final = logging_obj.cost_breakdown + assert breakdown is not None + return breakdown + + +def test_reported_total_is_split_between_input_and_output_by_the_cost_map_rate_ratio( + monkeypatch: pytest.MonkeyPatch, +) -> None: + breakdown: Final = _reported_cost_breakdown( + monkeypatch, + model="cc-reported-cost-both-rates", + call_type="completion", + cost_map_row={ + "litellm_provider": "openai", + "mode": "chat", + "input_cost_per_token": 1e-06, + "output_cost_per_token": 3e-06, + }, + ) + + input_share: Final = (100 * 1e-06) / (100 * 1e-06 + 20 * 3e-06) + assert breakdown["input_cost"] == pytest.approx(_REPORTED_TOTAL * input_share) + assert breakdown["output_cost"] == pytest.approx(_REPORTED_TOTAL * (1 - input_share)) + assert breakdown["input_cost"] + breakdown["output_cost"] == _REPORTED_TOTAL + assert breakdown["total_cost"] == _REPORTED_TOTAL + + +def test_reported_total_on_a_decisions_model_priced_per_input_token_lands_on_input( + monkeypatch: pytest.MonkeyPatch, +) -> None: + breakdown: Final = _reported_cost_breakdown( + monkeypatch, + model="cc-reported-cost-decisions", + call_type="decisions", + cost_map_row={ + "litellm_provider": "openai", + "mode": "decisions", + "input_cost_per_token": 4.2e-08, + "output_cost_per_token": 0.0, + }, + ) + + assert breakdown["input_cost"] == _REPORTED_TOTAL + assert breakdown["output_cost"] == 0.0 + + +def test_reported_total_without_a_cost_map_row_falls_back_to_output(monkeypatch: pytest.MonkeyPatch) -> None: + breakdown: Final = _reported_cost_breakdown( + monkeypatch, model="cc-reported-cost-unmapped", call_type="completion", cost_map_row=None + ) + + assert breakdown["input_cost"] == 0.0 + assert breakdown["output_cost"] == _REPORTED_TOTAL + + +def test_upstream_cost_header_total_is_split_by_the_cost_map_rate_ratio(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url="")) + litellm.register_model( + model_cost={ + "cc-header-cost-chat": { + "litellm_provider": "openai", + "mode": "chat", + "input_cost_per_token": 0.001, + "output_cost_per_token": 0.002, + } + } + ) + logging_obj: Final = Logging( + model="cc-header-cost-chat", + messages=[{"role": "user", "content": "reported cost"}], + stream=False, + call_type="completion", + start_time=datetime.datetime.now(), + litellm_call_id="reported-cost-header-split", + function_id="f", + ) + + cost: Final = response_cost_calculator( + response_object=_header_cost_chat_response({"llm_provider-x-litellm-response-cost": "0.25"}), + model="cc-header-cost-chat", + custom_llm_provider="openai", + call_type="completion", + optional_params={}, + litellm_logging_obj=logging_obj, + ) + + assert cost == 0.25 + breakdown: Final = logging_obj.cost_breakdown + assert breakdown is not None + input_share: Final = (10 * 0.001) / (10 * 0.001 + 20 * 0.002) + assert breakdown["input_cost"] == pytest.approx(0.25 * input_share) + assert breakdown["output_cost"] == pytest.approx(0.25 * (1 - input_share)) + assert breakdown["total_cost"] == 0.25 + + +def test_upstream_cost_header_total_on_an_unmapped_alias_is_split_by_the_served_model_rates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url="")) + litellm.register_model( + model_cost={ + "cc-header-cost-chat": { + "litellm_provider": "openai", + "mode": "chat", + "input_cost_per_token": 0.001, + "output_cost_per_token": 0.002, + } + } + ) + logging_obj: Final = Logging( + model="team-alias-for-header-cost", + messages=[{"role": "user", "content": "reported cost"}], + stream=False, + call_type="completion", + start_time=datetime.datetime.now(), + litellm_call_id="reported-cost-header-alias-split", + function_id="f", + ) + + cost: Final = response_cost_calculator( + response_object=_header_cost_chat_response({"llm_provider-x-litellm-response-cost": "0.25"}), + model="team-alias-for-header-cost", + custom_llm_provider="openai", + call_type="completion", + optional_params={}, + litellm_logging_obj=logging_obj, + ) + + assert cost == 0.25 + breakdown: Final = logging_obj.cost_breakdown + assert breakdown is not None + input_share: Final = (10 * 0.001) / (10 * 0.001 + 20 * 0.002) + assert breakdown["input_cost"] == pytest.approx(0.25 * input_share) + assert breakdown["output_cost"] == pytest.approx(0.25 * (1 - input_share))