Agents health checks (#23044)

* feat: add health check toggle to agents page

Backend:
- Add health_check query parameter to GET /v1/agents endpoint
- When health_check=true, performs concurrent GET requests to each agent's
  URL and filters out agents with unreachable URLs (5s timeout)
- Agents returning HTTP <500 are considered healthy; 5xx and connection
  errors mark agents as unhealthy

UI:
- Add Health Check toggle (Switch) to agents panel header
- Toggle triggers re-fetch with health_check=true, filtering the agent list
- Icon color changes (green/gray) to indicate toggle state
- Tooltip explains behavior: 'only agents with reachable URLs are shown'

Networking:
- Update getAgentsList to accept optional healthCheck boolean parameter

Tests:
- Backend: 9 new tests covering health check filtering, _check_agent_url_health
  helper (no URL, 200, 404, 500, connection error cases)
- UI: 3 new tests verifying toggle renders, initial fetch without health check,
  and fetch with health check after toggle click

Co-authored-by: Krish Dholakia <krrishdholakia@gmail.com>

* fix: fix greptile comment re: security issue

* fix: fix based on greptile feedback

* fix: align health check tests with implementation

- Rename test_should_return_unhealthy_when_no_url to
  test_should_return_healthy_when_no_url (implementation returns
  healthy=True for agents without a URL)
- Patch get_async_httpx_client instead of httpx.AsyncClient so mocks
  actually intercept the HTTP calls made by _check_agent_url_health
- Remove unnecessary __aenter__/__aexit__ context-manager mocks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: undo _experimental/out renames from cherry-pick

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Update litellm/proxy/agent_endpoints/endpoints.py

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Krish Dholakia 2026-03-07 18:32:47 -08:00 committed by GitHub
parent e7714f0ce6
commit 03ca98123f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 374 additions and 13 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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(<AgentsPanel accessToken="test-token" userRole="Admin" />);
expect(screen.getByText("Health Check")).toBeInTheDocument();
});
it("should render the Health Check toggle for non-admin users too", async () => {
render(<AgentsPanel accessToken="test-token" userRole="Internal User" />);
expect(screen.getByText("Health Check")).toBeInTheDocument();
});
it("should call getAgentsList with health_check=false on initial load", async () => {
render(<AgentsPanel accessToken="test-token" userRole="Admin" />);
await waitFor(() => {
expect(networking.getAgentsList).toHaveBeenCalledWith("test-token", false);
});
});
it("should call getAgentsList with health_check=true when toggle is enabled", async () => {
render(<AgentsPanel accessToken="test-token" userRole="Admin" />);
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);
});
});
});

View file

@ -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<AgentsPanelProps> = ({ accessToken, userRole }) => {
const [isDeleting, setIsDeleting] = useState(false);
const [agentToDelete, setAgentToDelete] = useState<{ id: string; name: string } | null>(null);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(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<AgentsPanelProps> = ({ 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<AgentsPanelProps> = ({ accessToken, userRole }) => {
showIcon
className="mb-3"
/>
{isAdmin && (
<div className="mt-2">
<div className="mt-2 flex items-center gap-4">
{isAdmin && (
<Button onClick={handleAddAgent} disabled={!accessToken}>
+ Add New Agent
</Button>
</div>
)}
)}
<Tooltip title="When enabled, only agents with reachable URLs are shown">
<div className="flex items-center gap-2">
<CheckCircleOutlined className={healthCheckEnabled ? "text-green-500" : "text-gray-400"} />
<span className="text-sm text-gray-600">Health Check</span>
<Switch
size="small"
checked={healthCheckEnabled}
onChange={handleHealthCheckToggle}
loading={isLoading && healthCheckEnabled}
/>
</div>
</Tooltip>
</div>
</div>
{selectedAgentId ? (

View file

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

View file

@ -14,7 +14,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"jsx": "preserve",
"incremental": true,
"plugins": [
{