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.
This commit is contained in:
Yuneng Jiang 2026-08-01 11:43:36 -07:00
parent 2b3070890a
commit 7d6ee2a9ca
No known key found for this signature in database
4 changed files with 65 additions and 5 deletions

View file

@ -517,6 +517,11 @@ class LiteLLMRoutes(enum.Enum):
"/guardrails/apply_guardrail", "/guardrails/apply_guardrail",
] ]
model_info_routes = [
"/model/info",
"/v1/model/info",
]
llm_api_routes = ( llm_api_routes = (
openai_routes openai_routes
+ anthropic_routes + anthropic_routes
@ -527,6 +532,7 @@ class LiteLLMRoutes(enum.Enum):
+ mcp_inference_routes + mcp_inference_routes
+ litellm_native_routes + litellm_native_routes
+ agent_routes + agent_routes
+ model_info_routes
) )
info_routes = [ info_routes = [
"/key/info", "/key/info",
@ -653,8 +659,8 @@ class LiteLLMRoutes(enum.Enum):
"/global/spend/all_tag_names", "/global/spend/all_tag_names",
] ]
public_routes = set( public_routes = frozenset(
[ (
"/routes", "/routes",
"/", "/",
"/health/liveliness", "/health/liveliness",
@ -669,7 +675,7 @@ class LiteLLMRoutes(enum.Enum):
"/public/mcp_hub", "/public/mcp_hub",
"/public/skill_hub", "/public/skill_hub",
"/public/litellm_model_cost_map", "/public/litellm_model_cost_map",
] )
) )
# Retained for backwards compatibility with JWT auth configs that reference # Retained for backwards compatibility with JWT auth configs that reference

View file

@ -69,7 +69,7 @@
"limit": 4 "limit": 4
}, },
"C405": { "C405": {
"limit": 23 "limit": 22
}, },
"C408": { "C408": {
"limit": 14 "limit": 14

View file

@ -445,6 +445,60 @@ def test_virtual_key_llm_api_routes_allows_mcp_inference_endpoints(route, method
assert result is True 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(): def test_spend_logs_v2_classified_as_management_not_llm_api():
"""Paginated spend logs are a management/spend read route, not an LLM API.""" """Paginated spend logs are a management/spend read route, not an LLM API."""

View file

@ -3,7 +3,7 @@
"limit": 23191 "limit": 23191
}, },
"LIT002": { "LIT002": {
"limit": 27276 "limit": 27275
}, },
"LIT003": { "LIT003": {
"limit": 292 "limit": 292