From f7f927c821fdf38664d2198931ec6ed655de4b47 Mon Sep 17 00:00:00 2001 From: Magesh Date: Mon, 8 Jun 2026 14:07:35 +0530 Subject: [PATCH] fix(proxy): exempt zero-cost models from internal-user budget hook _PROXY_MaxBudgetLimiter enforced the personal (internal-user) budget for every request and ignored model cost, so zero-cost models were wrongly blocked once a user was over budget - unlike common_checks, which already exempts them via _is_model_cost_zero/skip_budget_checks. Add the same zero-cost guard to the hook. Fixes #29912 --- litellm/proxy/hooks/max_budget_limiter.py | 15 ++++ .../proxy/hooks/test_max_budget_limiter.py | 82 +++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/litellm/proxy/hooks/max_budget_limiter.py b/litellm/proxy/hooks/max_budget_limiter.py index 658d7995631..dbb03f9c4ca 100644 --- a/litellm/proxy/hooks/max_budget_limiter.py +++ b/litellm/proxy/hooks/max_budget_limiter.py @@ -36,6 +36,21 @@ class _PROXY_MaxBudgetLimiter(CustomLogger): if user_api_key_dict.team_id is not None: return + # Zero-cost models (e.g. free / on-prem models configured with + # input+output cost == 0) bypass budget enforcement, matching + # common_checks()/_should_skip_budget_checks(). Without this, an + # over-budget user is incorrectly blocked from free models even + # though those requests add nothing to spend. + # Imported lazily to avoid a circular import via proxy.utils. + from litellm.proxy.auth.auth_checks import _is_model_cost_zero + from litellm.proxy.proxy_server import llm_router + + if _is_model_cost_zero( + model=data.get("model") if data else None, + llm_router=llm_router, + ): + return + # The reservation path admits at the strict-`<` boundary and # atomically pre-fills the same counter we'd read here. Re-checking # with `>=` would reject a request the reservation already admitted diff --git a/tests/test_litellm/proxy/hooks/test_max_budget_limiter.py b/tests/test_litellm/proxy/hooks/test_max_budget_limiter.py index 0074d7062b8..92c580c92a9 100644 --- a/tests/test_litellm/proxy/hooks/test_max_budget_limiter.py +++ b/tests/test_litellm/proxy/hooks/test_max_budget_limiter.py @@ -206,3 +206,85 @@ async def test_no_max_budget_passes(): assert result is None mock_get_spend.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# Regression: zero-cost (free / on-prem) models must NOT be blocked by the +# personal-budget hook when the user is over budget. +# +# common_checks already exempts zero-cost models via skip_budget_checks +# (_is_model_cost_zero). This hook is a *separate* enforcement point that +# currently ignores model cost, so it blocks free models too. These tests pin +# the intended behavior: free model -> allowed, paid model -> still blocked. +# --------------------------------------------------------------------------- + + +def _zero_and_paid_router(): + from litellm import Router + + return Router( + model_list=[ + { + "model_name": "free-model", + "litellm_params": { + "model": "openai/free", + "api_key": "x", + "input_cost_per_token": 0, + "output_cost_per_token": 0, + }, + }, + { + "model_name": "paid-model", + "litellm_params": { + "model": "openai/paid", + "api_key": "x", + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00001, + }, + }, + ] + ) + + +@pytest.mark.asyncio +async def test_zero_cost_model_exempt_from_personal_budget(): + """Over-budget user requesting a ZERO-COST model must be allowed (regression).""" + handler = _PROXY_MaxBudgetLimiter() + user_api_key_dict = _make_user_api_key_auth(user_max_budget=10.0) + router = _zero_and_paid_router() + + with patch( + "litellm.proxy.proxy_server.get_current_spend", + new=AsyncMock(return_value=10.0), + ), patch("litellm.proxy.proxy_server.llm_router", router): + result = await handler.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=DualCache(), + data={"model": "free-model"}, + call_type="completion", + ) + + assert result is None # zero-cost model is exempt despite over-budget + + +@pytest.mark.asyncio +async def test_paid_model_still_blocked_when_over_budget(): + """Over-budget user requesting a PAID model must still be blocked (guard).""" + handler = _PROXY_MaxBudgetLimiter() + user_api_key_dict = _make_user_api_key_auth(user_max_budget=10.0) + router = _zero_and_paid_router() + + with patch( + "litellm.proxy.proxy_server.get_current_spend", + new=AsyncMock(return_value=10.0), + ), patch("litellm.proxy.proxy_server.llm_router", router): + with pytest.raises(HTTPException) as exc_info: + await handler.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=DualCache(), + data={"model": "paid-model"}, + call_type="completion", + ) + + assert exc_info.value.status_code == 429 + assert "Max budget limit reached." in exc_info.value.detail