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:
ryan-crabbe-berri 2026-08-20 16:03:15 -07:00 committed by GitHub
parent d8a57a1a2b
commit bc52dd5c8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 207 additions and 13 deletions

View file

@ -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 ##

View file

@ -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

View file

@ -174,7 +174,7 @@
"limit": 176
},
"RUF012": {
"limit": 241
"limit": 240
},
"RUF015": {
"limit": 8

View file

@ -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
)

View file

@ -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={},
)