mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
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:
parent
2b3070890a
commit
7d6ee2a9ca
4 changed files with 65 additions and 5 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@
|
||||||
"limit": 4
|
"limit": 4
|
||||||
},
|
},
|
||||||
"C405": {
|
"C405": {
|
||||||
"limit": 23
|
"limit": 22
|
||||||
},
|
},
|
||||||
"C408": {
|
"C408": {
|
||||||
"limit": 14
|
"limit": 14
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
"limit": 23191
|
"limit": 23191
|
||||||
},
|
},
|
||||||
"LIT002": {
|
"LIT002": {
|
||||||
"limit": 27276
|
"limit": 27275
|
||||||
},
|
},
|
||||||
"LIT003": {
|
"LIT003": {
|
||||||
"limit": 292
|
"limit": 292
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue