diff --git a/litellm/proxy/spend_tracking/budget_reservation.py b/litellm/proxy/spend_tracking/budget_reservation.py index f4999d26d6f..87a9206825a 100644 --- a/litellm/proxy/spend_tracking/budget_reservation.py +++ b/litellm/proxy/spend_tracking/budget_reservation.py @@ -1045,6 +1045,21 @@ def _estimate_request_max_cost_for_model( return max(valid_estimates) if valid_estimates else None +_TIER_OUTPUT_RATE_KEYS: Final = ("output_cost_per_token", "output_cost_per_reasoning_token") + + +def _tier_output_rate(tier: Mapping[str, object], model_info: Mapping[str, object]) -> float: + """Output rate to reserve for a request billed at ``tier``. + + A tier table that prices only input falls back to the model's own output rates when + the request is billed, so reserving the tier's missing rate as 0 leaves every + completion under-reserved. The reasoning-token share is unknown before the request + runs, so the higher of the two rates is used either way. + """ + rates: Final = tier if any(key in tier for key in _TIER_OUTPUT_RATE_KEYS) else model_info + return max(_to_float(rates.get(key)) or 0.0 for key in _TIER_OUTPUT_RATE_KEYS) + + def _max_cost_for_cost_info( request_body: dict, route: str, @@ -1079,12 +1094,8 @@ def _max_cost_for_cost_info( if isinstance(tiered_pricing, list) and tiered_pricing: tier: Final = select_tier_for_input(tiered_pricing=tiered_pricing, input_tokens=estimated_input_tokens) if tier is not None: - output_rate = max( - tier_rate(tier, "output_cost_per_token"), - tier_rate(tier, "output_cost_per_reasoning_token"), - ) return (estimated_input_tokens * tier_rate(tier, "input_cost_per_token")) + ( - output_tokens * output_multiplier * output_rate + output_tokens * output_multiplier * _tier_output_rate(tier=tier, model_info=model_info) ) input_cost_per_token: Final = _to_float(model_info.get("input_cost_per_token")) diff --git a/tests/test_litellm/proxy/test_budget_reservation.py b/tests/test_litellm/proxy/test_budget_reservation.py index 9c88daa9990..d837a7372e2 100644 --- a/tests/test_litellm/proxy/test_budget_reservation.py +++ b/tests/test_litellm/proxy/test_budget_reservation.py @@ -1124,7 +1124,7 @@ def test_reservation_uses_most_expensive_deployment_in_group(): ], ids=["litellm_params", "model_info"], ) -def test_free_deployment_of_tiered_model_reserves_nothing(deployment_overrides): +def test_free_deployment_of_tiered_model_reserves_nothing(deployment_overrides: dict[str, dict[str, int]]): """A deployment priced at 0 on a model whose published entry carries a tier table must not be estimated against that table. Spend tracking bills such a deployment at its own rates, so reserving the published tier rate consumed, and rejected requests @@ -1223,6 +1223,46 @@ def test_deployment_declaring_own_tier_table_keeps_it(): assert estimated is not None and estimated > 0 +def test_input_only_tier_reserves_the_models_own_output_rate(): + """A tier table that prices only input is billed with the model's own output rates, + so reserving the tier's absent output rate as 0 would leave every completion + unreserved and let a budgeted caller run past their limit.""" + output_tokens = 500 + router = Router( + model_list=[ + { + "model_name": "input-tiered", + "litellm_params": { + "model": "dashscope/qwen-plus-latest", + "api_key": "sk-fake", + "input_cost_per_token": 1e-06, + "output_cost_per_token": 5e-06, + "tiered_pricing": [{"range": [0, 32000], "input_cost_per_token": 2e-06}], + }, + } + ] + ) + request_body = { + "model": "input-tiered", + "messages": [{"role": "user", "content": "hello " * 100}], + "max_tokens": output_tokens, + } + + input_cost = estimate_request_input_cost( + request_body=request_body, + route="/chat/completions", + llm_router=router, + ) + estimated = estimate_request_max_cost( + request_body=request_body, + route="/chat/completions", + llm_router=router, + ) + + assert input_cost is not None and input_cost > 0 + assert estimated == pytest.approx(input_cost + (output_tokens * 5e-06)) + + @pytest.mark.asyncio async def test_should_clamp_reservation_to_model_ceiling_when_caller_overrequests( spend_counter_state,