From bc52dd5c8b51e822dcdcce6f22fdcc10834971a5 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 20 Aug 2026 16:03:15 -0700 Subject: [PATCH] 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. --- litellm/proxy/_types.py | 27 ++++- litellm/proxy/auth/route_checks.py | 30 ++++-- ruff-strict-budget.json | 2 +- .../proxy/auth/test_route_checks.py | 60 +++++++++++ .../proxy/auth/test_route_checks.py | 101 ++++++++++++++++++ 5 files changed, 207 insertions(+), 13 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 8856e68698a..6b471ed795b 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -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 ## diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index 04eb7ab326b..cea21ca088b 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -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 diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json index 315a76b7fda..a990f7c3830 100644 --- a/ruff-strict-budget.json +++ b/ruff-strict-budget.json @@ -174,7 +174,7 @@ "limit": 176 }, "RUF012": { - "limit": 241 + "limit": 240 }, "RUF015": { "limit": 8 diff --git a/tests/enterprise/litellm_enterprise/proxy/auth/test_route_checks.py b/tests/enterprise/litellm_enterprise/proxy/auth/test_route_checks.py index c147c7aae91..f90ac9abb7d 100644 --- a/tests/enterprise/litellm_enterprise/proxy/auth/test_route_checks.py +++ b/tests/enterprise/litellm_enterprise/proxy/auth/test_route_checks.py @@ -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 + ) diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index aa9e5349c87..2f4c2d3870f 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -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={}, + )