From 7d6ee2a9ca04ce03165f02d063237d6710333432 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 1 Aug 2026 11:43:36 -0700 Subject: [PATCH 1/2] feat(proxy): let AI API keys read /model/info Keys created with key_type=llm_api get allowed_routes=["llm_api_routes"], which covered /v1/models but not /v1/model/info, so a client could list model names but not read pricing, mode, or max_tokens without a second key. Adds both /model/info and /v1/model/info (same handler) to llm_api_routes only. Membership there is not the same as RouteChecks.is_llm_api_route(), which is what gates DISABLE_LLM_API_ENDPOINTS, global/virtual-key budget enforcement, enforce_user_param and JWT team attachment; /guardrails/apply_guardrail already sits in the group the same way. /v2/model/info stays out: it is the paginated Admin UI listing, not model metadata a caller needs at request time. public_routes moves from set([...]) to a frozenset literal to keep the LIT002 and ruff-strict ceilings from rising; both budgets ratchet down by one. --- litellm/proxy/_types.py | 12 +++-- ruff-strict-budget.json | 2 +- .../proxy/auth/test_route_checks.py | 54 +++++++++++++++++++ type-discipline-budget.json | 2 +- 4 files changed, 65 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 23fe7af9994..e8355f4941a 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -517,6 +517,11 @@ class LiteLLMRoutes(enum.Enum): "/guardrails/apply_guardrail", ] + model_info_routes = [ + "/model/info", + "/v1/model/info", + ] + llm_api_routes = ( openai_routes + anthropic_routes @@ -527,6 +532,7 @@ class LiteLLMRoutes(enum.Enum): + mcp_inference_routes + litellm_native_routes + agent_routes + + model_info_routes ) info_routes = [ "/key/info", @@ -653,8 +659,8 @@ class LiteLLMRoutes(enum.Enum): "/global/spend/all_tag_names", ] - public_routes = set( - [ + public_routes = frozenset( + ( "/routes", "/", "/health/liveliness", @@ -669,7 +675,7 @@ class LiteLLMRoutes(enum.Enum): "/public/mcp_hub", "/public/skill_hub", "/public/litellm_model_cost_map", - ] + ) ) # Retained for backwards compatibility with JWT auth configs that reference diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json index b8650eea7aa..57f69dfffe7 100644 --- a/ruff-strict-budget.json +++ b/ruff-strict-budget.json @@ -69,7 +69,7 @@ "limit": 4 }, "C405": { - "limit": 23 + "limit": 22 }, "C408": { "limit": 14 diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index 06764139eda..1426876783b 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -445,6 +445,60 @@ def test_virtual_key_llm_api_routes_allows_mcp_inference_endpoints(route, method assert result is True +@pytest.mark.parametrize("route", ["/model/info", "/v1/model/info"]) +def test_virtual_key_llm_api_routes_allows_model_info(route): + """AI API virtual keys must be able to read model metadata (pricing, mode, + max_tokens) for the deployments they can already route to. Both the + unversioned and /v1 paths are the same handler, so both must be reachable. + """ + + valid_token = UserAPIKeyAuth( + user_id="test_user", + allowed_routes=["llm_api_routes"], + ) + + result = RouteChecks.is_virtual_key_allowed_to_call_route( + route=route, + valid_token=valid_token, + request=_mock_request("GET"), + ) + + assert result is True + + +@pytest.mark.parametrize("route", ["/model/info", "/v1/model/info"]) +def test_model_info_not_classified_as_llm_api(route): + """Membership in `llm_api_routes` must not promote /model/info to an + `is_llm_api_route()`. That predicate gates DISABLE_LLM_API_ENDPOINTS, + global/virtual-key budget enforcement, enforce_user_param and the JWT + x-litellm-team-id attachment; model metadata is a free read and must stay + outside all of them. + """ + + assert RouteChecks.is_llm_api_route(route=route) is False + + +@pytest.mark.parametrize("route", ["/v2/model/info", "/model_group/info"]) +def test_virtual_key_llm_api_routes_denies_other_model_info_routes(route): + """The grant is scoped to the two /model/info paths. The paginated Admin UI + listing and the model-group endpoint stay outside it. + """ + + valid_token = UserAPIKeyAuth( + user_id="test_user", + allowed_routes=["llm_api_routes"], + ) + + with pytest.raises(HTTPException) as exc_info: + RouteChecks.is_virtual_key_allowed_to_call_route( + route=route, + valid_token=valid_token, + request=_mock_request("GET"), + ) + + assert exc_info.value.status_code == 403 + + def test_spend_logs_v2_classified_as_management_not_llm_api(): """Paginated spend logs are a management/spend read route, not an LLM API.""" diff --git a/type-discipline-budget.json b/type-discipline-budget.json index ff037a2872e..b790d9acb0f 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -3,7 +3,7 @@ "limit": 23191 }, "LIT002": { - "limit": 27276 + "limit": 27275 }, "LIT003": { "limit": 292 From c541fb2b7a7b29af5d08e6d609daebe8ba3d5cca Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 1 Aug 2026 12:16:27 -0700 Subject: [PATCH 2/2] chore(proxy): remove duplicate Sequence import in team endpoints --- litellm/proxy/management_endpoints/team_endpoints.py | 1 - 1 file changed, 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 1d4cb277c2e..1efd0a747df 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -22,7 +22,6 @@ from typing import ( Mapping, Optional, Protocol, - Sequence, Tuple, TypeVar, Union,