From 5a31f62d327cd547c9e248646625f6548f0a4e60 Mon Sep 17 00:00:00 2001 From: Armaan Sandhu Date: Tue, 2 Jun 2026 14:34:20 +0530 Subject: [PATCH] fix(proxy): narrow model-discovery budget bypass to explicit route set (#27923) --- litellm/proxy/auth/auth_checks.py | 22 +- .../test_info_route_budget_bypass.py | 195 ------------------ .../proxy/auth/test_auth_checks.py | 111 +++++++++- 3 files changed, 125 insertions(+), 203 deletions(-) delete mode 100644 tests/proxy_unit_tests/test_info_route_budget_bypass.py diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 823bdbaa63f..917239e0358 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -491,6 +491,20 @@ async def check_tools_allowlist( ) +# Read-only discovery routes that incur no spend. Kept narrower than info_routes so an exhausted +# budget cannot reach side-effectful routes like /health/services (Slack/email/webhook). See #27923. +MODEL_DISCOVERY_ROUTES = frozenset( + { + "/v1/models", + "/models", + "/model/info", + "/v1/model/info", + "/v2/model/info", + "/model_group/info", + } +) + + async def common_checks( # noqa: PLR0915 request_body: dict, team_object: Optional[LiteLLM_TeamTable], @@ -534,13 +548,7 @@ async def common_checks( # noqa: PLR0915 request_query_params=_safe_get_request_query_params(request=request), ) - # Model-discovery / info routes (e.g. GET /v1/models, /models, /model/info) perform no - # inference and incur no spend, so a budget must never block them. `_global_proxy_budget_check` - # already exempts /v1/models and /models; mirror that here so the entity-level budget checks - # (team / key / org / user / end-user / project) stay consistent and OpenAI-compatible clients - # (Open WebUI, Cursor, Aider, etc.) can still discover models after a budget is exhausted. - # See https://github.com/BerriAI/litellm/issues/27923 - if RouteChecks.is_info_route(route=route): + if route in MODEL_DISCOVERY_ROUTES: skip_budget_checks = True # 1. If team is blocked diff --git a/tests/proxy_unit_tests/test_info_route_budget_bypass.py b/tests/proxy_unit_tests/test_info_route_budget_bypass.py deleted file mode 100644 index bb0617f1b8d..00000000000 --- a/tests/proxy_unit_tests/test_info_route_budget_bypass.py +++ /dev/null @@ -1,195 +0,0 @@ -""" -Tests for budget bypass on model-discovery / info routes. - -Model-discovery and info endpoints (e.g. GET /v1/models, /models, /model/info) -perform no inference and incur no spend, so an exhausted budget must never block -them. Otherwise OpenAI-compatible clients (Open WebUI, Cursor, Aider, Continue, -LibreChat, ...) that call GET /v1/models for model discovery break entirely once -any team/key/org/user budget is exceeded. - -Regression test for https://github.com/BerriAI/litellm/issues/27923 -""" - -from unittest.mock import MagicMock, patch - -import pytest - -import litellm -from litellm.proxy._types import ( - LiteLLM_BudgetTable, - LiteLLM_EndUserTable, - LiteLLM_TeamTable, - LiteLLM_UserTable, - UserAPIKeyAuth, -) -from litellm.proxy.auth.auth_checks import common_checks -from litellm.proxy.utils import ProxyLogging -from litellm.router import Router - -# Routes that must never be blocked by a budget. Sourced from -# LiteLLMRoutes.info_routes (the same group RouteChecks.is_info_route checks). -MODEL_DISCOVERY_ROUTES = [ - "/v1/models", - "/models", - "/model/info", - "/v1/model/info", - "/v2/model/info", - "/model_group/info", -] - - -@pytest.fixture -def mock_router(): - """A router with a single paid (non-zero-cost) model.""" - return Router( - model_list=[ - { - "model_name": "cloud-model", - "litellm_params": { - "model": "gpt-3.5-turbo", - "api_key": "sk-test", - }, - "model_info": {"id": "cloud-model-id"}, - } - ] - ) - - -@pytest.fixture -def mock_proxy_logging(): - proxy_logging = ProxyLogging(user_api_key_cache=None) - - async def mock_budget_alerts(*args, **kwargs): - pass - - proxy_logging.budget_alerts = mock_budget_alerts - return proxy_logging - - -class TestTeamBudgetInfoRouteBypass: - @pytest.mark.asyncio - @pytest.mark.parametrize("route", MODEL_DISCOVERY_ROUTES) - async def test_team_over_budget_can_list_models( - self, route, mock_router, mock_proxy_logging - ): - """A team that is over budget can still reach model-discovery routes.""" - team_object = LiteLLM_TeamTable( - team_id="test-team", spend=150.0, max_budget=100.0 - ) - - result = await common_checks( - request_body={}, - team_object=team_object, - user_object=None, - end_user_object=None, - global_proxy_spend=None, - general_settings={}, - route=route, - llm_router=mock_router, - proxy_logging_obj=mock_proxy_logging, - valid_token=UserAPIKeyAuth(token="test-token", team_id="test-team"), - request=MagicMock(), - ) - assert result is True - - @pytest.mark.asyncio - async def test_team_over_budget_still_blocked_on_inference( - self, mock_router, mock_proxy_logging - ): - """Control: inference routes are still budget-enforced (no regression).""" - team_object = LiteLLM_TeamTable( - team_id="test-team", spend=150.0, max_budget=100.0 - ) - - with pytest.raises(litellm.BudgetExceededError): - await common_checks( - request_body={"model": "cloud-model"}, - team_object=team_object, - user_object=None, - end_user_object=None, - global_proxy_spend=None, - general_settings={}, - route="/v1/chat/completions", - llm_router=mock_router, - proxy_logging_obj=mock_proxy_logging, - valid_token=UserAPIKeyAuth(token="test-token", team_id="test-team"), - request=MagicMock(), - ) - - -class TestUserBudgetInfoRouteBypass: - @pytest.mark.asyncio - @pytest.mark.parametrize("route", MODEL_DISCOVERY_ROUTES) - async def test_user_over_budget_can_list_models( - self, route, mock_router, mock_proxy_logging - ): - user_object = LiteLLM_UserTable( - user_id="test-user", spend=100.0, max_budget=50.0 - ) - - result = await common_checks( - request_body={}, - team_object=None, - user_object=user_object, - end_user_object=None, - global_proxy_spend=None, - general_settings={}, - route=route, - llm_router=mock_router, - proxy_logging_obj=mock_proxy_logging, - valid_token=UserAPIKeyAuth(token="test-token", user_id="test-user"), - request=MagicMock(), - ) - assert result is True - - -class TestEndUserBudgetInfoRouteBypass: - @pytest.mark.asyncio - @pytest.mark.parametrize("route", MODEL_DISCOVERY_ROUTES) - async def test_end_user_over_budget_can_list_models( - self, route, mock_router, mock_proxy_logging - ): - end_user_object = LiteLLM_EndUserTable( - user_id="end-user-123", - spend=50.0, - litellm_budget_table=LiteLLM_BudgetTable(max_budget=20.0), - blocked=False, - ) - - result = await common_checks( - request_body={"user": "end-user-123"}, - team_object=None, - user_object=None, - end_user_object=end_user_object, - global_proxy_spend=None, - general_settings={}, - route=route, - llm_router=mock_router, - proxy_logging_obj=mock_proxy_logging, - valid_token=UserAPIKeyAuth(token="test-token"), - request=MagicMock(), - ) - assert result is True - - -class TestGlobalProxyBudgetInfoRouteBypass: - @pytest.mark.asyncio - @pytest.mark.parametrize("route", MODEL_DISCOVERY_ROUTES) - async def test_global_proxy_over_budget_can_list_models( - self, route, mock_router, mock_proxy_logging - ): - with patch.object(litellm, "max_budget", 100.0): - result = await common_checks( - request_body={}, - team_object=None, - user_object=None, - end_user_object=None, - global_proxy_spend=150.0, - general_settings={}, - route=route, - llm_router=mock_router, - proxy_logging_obj=mock_proxy_logging, - valid_token=UserAPIKeyAuth(token="test-token"), - request=MagicMock(), - ) - assert result is True diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 52e7a0ff373..6f6c4508333 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -1628,7 +1628,8 @@ async def test_reject_clientside_metadata_tags_non_llm_route(): @pytest.mark.asyncio async def test_reject_clientside_metadata_tags_allows_key_tags_without_client_tags(): """Key metadata.tags are injected after the reject check; requests without - client metadata.tags must not be blocked when reject_clientside_metadata_tags is on.""" + client metadata.tags must not be blocked when reject_clientside_metadata_tags is on. + """ from fastapi import Request from litellm.proxy.auth.auth_checks import common_checks @@ -3513,3 +3514,111 @@ async def test_cache_team_object_writes_team_id_and_invalidates_team_alias(): for c in cache2.async_set_cache.await_args_list ] assert written_keys_aliasless == ["team_id:team-no-alias"] + + +MODEL_DISCOVERY_ROUTES = [ + "/v1/models", + "/models", + "/model/info", + "/v1/model/info", + "/v2/model/info", + "/model_group/info", +] + + +@pytest.mark.parametrize("route", MODEL_DISCOVERY_ROUTES) +@pytest.mark.asyncio +async def test_model_discovery_route_bypasses_team_budget(route): + """Regression for #27923: an exhausted team budget must not block model-discovery routes, + otherwise OpenAI-compatible clients calling GET /v1/models at startup break.""" + from litellm.proxy.auth.auth_checks import common_checks + + team_object = LiteLLM_TeamTable(team_id="test-team", spend=150.0, max_budget=100.0) + + result = await common_checks( + request_body={}, + team_object=team_object, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route=route, + llm_router=None, + proxy_logging_obj=AsyncMock(), + valid_token=UserAPIKeyAuth(token="test-token", team_id="test-team"), + request=MagicMock(), + ) + + assert result is True + + +@pytest.mark.asyncio +async def test_model_discovery_route_bypasses_user_budget(): + """Regression for #27923: an exhausted user budget must not block model discovery.""" + from litellm.proxy.auth.auth_checks import common_checks + + user_object = LiteLLM_UserTable(user_id="test-user", spend=100.0, max_budget=50.0) + + result = await common_checks( + request_body={}, + team_object=None, + user_object=user_object, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route="/v1/models", + llm_router=None, + proxy_logging_obj=AsyncMock(), + valid_token=UserAPIKeyAuth(token="test-token", user_id="test-user"), + request=MagicMock(), + ) + + assert result is True + + +@pytest.mark.asyncio +async def test_side_effectful_info_route_still_enforces_budget(): + """#27923 keeps the bypass narrow: /health/services can fire Slack/email/webhook test + messages, so an exhausted budget must still block it. Widening the exemption back to + is_info_route() would regress this.""" + from litellm.proxy.auth.auth_checks import common_checks + + team_object = LiteLLM_TeamTable(team_id="test-team", spend=150.0, max_budget=100.0) + + with pytest.raises(litellm.BudgetExceededError): + await common_checks( + request_body={}, + team_object=team_object, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route="/health/services", + llm_router=None, + proxy_logging_obj=AsyncMock(), + valid_token=UserAPIKeyAuth(token="test-token", team_id="test-team"), + request=MagicMock(), + ) + + +@pytest.mark.asyncio +async def test_inference_route_still_enforces_team_budget(): + """Control for #27923: inference routes stay fully budget-enforced.""" + from litellm.proxy.auth.auth_checks import common_checks + + team_object = LiteLLM_TeamTable(team_id="test-team", spend=150.0, max_budget=100.0) + + with pytest.raises(litellm.BudgetExceededError): + await common_checks( + request_body={}, + team_object=team_object, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route="/v1/chat/completions", + llm_router=None, + proxy_logging_obj=AsyncMock(), + valid_token=UserAPIKeyAuth(token="test-token", team_id="test-team"), + request=MagicMock(), + )