litellm/tests/test_litellm/a2a_protocol/test_card_resolver.py
Devin AI 75eec8712c fix(a2a): keep the upstream status on card discovery failures and inject the card client in tests
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-16 23:55:57 +00:00

249 lines
8.9 KiB
Python

"""
Mock tests for LiteLLMA2ACardResolver.
Tests that the card resolver tries both old and new well-known paths.
"""
from types import SimpleNamespace
from typing import Any, Final
from unittest.mock import MagicMock, patch
import httpx
import pytest
from litellm.a2a_protocol.card_resolver import (
LiteLLMA2ACardResolver,
fix_agent_card_url,
is_localhost_or_internal_url,
normalize_agent_card_interfaces,
set_agent_card_url,
)
from litellm.a2a_protocol.exceptions import A2AAgentCardDiscoveryError
@pytest.mark.asyncio
async def test_card_resolver_fallback_from_new_to_old_path():
"""
Test that the card resolver tries the new path (/.well-known/agent-card.json) first,
and falls back to the old path (/.well-known/agent.json) if the new path fails.
"""
# Mock the AgentCard
mock_agent_card = MagicMock()
mock_agent_card.name = "Test Agent"
mock_agent_card.description = "A test agent"
# Track which paths were called
paths_called = []
# Create a mock for the parent's get_agent_card method
async def mock_parent_get_agent_card(
self, relative_card_path=None, http_kwargs=None
):
paths_called.append(relative_card_path)
if relative_card_path == "/.well-known/agent-card.json":
# First call (new path) fails
raise Exception("404 Not Found")
else:
# Second call (old path) succeeds
return mock_agent_card
# Create a mock httpx client
mock_httpx_client = MagicMock()
# Patch the parent class's get_agent_card method
# We need to patch the actual parent class method that super() calls
with patch.object(
LiteLLMA2ACardResolver.__bases__[0],
"get_agent_card",
mock_parent_get_agent_card,
):
resolver = LiteLLMA2ACardResolver(
httpx_client=mock_httpx_client, base_url="http://test-agent:8000"
)
result = await resolver.get_agent_card()
# Verify both paths were tried in correct order
assert len(paths_called) == 2
assert paths_called[0] == "/.well-known/agent-card.json" # New path tried first
assert paths_called[1] == "/.well-known/agent.json" # Old path tried second
# Verify the result
assert result == mock_agent_card
assert result.name == "Test Agent"
def test_is_localhost_or_internal_url():
"""Test that localhost/internal URLs are correctly detected."""
# Should return True for localhost variants
assert is_localhost_or_internal_url("http://localhost:8000/") is True
assert is_localhost_or_internal_url("http://0.0.0.0:8001/") is True
# Should return False for public URLs
assert is_localhost_or_internal_url("https://my-agent.example.com/") is False
assert is_localhost_or_internal_url(None) is False
def test_fix_agent_card_url_replaces_localhost():
"""Test that fix_agent_card_url replaces localhost URLs with base_url."""
# Create mock agent card with localhost URL
mock_card = MagicMock()
mock_card.url = "http://0.0.0.0:8001/"
# Fix the URL
result = fix_agent_card_url(mock_card, "https://my-public-agent.example.com")
# Verify localhost URL was replaced with base_url
assert result.url == "https://my-public-agent.example.com/"
def test_set_agent_card_url_updates_top_level_and_supported_interface():
card = SimpleNamespace(
url="http://localhost:10001/",
supported_interfaces=[SimpleNamespace(url="http://0.0.0.0:10001/")],
)
set_agent_card_url(card, "https://my-public-agent.example.com")
assert card.url == "https://my-public-agent.example.com/"
assert card.supported_interfaces[0].url == "https://my-public-agent.example.com/"
def test_fix_agent_card_url_updates_interface_when_top_level_is_localhost():
card = SimpleNamespace(
url="http://localhost:10001/",
supported_interfaces=[SimpleNamespace(url="http://0.0.0.0:10001/")],
)
result = fix_agent_card_url(card, "https://my-public-agent.example.com")
assert result.url == "https://my-public-agent.example.com/"
assert result.supported_interfaces[0].url == "https://my-public-agent.example.com/"
def test_normalize_agent_card_interfaces_downgrades_miscased_interfaces_to_the_0_3_dialect():
pb2 = pytest.importorskip("a2a.types.a2a_pb2")
card = pb2.AgentCard(
name="langgraph",
supported_interfaces=[
pb2.AgentInterface(url="http://a/", protocol_binding="jsonrpc", protocol_version="1.0"),
pb2.AgentInterface(url="http://b/", protocol_binding="JSONRPC", protocol_version="1.0"),
pb2.AgentInterface(url="http://c/", protocol_binding="websocket", protocol_version="1.0"),
],
)
normalized = normalize_agent_card_interfaces(card)
assert [(i.protocol_binding, i.protocol_version) for i in normalized.supported_interfaces] == [
("JSONRPC", "0.3"),
("JSONRPC", "1.0"),
("websocket", "1.0"),
]
assert card.supported_interfaces[0].protocol_binding == "jsonrpc"
assert card.supported_interfaces[0].protocol_version == "1.0"
_FOUNDRY_BASE_URL: Final = "https://foundry.example.com/a2a"
_FOUNDRY_CARD_JSON: Final = {
"name": "Foundry Agent",
"description": "A test agent",
"url": "https://foundry.example.com/a2a",
"version": "1.0",
"capabilities": {"streaming": True},
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
"skills": [{"id": "chat", "name": "chat", "description": "Chat", "tags": ["chat"]}],
"protocolVersion": "1.0",
}
class _FakeHttpxClient:
"""Answers GETs from a path -> (status, body) map and records the path of each call."""
def __init__(self, base_url: str, responses: dict[str, tuple[int, dict[str, Any]]]) -> None:
self._base_url = base_url.rstrip("/")
self._responses = responses
self.calls: list[str] = []
async def get(self, url: str, **kwargs: Any) -> httpx.Response:
path: Final = url.removeprefix(self._base_url)
self.calls.append(path)
status_code, body = self._responses[path]
return httpx.Response(status_code, json=body, request=httpx.Request("GET", url))
@pytest.mark.asyncio
async def test_card_resolver_falls_through_to_the_foundry_card_path():
httpx_client = _FakeHttpxClient(
base_url=_FOUNDRY_BASE_URL,
responses={
"/.well-known/agent-card.json": (404, {"error": "not found"}),
"/.well-known/agent.json": (404, {"error": "not found"}),
"/agentCard/v1.0": (200, dict(_FOUNDRY_CARD_JSON)),
},
)
resolver = LiteLLMA2ACardResolver(httpx_client=httpx_client, base_url=_FOUNDRY_BASE_URL)
result = await resolver.get_agent_card()
assert httpx_client.calls == ["/.well-known/agent-card.json", "/.well-known/agent.json", "/agentCard/v1.0"]
assert result.name == "Foundry Agent"
assert result.supported_interfaces[0].url == "https://foundry.example.com/a2a"
@pytest.mark.asyncio
async def test_card_resolver_explicit_path_skips_the_probes():
httpx_client = _FakeHttpxClient(
base_url=_FOUNDRY_BASE_URL,
responses={"/agentCard/v1.0": (200, dict(_FOUNDRY_CARD_JSON))},
)
resolver = LiteLLMA2ACardResolver(httpx_client=httpx_client, base_url=_FOUNDRY_BASE_URL)
result = await resolver.get_agent_card(relative_card_path="agentCard/v1.0")
assert httpx_client.calls == ["/agentCard/v1.0"]
assert result.name == "Foundry Agent"
@pytest.mark.asyncio
async def test_card_resolver_names_every_probed_path_when_discovery_fails():
httpx_client = _FakeHttpxClient(
base_url=_FOUNDRY_BASE_URL,
responses={
"/.well-known/agent-card.json": (404, {"error": "not found"}),
"/.well-known/agent.json": (401, {"error": "unauthorized"}),
"/agentCard/v1.0": (404, {"error": "not found"}),
},
)
resolver = LiteLLMA2ACardResolver(httpx_client=httpx_client, base_url=_FOUNDRY_BASE_URL)
with pytest.raises(A2AAgentCardDiscoveryError) as raised:
await resolver.get_agent_card()
assert raised.value.status_code == 401
message = str(raised.value)
assert _FOUNDRY_BASE_URL in message
assert "/.well-known/agent-card.json (" in message and "HTTP 404" in message
assert "/.well-known/agent.json (" in message and "HTTP 401" in message
assert "/agentCard/v1.0 (" in message
@pytest.mark.asyncio
async def test_card_resolver_discovery_error_is_404_when_every_probe_is_404():
resolver = LiteLLMA2ACardResolver(
httpx_client=_FakeHttpxClient(
base_url=_FOUNDRY_BASE_URL,
responses={
"/.well-known/agent-card.json": (404, {"error": "not found"}),
"/.well-known/agent.json": (404, {"error": "not found"}),
"/agentCard/v1.0": (404, {"error": "not found"}),
},
),
base_url=_FOUNDRY_BASE_URL,
)
with pytest.raises(A2AAgentCardDiscoveryError) as raised:
await resolver.get_agent_card()
assert raised.value.status_code == 404