diff --git a/enterprise/litellm_enterprise/proxy/auth/route_checks.py b/enterprise/litellm_enterprise/proxy/auth/route_checks.py index 6f7cf9143f4..fc57292a8d2 100644 --- a/enterprise/litellm_enterprise/proxy/auth/route_checks.py +++ b/enterprise/litellm_enterprise/proxy/auth/route_checks.py @@ -41,6 +41,10 @@ class EnterpriseRouteChecks: return get_secret_bool("DISABLE_ADMIN_ENDPOINTS") is True + # Routes that should remain accessible even when LLM API endpoints are disabled. + # These are read-only model listing routes needed by the Admin UI. + LLM_API_EXEMPT_ROUTES = ["/models", "/v1/models"] + @staticmethod def should_call_route(route: str): """ @@ -58,6 +62,7 @@ class EnterpriseRouteChecks: ) elif ( RouteChecks.is_llm_api_route(route=route) + and route not in EnterpriseRouteChecks.LLM_API_EXEMPT_ROUTES and EnterpriseRouteChecks.is_llm_api_route_disabled() ): raise HTTPException( 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 706e3b71870..24e27977963 100644 --- a/tests/enterprise/litellm_enterprise/proxy/auth/test_route_checks.py +++ b/tests/enterprise/litellm_enterprise/proxy/auth/test_route_checks.py @@ -182,6 +182,76 @@ class TestEnterpriseRouteChecks: EnterpriseRouteChecks.should_call_route("/config/update") +@patch("litellm.proxy.proxy_server.premium_user", True) +class TestEnterpriseRouteChecksModelListExemption: + """Test that /models and /v1/models are exempt from DISABLE_LLM_API_ENDPOINTS""" + + @patch.object(EnterpriseRouteChecks, "is_llm_api_route_disabled") + @patch.object(EnterpriseRouteChecks, "is_management_routes_disabled") + @patch("litellm.proxy.auth.route_checks.RouteChecks.is_llm_api_route") + @patch("litellm.proxy.auth.route_checks.RouteChecks.is_management_route") + def test_models_route_allowed_when_llm_api_disabled( + self, + mock_is_management_route, + mock_is_llm_api_route, + mock_is_management_disabled, + mock_is_llm_api_disabled, + ): + """Test that /models is allowed even when LLM API routes are disabled""" + mock_is_management_route.return_value = False + mock_is_llm_api_route.return_value = True + mock_is_management_disabled.return_value = False + mock_is_llm_api_disabled.return_value = True + + # Should not raise exception for /models + EnterpriseRouteChecks.should_call_route("/models") + + @patch.object(EnterpriseRouteChecks, "is_llm_api_route_disabled") + @patch.object(EnterpriseRouteChecks, "is_management_routes_disabled") + @patch("litellm.proxy.auth.route_checks.RouteChecks.is_llm_api_route") + @patch("litellm.proxy.auth.route_checks.RouteChecks.is_management_route") + def test_v1_models_route_allowed_when_llm_api_disabled( + self, + mock_is_management_route, + mock_is_llm_api_route, + mock_is_management_disabled, + mock_is_llm_api_disabled, + ): + """Test that /v1/models is allowed even when LLM API routes are disabled""" + mock_is_management_route.return_value = False + mock_is_llm_api_route.return_value = True + mock_is_management_disabled.return_value = False + mock_is_llm_api_disabled.return_value = True + + # Should not raise exception for /v1/models + EnterpriseRouteChecks.should_call_route("/v1/models") + + @patch.object(EnterpriseRouteChecks, "is_llm_api_route_disabled") + @patch.object(EnterpriseRouteChecks, "is_management_routes_disabled") + @patch("litellm.proxy.auth.route_checks.RouteChecks.is_llm_api_route") + @patch("litellm.proxy.auth.route_checks.RouteChecks.is_management_route") + def test_chat_completions_still_blocked_when_llm_api_disabled( + self, + mock_is_management_route, + mock_is_llm_api_route, + mock_is_management_disabled, + mock_is_llm_api_disabled, + ): + """Test that non-exempt LLM routes like /v1/chat/completions are still blocked""" + mock_is_management_route.return_value = False + mock_is_llm_api_route.return_value = True + mock_is_management_disabled.return_value = False + mock_is_llm_api_disabled.return_value = True + + with pytest.raises(HTTPException) as exc_info: + EnterpriseRouteChecks.should_call_route("/v1/chat/completions") + + assert exc_info.value.status_code == 403 + assert "LLM API routes are disabled for this instance." in str( + exc_info.value.detail + ) + + class TestEnterpriseRouteChecksErrorMessages: """Test that error messages correctly identify which feature requires Enterprise license""" diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index a745ac3de13..20c07ac2128 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -906,6 +906,109 @@ def test_proxy_admin_viewer_can_access_global_spend_tags(): ) +class TestModelsRouteExemptFromDisableLLMEndpoints: + """ + Test that /models and /v1/models are exempt from DISABLE_LLM_API_ENDPOINTS. + + When DISABLE_LLM_API_ENDPOINTS is set, inference routes like /v1/chat/completions + should be blocked, but /models and /v1/models should remain accessible because + they are read-only model listing routes needed by the Admin UI. + + Relevant issue: https://github.com/BerriAI/litellm/issues/new (UI breaks with DISABLE_LLM_ENDPOINTS) + """ + + def _get_enterprise_route_checks(self): + """Import EnterpriseRouteChecks from the local enterprise source file.""" + import importlib.util + + local_file = os.path.join( + os.path.dirname(__file__), + "..", "..", "..", "..", "enterprise", + "litellm_enterprise", "proxy", "auth", "route_checks.py", + ) + local_file = os.path.abspath(local_file) + + spec = importlib.util.spec_from_file_location( + "local_enterprise_route_checks", local_file + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod.EnterpriseRouteChecks + + @patch("litellm.proxy.proxy_server.premium_user", True) + def test_should_models_route_allowed_when_llm_api_disabled(self): + """Test that /models is allowed even when LLM API routes are disabled""" + EnterpriseRouteChecks = self._get_enterprise_route_checks() + + with patch.object( + EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True + ), patch.object( + EnterpriseRouteChecks, "is_management_routes_disabled", return_value=False + ): + # /models should NOT raise - it's exempt + EnterpriseRouteChecks.should_call_route("/models") + + @patch("litellm.proxy.proxy_server.premium_user", True) + def test_should_v1_models_route_allowed_when_llm_api_disabled(self): + """Test that /v1/models is allowed even when LLM API routes are disabled""" + EnterpriseRouteChecks = self._get_enterprise_route_checks() + + with patch.object( + EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True + ), patch.object( + EnterpriseRouteChecks, "is_management_routes_disabled", return_value=False + ): + # /v1/models should NOT raise - it's exempt + EnterpriseRouteChecks.should_call_route("/v1/models") + + @patch("litellm.proxy.proxy_server.premium_user", True) + def test_should_chat_completions_still_blocked_when_llm_api_disabled(self): + """Test that non-exempt LLM routes like /v1/chat/completions are still blocked""" + EnterpriseRouteChecks = self._get_enterprise_route_checks() + + with patch.object( + EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True + ), patch.object( + EnterpriseRouteChecks, "is_management_routes_disabled", return_value=False + ): + with pytest.raises(HTTPException) as exc_info: + EnterpriseRouteChecks.should_call_route("/v1/chat/completions") + + assert exc_info.value.status_code == 403 + assert "LLM API routes are disabled for this instance." in str( + exc_info.value.detail + ) + + @patch("litellm.proxy.proxy_server.premium_user", True) + def test_should_embeddings_still_blocked_when_llm_api_disabled(self): + """Test that /v1/embeddings is still blocked when LLM API routes are disabled""" + EnterpriseRouteChecks = self._get_enterprise_route_checks() + + with patch.object( + EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True + ), patch.object( + EnterpriseRouteChecks, "is_management_routes_disabled", return_value=False + ): + with pytest.raises(HTTPException) as exc_info: + EnterpriseRouteChecks.should_call_route("/v1/embeddings") + + assert exc_info.value.status_code == 403 + + @patch("litellm.proxy.proxy_server.premium_user", True) + def test_should_models_route_allowed_when_llm_api_not_disabled(self): + """Test that /models works normally when LLM API routes are not disabled""" + EnterpriseRouteChecks = self._get_enterprise_route_checks() + + with patch.object( + EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=False + ), patch.object( + EnterpriseRouteChecks, "is_management_routes_disabled", return_value=False + ): + # Should not raise + EnterpriseRouteChecks.should_call_route("/models") + EnterpriseRouteChecks.should_call_route("/v1/models") + + def test_route_in_additional_public_routes_wildcard_match(): """ Test that route_in_additonal_public_routes supports wildcard patterns.