Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions litellm/proxy/auth/auth_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -534,6 +548,9 @@ async def common_checks( # noqa: PLR0915
request_query_params=_safe_get_request_query_params(request=request),
)

if route in MODEL_DISCOVERY_ROUTES:
skip_budget_checks = True

# 1. If team is blocked
if team_object is not None and team_object.blocked is True:
raise Exception(
Expand Down
111 changes: 110 additions & 1 deletion tests/test_litellm/proxy/auth/test_auth_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
)
Loading