mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
fix(proxy): split agent inference and management routes so admin nodes can create agents (#37730)
Agent registry CRUD (/v1/agents*) sat in agent_routes, which feeds llm_api_routes, so DISABLE_LLM_API_ENDPOINTS returned "LLM API routes are disabled for this instance." for every Admin UI Agents tab call. Split the group the same way MCP is split: agent_inference_routes stays on the data plane, agent_management_routes joins management_routes, and agent_routes remains their union for keys configured with allowed_routes=["agent_routes"]. Non-admin callers reached agent CRUD through llm_api_routes before, so the management paths also join self_managed_routes and the llm_api_routes virtual key carve-out; the handlers already scope reads by role and 403 non-admin writes. Both new groups are tuples, so check_route_access now takes a Sequence and matches wildcards through a generator instead of materializing an intermediate list on every call.
This commit is contained in:
parent
d8a57a1a2b
commit
bc52dd5c8b
5 changed files with 207 additions and 13 deletions
|
|
@ -515,15 +515,28 @@ class LiteLLMRoutes(enum.Enum):
|
|||
# allowed_routes=["mcp_routes"], which should cover both halves.
|
||||
mcp_routes = mcp_inference_routes + mcp_management_routes
|
||||
|
||||
agent_routes = [
|
||||
"/v1/agents",
|
||||
"/v1/agents/{agent_id}",
|
||||
# A2A agent invocation / discovery routes — data-plane. Gated by DISABLE_LLM_API_ENDPOINTS.
|
||||
agent_inference_routes = (
|
||||
"/agents",
|
||||
"/a2a/{agent_id}",
|
||||
"/a2a/{agent_id}/message/send",
|
||||
"/a2a/{agent_id}/message/stream",
|
||||
"/a2a/{agent_id}/.well-known/agent-card.json",
|
||||
]
|
||||
)
|
||||
|
||||
# Agent registry CRUD routes — control-plane. Gated by DISABLE_ADMIN_ENDPOINTS.
|
||||
# The handlers in agent_endpoints/endpoints.py enforce proxy-admin on writes and
|
||||
# scope reads by role, so these also appear in self_managed_routes.
|
||||
agent_management_routes = (
|
||||
"/v1/agents",
|
||||
"/v1/agents/{agent_id}",
|
||||
"/v1/agents/make_public",
|
||||
"/v1/agents/{agent_id}/make_public",
|
||||
)
|
||||
|
||||
# Backwards-compat union — virtual keys may be configured with
|
||||
# allowed_routes=["agent_routes"], which should cover both halves.
|
||||
agent_routes = agent_inference_routes + agent_management_routes
|
||||
|
||||
google_routes = [
|
||||
"/v1beta/models/{model_name:path}:countTokens",
|
||||
|
|
@ -563,7 +576,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
+ apply_guardrail_routes
|
||||
+ mcp_inference_routes
|
||||
+ litellm_native_routes
|
||||
+ agent_routes
|
||||
+ list(agent_inference_routes)
|
||||
+ model_info_routes
|
||||
)
|
||||
info_routes = [
|
||||
|
|
@ -664,6 +677,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
]
|
||||
+ key_management_routes
|
||||
+ mcp_management_routes
|
||||
+ list(agent_management_routes)
|
||||
)
|
||||
|
||||
spend_tracking_routes = [
|
||||
|
|
@ -836,6 +850,9 @@ class LiteLLMRoutes(enum.Enum):
|
|||
# proxy admin, or team admin naming their own team via team_id
|
||||
"/auto_router/test_routing",
|
||||
"/auto_router/validate_complexity_router_config",
|
||||
# Agent registry - reads are role-scoped and writes are proxy-admin-gated
|
||||
# inside agent_endpoints/endpoints.py
|
||||
*agent_management_routes,
|
||||
] # routes that manage their own allowed/disallowed logic
|
||||
|
||||
## Org Admin Routes ##
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import re
|
||||
from collections.abc import Sequence
|
||||
from typing import Final
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
|
@ -165,6 +166,19 @@ class RouteChecks:
|
|||
if RouteChecks._is_get_mcp_server_discovery_route(route=route, request=request):
|
||||
return True
|
||||
|
||||
# Agent registry CRUD moved from llm_api_routes into
|
||||
# management_routes so DISABLE_LLM_API_ENDPOINTS stops
|
||||
# blocking it. Keys configured with
|
||||
# allowed_routes=["llm_api_routes"] before that split
|
||||
# could reach these paths, so keep them reachable here;
|
||||
# the handlers in agent_endpoints/endpoints.py still
|
||||
# enforce proxy-admin on writes and scope reads by role.
|
||||
if RouteChecks.check_route_access(
|
||||
route=route,
|
||||
allowed_routes=LiteLLMRoutes.agent_management_routes.value,
|
||||
):
|
||||
return True
|
||||
|
||||
# check if wildcard pattern is allowed
|
||||
for allowed_route in valid_token.allowed_routes:
|
||||
if RouteChecks._route_matches_wildcard_pattern(route=route, pattern=allowed_route):
|
||||
|
|
@ -367,7 +381,7 @@ class RouteChecks:
|
|||
if RouteChecks.check_route_access(route=route, allowed_routes=LiteLLMRoutes.mcp_inference_routes.value):
|
||||
return True
|
||||
|
||||
if RouteChecks.check_route_access(route=route, allowed_routes=LiteLLMRoutes.agent_routes.value):
|
||||
if RouteChecks.check_route_access(route=route, allowed_routes=LiteLLMRoutes.agent_inference_routes.value):
|
||||
return True
|
||||
|
||||
if route in LiteLLMRoutes.litellm_native_routes.value:
|
||||
|
|
@ -558,13 +572,13 @@ class RouteChecks:
|
|||
return False
|
||||
|
||||
@staticmethod
|
||||
def check_route_access(route: str, allowed_routes: list[str]) -> bool:
|
||||
def check_route_access(route: str, allowed_routes: Sequence[str]) -> bool:
|
||||
"""
|
||||
Check if a route has access by checking both exact matches and patterns
|
||||
|
||||
Args:
|
||||
route (str): The route to check
|
||||
allowed_routes (list): List of allowed routes/patterns
|
||||
allowed_routes (Sequence): Allowed routes/patterns
|
||||
|
||||
Returns:
|
||||
bool: True if route is allowed, False otherwise
|
||||
|
|
@ -579,10 +593,12 @@ class RouteChecks:
|
|||
# wildcard match route is in allowed_routes
|
||||
# e.g calling /anthropic/v1/messages is allowed if allowed_routes has /anthropic/*
|
||||
#########################################################
|
||||
wildcard_allowed_routes = [route for route in allowed_routes if RouteChecks._is_wildcard_pattern(pattern=route)]
|
||||
for allowed_route in wildcard_allowed_routes:
|
||||
if RouteChecks._route_matches_wildcard_pattern(route=route, pattern=allowed_route):
|
||||
return True
|
||||
if any(
|
||||
RouteChecks._route_matches_wildcard_pattern(route=route, pattern=allowed_route)
|
||||
for allowed_route in allowed_routes
|
||||
if RouteChecks._is_wildcard_pattern(pattern=allowed_route)
|
||||
):
|
||||
return True
|
||||
|
||||
#########################################################
|
||||
# pattern match route is in allowed_routes
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@
|
|||
"limit": 176
|
||||
},
|
||||
"RUF012": {
|
||||
"limit": 241
|
||||
"limit": 240
|
||||
},
|
||||
"RUF015": {
|
||||
"limit": 8
|
||||
|
|
|
|||
|
|
@ -373,3 +373,63 @@ class TestEnterpriseRouteChecksErrorMessages:
|
|||
# Should not raise exception for premium users
|
||||
result = EnterpriseRouteChecks.is_management_routes_disabled()
|
||||
assert result is True
|
||||
|
||||
|
||||
@patch("litellm.proxy.proxy_server.premium_user", True)
|
||||
class TestEnterpriseRouteChecksAgentManagement:
|
||||
"""Regression tests for LIT-2069: the Admin UI Agents tab could not create an
|
||||
external agent on nodes with DISABLE_LLM_API_ENDPOINTS set, because agent
|
||||
registry CRUD (/v1/agents*) was classified as an LLM API route. It is now a
|
||||
management route, so DISABLE_ADMIN_ENDPOINTS gates it instead. Uses the real
|
||||
is_llm_api_route / is_management_route classifiers (not mocks)."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"route",
|
||||
[
|
||||
"/v1/agents",
|
||||
"/v1/agents/abc-123",
|
||||
"/v1/agents/make_public",
|
||||
"/v1/agents/abc-123/make_public",
|
||||
],
|
||||
)
|
||||
def test_agent_management_allowed_when_llm_api_disabled(self, route):
|
||||
with patch.dict(os.environ, {"DISABLE_LLM_API_ENDPOINTS": "true"}, clear=False):
|
||||
os.environ.pop("DISABLE_ADMIN_ENDPOINTS", None)
|
||||
# Should not raise - agent CRUD is a management route, not llm_api.
|
||||
EnterpriseRouteChecks.should_call_route(route)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"route",
|
||||
[
|
||||
"/v1/agents",
|
||||
"/v1/agents/abc-123",
|
||||
],
|
||||
)
|
||||
def test_agent_management_blocked_when_admin_disabled(self, route):
|
||||
with patch.dict(os.environ, {"DISABLE_ADMIN_ENDPOINTS": "true"}, clear=False):
|
||||
os.environ.pop("DISABLE_LLM_API_ENDPOINTS", None)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
EnterpriseRouteChecks.should_call_route(route)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "Management routes are disabled for this instance." in str(
|
||||
exc_info.value.detail
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"route",
|
||||
[
|
||||
"/a2a/abc-123/message/send",
|
||||
"/a2a/abc-123/message/stream",
|
||||
],
|
||||
)
|
||||
def test_agent_inference_still_blocked_when_llm_api_disabled(self, route):
|
||||
with patch.dict(os.environ, {"DISABLE_LLM_API_ENDPOINTS": "true"}, clear=False):
|
||||
os.environ.pop("DISABLE_ADMIN_ENDPOINTS", None)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
EnterpriseRouteChecks.should_call_route(route)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "LLM API routes are disabled for this instance." in str(
|
||||
exc_info.value.detail
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3428,3 +3428,104 @@ def test_auto_router_dry_runs_share_model_new_audience(user_role, dry_run_route)
|
|||
# Anchor so parity cannot be satisfied by both routes 403ing for everyone
|
||||
if user_role == LitellmUserRoles.INTERNAL_USER.value:
|
||||
assert outcome(dry_run_route) == "allowed"
|
||||
|
||||
|
||||
AGENT_MANAGEMENT_ROUTES = [
|
||||
"/v1/agents",
|
||||
"/v1/agents/abc-123",
|
||||
"/v1/agents/make_public",
|
||||
"/v1/agents/abc-123/make_public",
|
||||
]
|
||||
|
||||
AGENT_INFERENCE_ROUTES = [
|
||||
"/a2a/abc-123",
|
||||
"/a2a/abc-123/message/send",
|
||||
"/a2a/abc-123/message/stream",
|
||||
"/a2a/abc-123/.well-known/agent-card.json",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", AGENT_MANAGEMENT_ROUTES)
|
||||
def test_agent_management_routes_classified_as_management_not_llm_api(route):
|
||||
"""Agent registry CRUD must be management routes, not llm_api routes.
|
||||
|
||||
Regression for the Admin UI Agents tab failing with "LLM API routes are
|
||||
disabled for this instance." on admin nodes that set
|
||||
DISABLE_LLM_API_ENDPOINTS.
|
||||
"""
|
||||
|
||||
assert RouteChecks.is_llm_api_route(route=route) is False
|
||||
assert RouteChecks.is_management_route(route=route) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", AGENT_INFERENCE_ROUTES)
|
||||
def test_agent_inference_routes_stay_llm_api(route):
|
||||
"""A2A invocation stays on the data plane, gated by DISABLE_LLM_API_ENDPOINTS."""
|
||||
|
||||
assert RouteChecks.is_llm_api_route(route=route) is True
|
||||
assert RouteChecks.is_management_route(route=route) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", AGENT_MANAGEMENT_ROUTES + AGENT_INFERENCE_ROUTES)
|
||||
def test_agent_routes_union_still_covers_both_halves(route):
|
||||
"""Keys configured with allowed_routes=["agent_routes"] must keep both halves."""
|
||||
|
||||
assert (
|
||||
RouteChecks.check_route_access(
|
||||
route=route, allowed_routes=LiteLLMRoutes.agent_routes.value
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", AGENT_MANAGEMENT_ROUTES)
|
||||
@pytest.mark.parametrize("method", ["GET", "POST", "DELETE"])
|
||||
def test_virtual_key_llm_api_routes_allows_agent_registry(route, method):
|
||||
"""Keys with allowed_routes=["llm_api_routes"] could reach agent CRUD before the
|
||||
inference/management split and must still reach it after.
|
||||
|
||||
Writes remain proxy-admin-only inside agent_endpoints/endpoints.py, so this
|
||||
carve-out is not method-aware.
|
||||
"""
|
||||
|
||||
valid_token = UserAPIKeyAuth(user_id="test_user", allowed_routes=["llm_api_routes"])
|
||||
|
||||
assert (
|
||||
RouteChecks.is_virtual_key_allowed_to_call_route(
|
||||
route=route,
|
||||
valid_token=valid_token,
|
||||
request=_mock_request(method),
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user_role",
|
||||
[
|
||||
LitellmUserRoles.INTERNAL_USER.value,
|
||||
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
|
||||
None,
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("method, route", [("GET", "/v1/agents"), ("POST", "/v1/agents")])
|
||||
def test_agent_registry_route_gate_open_to_non_admin_roles(user_role, method, route):
|
||||
"""Non-admin callers reached agent CRUD through llm_api_routes before the split.
|
||||
|
||||
The route gate must keep letting them through so the handlers can scope the
|
||||
listing by role and 403 non-admin writes themselves.
|
||||
"""
|
||||
|
||||
valid_token = UserAPIKeyAuth(user_id="test_user", user_role=user_role)
|
||||
request = MagicMock(spec=Request)
|
||||
request.method = method
|
||||
request.query_params = {}
|
||||
|
||||
RouteChecks.non_proxy_admin_allowed_routes_check(
|
||||
user_obj=LiteLLM_UserTable(user_id="test_user", user_role=user_role),
|
||||
_user_role=user_role,
|
||||
route=route,
|
||||
request=request,
|
||||
valid_token=valid_token,
|
||||
request_data={},
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue