diff --git a/litellm/proxy/agent_endpoints/endpoints.py b/litellm/proxy/agent_endpoints/endpoints.py index 80c55f634f7..eef969951a0 100644 --- a/litellm/proxy/agent_endpoints/endpoints.py +++ b/litellm/proxy/agent_endpoints/endpoints.py @@ -8,12 +8,15 @@ Follows the A2A Spec. 3. Get specific agent via GET `/v1/agents/{agent_id}` """ -from typing import Any, List, Optional +import asyncio +import os +from typing import Any, Dict, List, Optional -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request import litellm from litellm._logging import verbose_proxy_logger +from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils.rbac_utils import check_feature_access_for_user @@ -25,6 +28,7 @@ from litellm.types.agents import ( MakeAgentsPublicRequest, PatchAgentRequest, ) +from litellm.types.llms.custom_http import httpxSpecialProvider from litellm.types.proxy.management_endpoints.common_daily_activity import ( SpendAnalyticsPaginatedResponse, ) @@ -49,6 +53,48 @@ def _check_agent_management_permission(user_api_key_dict: UserAPIKeyAuth) -> Non ) +AGENT_HEALTH_CHECK_TIMEOUT_SECONDS = float( + os.environ.get("LITELLM_AGENT_HEALTH_CHECK_TIMEOUT", "5.0") +) +AGENT_HEALTH_CHECK_GATHER_TIMEOUT_SECONDS = float( + os.environ.get("LITELLM_AGENT_HEALTH_CHECK_GATHER_TIMEOUT", "30.0") +) + + +async def _check_agent_url_health( + agent: AgentResponse, +) -> Dict[str, Any]: + """ + Perform a GET request against the agent's URL and return the health result. + + Returns a dict with ``agent_id``, ``healthy`` (bool), and an optional + ``error`` message. + """ + url = (agent.agent_card_params or {}).get("url") + if not url: + return {"agent_id": agent.agent_id, "healthy": True} + + try: + client = get_async_httpx_client( + llm_provider=httpxSpecialProvider.AgentHealthCheck, + params={"timeout": AGENT_HEALTH_CHECK_TIMEOUT_SECONDS}, + ) + response = await client.get(url) + if response.status_code >= 500: + return { + "agent_id": agent.agent_id, + "healthy": False, + "error": f"HTTP {response.status_code}", + } + return {"agent_id": agent.agent_id, "healthy": True} + except Exception as exc: + return { + "agent_id": agent.agent_id, + "healthy": False, + "error": str(exc), + } + + @router.get( "/v1/agents", tags=["[beta] A2A Agents"], @@ -57,6 +103,9 @@ def _check_agent_management_permission(user_api_key_dict: UserAPIKeyAuth) -> Non ) async def get_agents( request: Request, + health_check: bool = Query( + False, + description="When true, performs a GET request to each agent's URL. Agents with reachable URLs (HTTP status < 500) and agents without a URL are returned; unreachable agents are filtered out.", user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # Used for auth ): """ @@ -67,6 +116,13 @@ async def get_agents( -H "Authorization: Bearer your-key" \ ``` + Pass `?health_check=true` to filter out agents whose URL is unreachable: + ``` + curl -X GET "http://localhost:4000/v1/agents?health_check=true" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer your-key" \ + ``` + Returns: List[AgentResponse] """ @@ -112,6 +168,44 @@ async def get_agents( and (agent.agent_id in litellm.public_agent_groups) ) + if health_check: + agents_with_url = [ + agent + for agent in returned_agents + if (agent.agent_card_params or {}).get("url") + ] + agents_without_url = [ + agent + for agent in returned_agents + if not (agent.agent_card_params or {}).get("url") + ] + try: + health_results = await asyncio.wait_for( + asyncio.gather( + *[_check_agent_url_health(agent) for agent in agents_with_url] + ), + timeout=AGENT_HEALTH_CHECK_GATHER_TIMEOUT_SECONDS, + ) + except asyncio.TimeoutError: + verbose_proxy_logger.warning( + "Agent health check gather timed out after %s seconds", + AGENT_HEALTH_CHECK_GATHER_TIMEOUT_SECONDS, + ) + health_results = [ + {"agent_id": agent.agent_id, "healthy": False, "error": "Health check timed out"} + for agent in agents_with_url + ] + healthy_ids = { + result["agent_id"] + for result in health_results + if result["healthy"] + } + returned_agents = [ + agent + for agent in agents_with_url + if agent.agent_id in healthy_ids + ] + agents_without_url + return returned_agents except HTTPException: raise diff --git a/litellm/types/llms/custom_http.py b/litellm/types/llms/custom_http.py index 8f192d876c4..792adb4182d 100644 --- a/litellm/types/llms/custom_http.py +++ b/litellm/types/llms/custom_http.py @@ -25,6 +25,7 @@ class httpxSpecialProvider(str, Enum): MCP = "mcp" RAG = "rag" A2AProvider = "a2a_provider" + AgentHealthCheck = "agent_health_check" A2A = "a2a" PromptManagement = "prompt_management" UI = "ui" diff --git a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py index fcf8f048190..00d08504fe5 100644 --- a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py @@ -439,3 +439,200 @@ class TestAgentRoutesIncludesAgentIdPattern: from litellm.proxy._types import LiteLLMRoutes assert "/v1/agents/{agent_id}" in LiteLLMRoutes.agent_routes.value + + +class TestAgentHealthCheck: + """Tests for the health_check query parameter on GET /v1/agents.""" + + @pytest.fixture(autouse=True) + def _setup(self, monkeypatch): + from litellm.proxy.agent_endpoints import agent_registry as ar_mod + + self.admin_client = _make_app_with_role(LitellmUserRoles.PROXY_ADMIN) + self.mock_registry = MagicMock() + monkeypatch.setattr(ar_mod, "global_agent_registry", self.mock_registry) + + def _make_agent(self, agent_id: str, url: str | None = None) -> AgentResponse: + card = _sample_agent_card_params() + if url is not None: + card["url"] = url + else: + card.pop("url", None) + return AgentResponse( + agent_id=agent_id, + agent_name=f"Agent {agent_id}", + agent_card_params=card, + litellm_params={}, + ) + + def test_should_return_all_agents_when_health_check_disabled(self): + agents = [self._make_agent("a1", "http://reachable"), self._make_agent("a2", "http://unreachable")] + self.mock_registry.get_agent_list = MagicMock(return_value=agents) + + resp = self.admin_client.get( + "/v1/agents", headers={"Authorization": "Bearer k"} + ) + assert resp.status_code == 200 + assert len(resp.json()) == 2 + + def test_should_filter_unhealthy_agents_when_health_check_enabled(self, monkeypatch): + agents = [ + self._make_agent("a1", "http://reachable"), + self._make_agent("a2", "http://unreachable"), + ] + self.mock_registry.get_agent_list = MagicMock(return_value=agents) + + results = iter([ + {"agent_id": "a1", "healthy": True}, + {"agent_id": "a2", "healthy": False, "error": "Connection refused"}, + ]) + monkeypatch.setattr( + agent_endpoints, + "_check_agent_url_health", + AsyncMock(side_effect=lambda agent: next(results)), + ) + + resp = self.admin_client.get( + "/v1/agents?health_check=true", + headers={"Authorization": "Bearer k"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["agent_id"] == "a1" + + def test_should_return_empty_list_when_all_agents_unhealthy(self, monkeypatch): + agents = [self._make_agent("a1", "http://down")] + self.mock_registry.get_agent_list = MagicMock(return_value=agents) + monkeypatch.setattr( + agent_endpoints, + "_check_agent_url_health", + AsyncMock(return_value={"agent_id": "a1", "healthy": False, "error": "timeout"}), + ) + + resp = self.admin_client.get( + "/v1/agents?health_check=true", + headers={"Authorization": "Bearer k"}, + ) + assert resp.status_code == 200 + assert len(resp.json()) == 0 + + def test_should_return_all_agents_when_all_healthy(self, monkeypatch): + agents = [self._make_agent("a1", "http://ok1"), self._make_agent("a2", "http://ok2")] + self.mock_registry.get_agent_list = MagicMock(return_value=agents) + + results = iter([ + {"agent_id": "a1", "healthy": True}, + {"agent_id": "a2", "healthy": True}, + ]) + monkeypatch.setattr( + agent_endpoints, + "_check_agent_url_health", + AsyncMock(side_effect=lambda agent: next(results)), + ) + + resp = self.admin_client.get( + "/v1/agents?health_check=true", + headers={"Authorization": "Bearer k"}, + ) + assert resp.status_code == 200 + assert len(resp.json()) == 2 + + +class TestCheckAgentUrlHealth: + """Unit tests for the _check_agent_url_health helper.""" + + @pytest.mark.asyncio + async def test_should_return_healthy_when_no_url(self): + from litellm.proxy.agent_endpoints.endpoints import _check_agent_url_health + + agent = AgentResponse( + agent_id="no-url", + agent_name="No URL Agent", + agent_card_params={"name": "test"}, + litellm_params={}, + ) + result = await _check_agent_url_health(agent) + assert result["healthy"] is True + assert "error" not in result + + @pytest.mark.asyncio + @patch("litellm.proxy.agent_endpoints.endpoints.get_async_httpx_client") + async def test_should_return_healthy_for_200(self, mock_get_client): + from litellm.proxy.agent_endpoints.endpoints import _check_agent_url_health + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=mock_response) + mock_get_client.return_value = mock_client + + agent = AgentResponse( + agent_id="ok", + agent_name="OK Agent", + agent_card_params={"url": "http://example.com"}, + litellm_params={}, + ) + result = await _check_agent_url_health(agent) + assert result["healthy"] is True + + @pytest.mark.asyncio + @patch("litellm.proxy.agent_endpoints.endpoints.get_async_httpx_client") + async def test_should_return_unhealthy_for_500(self, mock_get_client): + from litellm.proxy.agent_endpoints.endpoints import _check_agent_url_health + + mock_response = MagicMock() + mock_response.status_code = 500 + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=mock_response) + mock_get_client.return_value = mock_client + + agent = AgentResponse( + agent_id="err", + agent_name="Error Agent", + agent_card_params={"url": "http://failing.com"}, + litellm_params={}, + ) + result = await _check_agent_url_health(agent) + assert result["healthy"] is False + assert "HTTP 500" in result["error"] + + @pytest.mark.asyncio + @patch("litellm.proxy.agent_endpoints.endpoints.get_async_httpx_client") + async def test_should_return_unhealthy_on_connection_error(self, mock_get_client): + from litellm.proxy.agent_endpoints.endpoints import _check_agent_url_health + + mock_client = AsyncMock() + mock_client.get = AsyncMock(side_effect=Exception("Connection refused")) + mock_get_client.return_value = mock_client + + agent = AgentResponse( + agent_id="down", + agent_name="Down Agent", + agent_card_params={"url": "http://down.com"}, + litellm_params={}, + ) + result = await _check_agent_url_health(agent) + assert result["healthy"] is False + assert "Connection refused" in result["error"] + + @pytest.mark.asyncio + @patch("litellm.proxy.agent_endpoints.endpoints.get_async_httpx_client") + async def test_should_treat_404_as_healthy(self, mock_get_client): + """A 404 means the server is reachable, just not the specific path.""" + from litellm.proxy.agent_endpoints.endpoints import _check_agent_url_health + + mock_response = MagicMock() + mock_response.status_code = 404 + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=mock_response) + mock_get_client.return_value = mock_client + + agent = AgentResponse( + agent_id="notfound", + agent_name="NotFound Agent", + agent_card_params={"url": "http://example.com/missing"}, + litellm_params={}, + ) + result = await _check_agent_url_health(agent) + assert result["healthy"] is True diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 69efbf19c38..d4f60ff0d7f 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -13279,6 +13279,21 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz", + "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } } diff --git a/ui/litellm-dashboard/src/components/agents.test.tsx b/ui/litellm-dashboard/src/components/agents.test.tsx index 2d4c879dec0..8914f8ad6bc 100644 --- a/ui/litellm-dashboard/src/components/agents.test.tsx +++ b/ui/litellm-dashboard/src/components/agents.test.tsx @@ -1,7 +1,8 @@ import React from "react"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, act, fireEvent } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import AgentsPanel from "./agents"; +import * as networking from "./networking"; vi.mock("./networking", () => ({ getAgentsList: vi.fn().mockResolvedValue({ agents: [] }), @@ -68,4 +69,37 @@ describe("AgentsPanel", () => { expect(grid).toHaveAttribute("data-is-admin", "false"); }); }); + + it("should render the Health Check toggle", async () => { + render(); + expect(screen.getByText("Health Check")).toBeInTheDocument(); + }); + + it("should render the Health Check toggle for non-admin users too", async () => { + render(); + expect(screen.getByText("Health Check")).toBeInTheDocument(); + }); + + it("should call getAgentsList with health_check=false on initial load", async () => { + render(); + await waitFor(() => { + expect(networking.getAgentsList).toHaveBeenCalledWith("test-token", false); + }); + }); + + it("should call getAgentsList with health_check=true when toggle is enabled", async () => { + render(); + await waitFor(() => { + expect(networking.getAgentsList).toHaveBeenCalledWith("test-token", false); + }); + + const toggle = screen.getByRole("switch"); + await act(async () => { + fireEvent.click(toggle); + }); + + await waitFor(() => { + expect(networking.getAgentsList).toHaveBeenCalledWith("test-token", true); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/agents.tsx b/ui/litellm-dashboard/src/components/agents.tsx index 8dd9bc7d01c..de544acb9f1 100644 --- a/ui/litellm-dashboard/src/components/agents.tsx +++ b/ui/litellm-dashboard/src/components/agents.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from "react"; import { Button } from "@tremor/react"; -import { Modal, Alert } from "antd"; +import { Modal, Alert, Switch, Tooltip } from "antd"; +import { CheckCircleOutlined } from "@ant-design/icons"; import { getAgentsList, deleteAgentCall, keyListCall } from "./networking"; import AddAgentForm from "./agents/add_agent_form"; import AgentCardGrid from "./agents/agent_card_grid"; @@ -26,17 +27,18 @@ const AgentsPanel: React.FC = ({ accessToken, userRole }) => { const [isDeleting, setIsDeleting] = useState(false); const [agentToDelete, setAgentToDelete] = useState<{ id: string; name: string } | null>(null); const [selectedAgentId, setSelectedAgentId] = useState(null); + const [healthCheckEnabled, setHealthCheckEnabled] = useState(false); const isAdmin = userRole ? isAdminRole(userRole) : false; - const fetchAgents = async () => { + const fetchAgents = async (healthCheck?: boolean) => { if (!accessToken) { return; } setIsLoading(true); try { - const response: AgentsResponse = await getAgentsList(accessToken); + const response: AgentsResponse = await getAgentsList(accessToken, healthCheck ?? healthCheckEnabled); setAgentsList(response.agents || []); } catch (error) { console.error("Error fetching agents:", error); @@ -89,6 +91,11 @@ const AgentsPanel: React.FC = ({ accessToken, userRole }) => { } }, [accessToken, agentsList.length]); + const handleHealthCheckToggle = (checked: boolean) => { + setHealthCheckEnabled(checked); + fetchAgents(checked); + }; + const handleAddAgent = () => { if (selectedAgentId) { setSelectedAgentId(null); @@ -141,13 +148,25 @@ const AgentsPanel: React.FC = ({ accessToken, userRole }) => { showIcon className="mb-3" /> - {isAdmin && ( -
+
+ {isAdmin && ( -
- )} + )} + +
+ + Health Check + +
+
+
{selectedAgentId ? ( diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 91454d8d8b3..244144f56bf 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -7607,9 +7607,10 @@ export const getMajorAirlines = async (accessToken: string) => { } }; -export const getAgentsList = async (accessToken: string) => { +export const getAgentsList = async (accessToken: string, healthCheck: boolean = false) => { try { - const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/agents` : `/v1/agents`; + const params = healthCheck ? "?health_check=true" : ""; + const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/agents${params}` : `/v1/agents${params}`; const response = await fetch(url, { method: "GET", diff --git a/ui/litellm-dashboard/tsconfig.json b/ui/litellm-dashboard/tsconfig.json index d24bdd340f7..5b0352feb98 100644 --- a/ui/litellm-dashboard/tsconfig.json +++ b/ui/litellm-dashboard/tsconfig.json @@ -14,7 +14,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "react-jsx", + "jsx": "preserve", "incremental": true, "plugins": [ {