mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
Fix agent_card_params validation to use protobuf-compatible AgentCard conversion, and harden regression tests to fake the HTTP boundary instead of patching internals
This commit is contained in:
parent
22c9514b58
commit
9523a8f888
2 changed files with 111 additions and 43 deletions
|
|
@ -798,11 +798,13 @@ async def create_a2a_client(
|
|||
agent_card: AgentCard | None = None
|
||||
|
||||
if agent_card_params:
|
||||
from a2a.compat.v0_3.types import AgentCard as _AgentCard
|
||||
from a2a.compat.v0_3 import conversions as _conversions
|
||||
from a2a.compat.v0_3.types import AgentCard as _CompatAgentCard
|
||||
from pydantic import ValidationError as _ValidationError
|
||||
|
||||
try:
|
||||
agent_card = normalize_agent_card_interfaces(_AgentCard.model_validate(agent_card_params))
|
||||
compat_card = _CompatAgentCard.model_validate(agent_card_params)
|
||||
agent_card = normalize_agent_card_interfaces(_conversions.to_core_agent_card(compat_card))
|
||||
verbose_logger.info("Using pre-registered agent card for %s (skipping well-known discovery)", base_url)
|
||||
except _ValidationError as e:
|
||||
verbose_logger.warning(
|
||||
|
|
|
|||
|
|
@ -1,19 +1,40 @@
|
|||
"""
|
||||
Regression tests for GH #40586: message/send re-discovered the agent card
|
||||
from well-known paths instead of using the registered agent_card_params.
|
||||
|
||||
These tests fake the HTTP boundary with a real httpx.AsyncClient wired to an
|
||||
httpx.MockTransport, rather than patching SDK internals, so they verify
|
||||
actual behavior: whether a well-known-path request goes out over the wire,
|
||||
and what agent card the resulting client ends up holding.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from litellm.a2a_protocol.main import create_a2a_client
|
||||
|
||||
BASE_URL = "https://example.com/agents/a2a"
|
||||
WELL_KNOWN_PATH = "/agents/a2a/.well-known/agent-card.json"
|
||||
|
||||
MINIMAL_AGENT_CARD_PARAMS = {
|
||||
"protocolVersion": "1.0",
|
||||
"name": "agent",
|
||||
"name": "pre-registered-agent",
|
||||
"description": "A test assistant.",
|
||||
"url": "https://example.com/agents/a2a",
|
||||
"url": BASE_URL,
|
||||
"version": "1.0",
|
||||
"capabilities": {"streaming": False},
|
||||
"defaultInputModes": ["text"],
|
||||
"defaultOutputModes": ["text"],
|
||||
"skills": [],
|
||||
}
|
||||
|
||||
DISCOVERED_AGENT_CARD_JSON = {
|
||||
"protocolVersion": "1.0",
|
||||
"name": "discovered-agent",
|
||||
"description": "A test assistant found via well-known discovery.",
|
||||
"url": BASE_URL,
|
||||
"version": "1.0",
|
||||
"capabilities": {"streaming": False},
|
||||
"defaultInputModes": ["text"],
|
||||
|
|
@ -22,61 +43,106 @@ MINIMAL_AGENT_CARD_PARAMS = {
|
|||
}
|
||||
|
||||
|
||||
def _mock_client(handler):
|
||||
"""A real httpx.AsyncClient wired to a local handler instead of the network."""
|
||||
transport = httpx.MockTransport(handler)
|
||||
client = httpx.AsyncClient(transport=transport)
|
||||
|
||||
class _FakeAsyncHandler:
|
||||
def __init__(self, c):
|
||||
self.client = c
|
||||
|
||||
return _FakeAsyncHandler(client)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_a2a_client_skips_discovery_when_agent_card_params_given():
|
||||
"""
|
||||
When a caller already has a resolved agent card (e.g. one stored via
|
||||
POST /v1/agents), create_a2a_client must build the AgentCard directly
|
||||
from it instead of probing the upstream server's well-known paths.
|
||||
POST /v1/agents), create_a2a_client must build the client from it
|
||||
directly instead of making a well-known-path HTTP request.
|
||||
"""
|
||||
with (
|
||||
patch("litellm.a2a_protocol.main.get_async_httpx_client") as mock_get_httpx,
|
||||
patch("litellm.a2a_protocol.main.A2ACardResolver") as mock_resolver_cls,
|
||||
patch("litellm.a2a_protocol.main.create_client", new_callable=AsyncMock) as mock_create_client,
|
||||
patch(
|
||||
"litellm.a2a_protocol.main.normalize_agent_card_interfaces",
|
||||
side_effect=lambda card: card,
|
||||
),
|
||||
):
|
||||
mock_get_httpx.return_value.client = MagicMock()
|
||||
mock_create_client.return_value = MagicMock()
|
||||
requests_made: list[str] = []
|
||||
|
||||
await create_a2a_client(
|
||||
base_url="https://example.com/agents/a2a",
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests_made.append(str(request.url))
|
||||
return httpx.Response(404, json={"error": "should not be called"})
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: injects a real httpx.AsyncClient wired to httpx.MockTransport, not a mock of behavior
|
||||
"litellm.a2a_protocol.main.get_async_httpx_client",
|
||||
return_value=_mock_client(handler),
|
||||
)
|
||||
):
|
||||
a2a_client = await create_a2a_client(
|
||||
base_url=BASE_URL,
|
||||
agent_card_params=MINIMAL_AGENT_CARD_PARAMS,
|
||||
)
|
||||
|
||||
# The whole point of the fix: the resolver must never be touched
|
||||
# when a card was already supplied at registration time.
|
||||
mock_resolver_cls.assert_not_called()
|
||||
# The real, observable proof of the fix: no HTTP request went out at all.
|
||||
assert requests_made == []
|
||||
|
||||
mock_create_client.assert_awaited_once()
|
||||
called_card = mock_create_client.await_args.args[0]
|
||||
assert called_card.name == "agent"
|
||||
assert str(called_card.url) == "https://example.com/agents/a2a"
|
||||
resolved_card = a2a_client._litellm_agent_card
|
||||
assert resolved_card.name == "pre-registered-agent"
|
||||
assert resolved_card.supported_interfaces[0].url == BASE_URL
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_a2a_client_falls_back_to_discovery_without_agent_card_params():
|
||||
"""
|
||||
Unchanged behavior: when no agent_card_params is supplied, resolve the
|
||||
card via the well-known-path resolver, same as before the fix.
|
||||
Unchanged behavior: when no agent_card_params is supplied, the client
|
||||
resolves the card over HTTP from the well-known path, same as before
|
||||
the fix.
|
||||
"""
|
||||
requests_made: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests_made.append(str(request.url))
|
||||
if request.url.path == WELL_KNOWN_PATH:
|
||||
return httpx.Response(200, json=DISCOVERED_AGENT_CARD_JSON)
|
||||
return httpx.Response(404)
|
||||
|
||||
with (
|
||||
patch("litellm.a2a_protocol.main.get_async_httpx_client") as mock_get_httpx,
|
||||
patch("litellm.a2a_protocol.main.A2ACardResolver") as mock_resolver_cls,
|
||||
patch("litellm.a2a_protocol.main.create_client", new_callable=AsyncMock) as mock_create_client,
|
||||
patch(
|
||||
"litellm.a2a_protocol.main.normalize_agent_card_interfaces",
|
||||
side_effect=lambda card: card,
|
||||
),
|
||||
patch( # test-quality-ok: injects a real httpx.AsyncClient wired to httpx.MockTransport, not a mock of behavior
|
||||
"litellm.a2a_protocol.main.get_async_httpx_client",
|
||||
return_value=_mock_client(handler),
|
||||
)
|
||||
):
|
||||
mock_get_httpx.return_value.client = MagicMock()
|
||||
mock_resolver_instance = mock_resolver_cls.return_value
|
||||
mock_resolver_instance.get_agent_card = AsyncMock(return_value=MagicMock())
|
||||
mock_create_client.return_value = MagicMock()
|
||||
a2a_client = await create_a2a_client(base_url=BASE_URL)
|
||||
|
||||
await create_a2a_client(base_url="https://example.com/agents/a2a")
|
||||
assert any(WELL_KNOWN_PATH in url for url in requests_made)
|
||||
resolved_card = a2a_client._litellm_agent_card
|
||||
assert resolved_card.name == "discovered-agent"
|
||||
|
||||
mock_resolver_cls.assert_called_once()
|
||||
mock_resolver_instance.get_agent_card.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_a2a_client_falls_back_to_discovery_on_invalid_agent_card_params():
|
||||
"""
|
||||
If the stored agent_card_params doesn't validate as a full AgentCard
|
||||
(e.g. a partial card missing required fields), create_a2a_client must
|
||||
fall back to well-known-path discovery rather than raising.
|
||||
"""
|
||||
requests_made: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests_made.append(str(request.url))
|
||||
if request.url.path == WELL_KNOWN_PATH:
|
||||
return httpx.Response(200, json=DISCOVERED_AGENT_CARD_JSON)
|
||||
return httpx.Response(404)
|
||||
|
||||
incomplete_agent_card_params = {"url": BASE_URL} # missing required fields
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: injects a real httpx.AsyncClient wired to httpx.MockTransport, not a mock of behavior
|
||||
"litellm.a2a_protocol.main.get_async_httpx_client",
|
||||
return_value=_mock_client(handler),
|
||||
)
|
||||
):
|
||||
a2a_client = await create_a2a_client(
|
||||
base_url=BASE_URL,
|
||||
agent_card_params=incomplete_agent_card_params,
|
||||
)
|
||||
|
||||
assert any(WELL_KNOWN_PATH in url for url in requests_made)
|
||||
resolved_card = a2a_client._litellm_agent_card
|
||||
assert resolved_card.name == "discovered-agent"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue