mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-12 03:48:56 +00:00
* test(proxy): move utils, agent_endpoints and endpoint tests into tests/unit/proxy Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): package moved unit test directories Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): exclude proxy-db-owned files from the misc target Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): drop the redundant fixture docstrings in the proxy conftest Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yuneng <yuneng@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
283 lines
9.5 KiB
Python
283 lines
9.5 KiB
Python
"""Tests for the well-known card fetcher and the discovery endpoint."""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
import litellm
|
|
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
|
from litellm.proxy.a2a.discovery import (
|
|
AGENT_CARD_WELL_KNOWN_PATHS,
|
|
AgentCardDiscoveryError,
|
|
DiscoveryMode,
|
|
fetch_well_known_card,
|
|
)
|
|
from litellm.proxy.a2a.endpoints import router as a2a_router
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _disable_url_validation_for_mocks(monkeypatch):
|
|
"""The fetch tests use placeholder hostnames (``upstream.example``,
|
|
``localhost:2024``) with mocked HTTP clients. ``async_safe_get`` would
|
|
otherwise resolve those hostnames and either fail DNS or block on the
|
|
SSRF guard. Disabling validation here lets the unit tests focus on
|
|
fallback / parsing logic; SSRF behavior is covered in its own test."""
|
|
monkeypatch.setattr(litellm, "user_url_validation", False)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fetch_well_known_card
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _mock_response(status_code: int = 200, body=None, raise_json=False):
|
|
response = MagicMock()
|
|
response.status_code = status_code
|
|
if raise_json:
|
|
response.json = MagicMock(side_effect=ValueError("bad json"))
|
|
else:
|
|
response.json = MagicMock(return_value=body)
|
|
return response
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_uses_first_path_that_returns_200():
|
|
body = {"name": "agent"}
|
|
fake_client = MagicMock()
|
|
fake_client.get = AsyncMock(return_value=_mock_response(200, body=body))
|
|
|
|
with patch(
|
|
"litellm.proxy.a2a.discovery.get_async_httpx_client", return_value=fake_client
|
|
):
|
|
card = await fetch_well_known_card("https://upstream.example")
|
|
|
|
assert card == body
|
|
# First call should be to the canonical path.
|
|
called_url = fake_client.get.call_args.args[0]
|
|
assert called_url == f"https://upstream.example{AGENT_CARD_WELL_KNOWN_PATHS[0]}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_falls_back_to_later_paths_on_404():
|
|
body = {"name": "agent"}
|
|
fake_client = MagicMock()
|
|
fake_client.get = AsyncMock(
|
|
side_effect=[
|
|
_mock_response(404),
|
|
_mock_response(404),
|
|
_mock_response(200, body=body),
|
|
]
|
|
)
|
|
|
|
with patch(
|
|
"litellm.proxy.a2a.discovery.get_async_httpx_client", return_value=fake_client
|
|
):
|
|
card = await fetch_well_known_card("https://upstream.example")
|
|
|
|
assert card == body
|
|
assert fake_client.get.await_count == len(AGENT_CARD_WELL_KNOWN_PATHS)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_raises_when_all_paths_fail():
|
|
fake_client = MagicMock()
|
|
fake_client.get = AsyncMock(
|
|
side_effect=[_mock_response(404) for _ in AGENT_CARD_WELL_KNOWN_PATHS]
|
|
)
|
|
|
|
with patch(
|
|
"litellm.proxy.a2a.discovery.get_async_httpx_client", return_value=fake_client
|
|
):
|
|
with pytest.raises(AgentCardDiscoveryError):
|
|
await fetch_well_known_card("https://upstream.example")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_skips_path_that_returns_non_json_body():
|
|
body = {"name": "agent"}
|
|
fake_client = MagicMock()
|
|
fake_client.get = AsyncMock(
|
|
side_effect=[
|
|
_mock_response(200, raise_json=True),
|
|
_mock_response(200, body=body),
|
|
]
|
|
)
|
|
|
|
with patch(
|
|
"litellm.proxy.a2a.discovery.get_async_httpx_client", return_value=fake_client
|
|
):
|
|
card = await fetch_well_known_card("https://upstream.example")
|
|
|
|
assert card == body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_skips_path_that_returns_non_object_json():
|
|
fake_client = MagicMock()
|
|
fake_client.get = AsyncMock(
|
|
side_effect=[
|
|
_mock_response(200, body=["not", "an", "object"]),
|
|
_mock_response(200, body={"name": "agent"}),
|
|
_mock_response(404),
|
|
]
|
|
)
|
|
|
|
with patch(
|
|
"litellm.proxy.a2a.discovery.get_async_httpx_client", return_value=fake_client
|
|
):
|
|
card = await fetch_well_known_card("https://upstream.example")
|
|
|
|
assert card == {"name": "agent"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_requires_base_url():
|
|
with pytest.raises(AgentCardDiscoveryError):
|
|
await fetch_well_known_card("")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LangGraph Platform discovery mode
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_langgraph_mode_appends_assistant_id_query_param():
|
|
"""LangGraph serves one card endpoint; the assistant is selected via query string."""
|
|
body = {"name": "support-agent"}
|
|
fake_client = MagicMock()
|
|
fake_client.get = AsyncMock(return_value=_mock_response(200, body=body))
|
|
|
|
with patch(
|
|
"litellm.proxy.a2a.discovery.get_async_httpx_client", return_value=fake_client
|
|
):
|
|
card = await fetch_well_known_card(
|
|
"http://localhost:2024",
|
|
discovery_mode=DiscoveryMode.LANGGRAPH_PLATFORM,
|
|
params={"assistant_id": "agent"},
|
|
)
|
|
|
|
assert card == body
|
|
called_url = fake_client.get.call_args.args[0]
|
|
# The canonical A2A path with the LangGraph query parameter — NOT a
|
|
# per-assistant subpath like /agent/.well-known/agent-card.json.
|
|
assert called_url == (
|
|
"http://localhost:2024/.well-known/agent-card.json?assistant_id=agent"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_langgraph_mode_requires_assistant_id():
|
|
with pytest.raises(AgentCardDiscoveryError, match="assistant_id"):
|
|
await fetch_well_known_card(
|
|
"http://localhost:2024",
|
|
discovery_mode=DiscoveryMode.LANGGRAPH_PLATFORM,
|
|
params={},
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_langgraph_mode_falls_back_to_older_well_known_paths():
|
|
"""If an older LangGraph deployment serves /.well-known/agent.json, accept that too."""
|
|
fake_client = MagicMock()
|
|
fake_client.get = AsyncMock(
|
|
side_effect=[
|
|
_mock_response(404),
|
|
_mock_response(200, body={"name": "support-agent"}),
|
|
]
|
|
)
|
|
|
|
with patch(
|
|
"litellm.proxy.a2a.discovery.get_async_httpx_client", return_value=fake_client
|
|
):
|
|
card = await fetch_well_known_card(
|
|
"http://localhost:2024",
|
|
discovery_mode=DiscoveryMode.LANGGRAPH_PLATFORM,
|
|
params={"assistant_id": "agent"},
|
|
)
|
|
|
|
assert card == {"name": "support-agent"}
|
|
# Both calls carry the assistant_id query param.
|
|
for call in fake_client.get.await_args_list:
|
|
assert "assistant_id=agent" in call.args[0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /v1/a2a/discover
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _client_for_role(role: LitellmUserRoles) -> TestClient:
|
|
app = FastAPI()
|
|
app.include_router(a2a_router)
|
|
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
|
|
user_id="u", user_role=role
|
|
)
|
|
return TestClient(app)
|
|
|
|
|
|
def test_discover_admin_returns_raw_card():
|
|
client = _client_for_role(LitellmUserRoles.PROXY_ADMIN)
|
|
with patch(
|
|
"litellm.proxy.a2a.endpoints.fetch_well_known_card",
|
|
new=AsyncMock(return_value={"name": "Upstream"}),
|
|
):
|
|
resp = client.post("/v1/a2a/discover", json={"url": "https://upstream.example"})
|
|
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["url"] == "https://upstream.example"
|
|
assert body["agent_card"] == {"name": "Upstream"}
|
|
|
|
|
|
def test_discover_non_admin_forbidden():
|
|
client = _client_for_role(LitellmUserRoles.INTERNAL_USER)
|
|
resp = client.post("/v1/a2a/discover", json={"url": "https://upstream.example"})
|
|
assert resp.status_code == 403
|
|
|
|
|
|
def test_discover_returns_400_when_upstream_unreachable():
|
|
client = _client_for_role(LitellmUserRoles.PROXY_ADMIN)
|
|
with patch(
|
|
"litellm.proxy.a2a.endpoints.fetch_well_known_card",
|
|
new=AsyncMock(side_effect=AgentCardDiscoveryError("no luck")),
|
|
):
|
|
resp = client.post("/v1/a2a/discover", json={"url": "https://upstream.example"})
|
|
|
|
assert resp.status_code == 400
|
|
assert "no luck" in resp.json()["detail"]
|
|
|
|
|
|
def test_discover_forwards_mode_and_params_to_fetcher():
|
|
"""The endpoint must hand discovery_mode + params to fetch_well_known_card."""
|
|
client = _client_for_role(LitellmUserRoles.PROXY_ADMIN)
|
|
fetch_stub = AsyncMock(return_value={"name": "support-agent"})
|
|
with patch("litellm.proxy.a2a.endpoints.fetch_well_known_card", new=fetch_stub):
|
|
resp = client.post(
|
|
"/v1/a2a/discover",
|
|
json={
|
|
"url": "http://localhost:2024",
|
|
"discovery_mode": "langgraph_platform",
|
|
"params": {"assistant_id": "agent"},
|
|
},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
# Pydantic deserializes the JSON string back into the DiscoveryMode enum.
|
|
assert fetch_stub.await_args is not None
|
|
kwargs = fetch_stub.await_args.kwargs
|
|
assert kwargs["discovery_mode"] == DiscoveryMode.LANGGRAPH_PLATFORM
|
|
assert kwargs["params"] == {"assistant_id": "agent"}
|
|
|
|
|
|
def test_discover_rejects_unknown_mode():
|
|
"""Pydantic should 422 on an enum value we don't recognize."""
|
|
client = _client_for_role(LitellmUserRoles.PROXY_ADMIN)
|
|
resp = client.post(
|
|
"/v1/a2a/discover",
|
|
json={"url": "http://localhost:2024", "discovery_mode": "bogus"},
|
|
)
|
|
assert resp.status_code == 422
|