From 9523a8f888ca02fb4ea166a0a24bfca486b785a4 Mon Sep 17 00:00:00 2001 From: AlphaRex-pixel Date: Fri, 11 Sep 2026 01:44:53 +0530 Subject: [PATCH] Fix agent_card_params validation to use protobuf-compatible AgentCard conversion, and harden regression tests to fake the HTTP boundary instead of patching internals --- litellm/a2a_protocol/main.py | 6 +- .../test_agent_card_params_skip_discovery.py | 148 +++++++++++++----- 2 files changed, 111 insertions(+), 43 deletions(-) diff --git a/litellm/a2a_protocol/main.py b/litellm/a2a_protocol/main.py index 176e67214dc..759dc4bbf76 100644 --- a/litellm/a2a_protocol/main.py +++ b/litellm/a2a_protocol/main.py @@ -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( diff --git a/tests/test_litellm/a2a_protocol/test_agent_card_params_skip_discovery.py b/tests/test_litellm/a2a_protocol/test_agent_card_params_skip_discovery.py index c402d31d008..61c368f9c14 100644 --- a/tests/test_litellm/a2a_protocol/test_agent_card_params_skip_discovery.py +++ b/tests/test_litellm/a2a_protocol/test_agent_card_params_skip_discovery.py @@ -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() \ No newline at end of file + +@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"