From ce8b11639611d9e1c4d69577cb3e91c92f9cc0a7 Mon Sep 17 00:00:00 2001 From: AlphaRex-pixel Date: Fri, 11 Sep 2026 00:30:38 +0530 Subject: [PATCH 1/4] Fix: use registered agent_card_params for A2A message/send instead of re-discovering via well-known paths (#40586) --- litellm/a2a_protocol/main.py | 51 ++++++++---- .../proxy/agent_endpoints/a2a_endpoints.py | 4 + .../test_agent_card_params_skip_discovery.py | 82 +++++++++++++++++++ 3 files changed, 121 insertions(+), 16 deletions(-) create mode 100644 tests/test_litellm/a2a_protocol/test_agent_card_params_skip_discovery.py diff --git a/litellm/a2a_protocol/main.py b/litellm/a2a_protocol/main.py index 0e8b8136c19..a88cca0cda0 100644 --- a/litellm/a2a_protocol/main.py +++ b/litellm/a2a_protocol/main.py @@ -387,9 +387,7 @@ async def asend_message( api_base: str | None = None, litellm_params: dict[str, object] | None = None, agent_id: str | None = None, - agent_extra_headers: dict[str, str] | None = None, - **kwargs: object, -) -> LiteLLMSendMessageResponse: + agent_extra_headers: dict[str, str] | None = None, agent_card_params: dict[str, object] | None = None, **kwargs: object, ) -> LiteLLMSendMessageResponse: """ Async: Send a message to an A2A agent. @@ -474,7 +472,7 @@ async def asend_message( # Overlay agent-level headers (agent headers take precedence over LiteLLM internal ones) if agent_extra_headers: extra_headers.update(agent_extra_headers) - a2a_client = await create_a2a_client(base_url=api_base, extra_headers=extra_headers) + a2a_client = await create_a2a_client( base_url=api_base, extra_headers=extra_headers, agent_card_params=agent_card_params, ) # Type assertion: a2a_client is guaranteed to be non-None here assert a2a_client is not None @@ -609,9 +607,9 @@ async def asend_message_streaming( agent_id: str | None = None, metadata: dict[str, object] | None = None, proxy_server_request: dict[str, object] | None = None, - agent_extra_headers: dict[str, str] | None = None, - **kwargs: object, -) -> AsyncIterator[Any]: + agent_extra_headers: dict[str, str] | None = None, + agent_card_params: dict[str, object] | None = None, + **kwargs: object, ) -> AsyncIterator[Any]: """ Async: Send a streaming message to an A2A agent. @@ -695,11 +693,7 @@ async def asend_message_streaming( extra_headers["X-LiteLLM-Agent-Id"] = agent_id if agent_extra_headers: extra_headers.update(agent_extra_headers) - a2a_client = await create_a2a_client( - base_url=api_base, - extra_headers=extra_headers, - streaming=True, - ) + a2a_client = await create_a2a_client( base_url=api_base, extra_headers=extra_headers, streaming=True, agent_card_params=agent_card_params, ) assert a2a_client is not None @@ -745,6 +739,7 @@ async def create_a2a_client( timeout: float = DEFAULT_A2A_AGENT_TIMEOUT, extra_headers: dict[str, str] | None = None, streaming: bool = False, + agent_card_params: dict[str, object] | None = None, ) -> "A2AClientType": """ Create an A2A client for the given agent URL. @@ -787,10 +782,34 @@ async def create_a2a_client( if extra_headers: verbose_proxy_logger.debug("A2A client created with extra_headers=%s", list(extra_headers.keys())) - resolver: Final = A2ACardResolver(httpx_client=httpx_client, base_url=base_url) - agent_card: Final = normalize_agent_card_interfaces( - await resolver.get_agent_card(http_kwargs={"headers": extra_headers} if extra_headers else None) - ) + agent_card: "AgentCard | None" = None + + if agent_card_params: + from pydantic import ValidationError as _ValidationError + + from a2a.compat.v0_3.types import AgentCard as _AgentCard + + try: + agent_card = normalize_agent_card_interfaces( + _AgentCard.model_validate(agent_card_params) + ) + verbose_logger.info( + "Using pre-registered agent card for %s (skipping well-known discovery)", base_url + ) + except _ValidationError as e: + verbose_logger.warning( + "Stored agent_card_params for %s failed AgentCard validation (%s); " + "falling back to well-known discovery.", + base_url, + e, + ) + agent_card = None + + if agent_card is None: + resolver: Final = A2ACardResolver(httpx_client=httpx_client, base_url=base_url) + agent_card = normalize_agent_card_interfaces( + await resolver.get_agent_card(http_kwargs={"headers": extra_headers} if extra_headers else None) + ) a2a_client: Final = await create_client( # pyright: ignore[reportOptionalCall] agent_card, diff --git a/litellm/proxy/agent_endpoints/a2a_endpoints.py b/litellm/proxy/agent_endpoints/a2a_endpoints.py index 95c34f70d7b..18ab3e6087d 100644 --- a/litellm/proxy/agent_endpoints/a2a_endpoints.py +++ b/litellm/proxy/agent_endpoints/a2a_endpoints.py @@ -404,6 +404,7 @@ async def _handle_stream_message( user_api_key_dict: UserAPIKeyAuth | None = None, request_data: dict[str, object] | None = None, proxy_logging_obj: ProxyLogging | None = None, + agent_card_params: dict[str, object] | None = None, served_version: A2AVersion = "0.3", ) -> StreamingResponse: """Handle message/stream method via SDK functions. @@ -474,6 +475,7 @@ async def _handle_stream_message( metadata=metadata, proxy_server_request=proxy_server_request, agent_extra_headers=agent_extra_headers, + agent_card_params=agent_card_params, ) if ( @@ -863,6 +865,7 @@ async def invoke_agent_a2a( proxy_server_request=data.get("proxy_server_request"), litellm_logging_obj=logging_obj, agent_extra_headers=agent_extra_headers, + agent_card_params=agent_card_params or None, ) try: @@ -904,6 +907,7 @@ async def invoke_agent_a2a( agent_extra_headers=agent_extra_headers, user_api_key_dict=user_api_key_dict, request_data=data, + agent_card_params=agent_card_params or None, proxy_logging_obj=proxy_logging_obj, served_version=served_version, ) 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 new file mode 100644 index 00000000000..c402d31d008 --- /dev/null +++ b/tests/test_litellm/a2a_protocol/test_agent_card_params_skip_discovery.py @@ -0,0 +1,82 @@ +""" +Regression tests for GH #40586: message/send re-discovered the agent card +from well-known paths instead of using the registered agent_card_params. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from litellm.a2a_protocol.main import create_a2a_client + +MINIMAL_AGENT_CARD_PARAMS = { + "protocolVersion": "1.0", + "name": "agent", + "description": "A test assistant.", + "url": "https://example.com/agents/a2a", + "version": "1.0", + "capabilities": {"streaming": False}, + "defaultInputModes": ["text"], + "defaultOutputModes": ["text"], + "skills": [], +} + + +@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. + """ + 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() + + await create_a2a_client( + base_url="https://example.com/agents/a2a", + 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() + + 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" + + +@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. + """ + 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_resolver_instance = mock_resolver_cls.return_value + mock_resolver_instance.get_agent_card = AsyncMock(return_value=MagicMock()) + mock_create_client.return_value = MagicMock() + + await create_a2a_client(base_url="https://example.com/agents/a2a") + + mock_resolver_cls.assert_called_once() + mock_resolver_instance.get_agent_card.assert_awaited_once() \ No newline at end of file From c39b23a5d53f1e7fb277d34944969283ca0c1ac7 Mon Sep 17 00:00:00 2001 From: AlphaRex-pixel Date: Fri, 11 Sep 2026 00:41:54 +0530 Subject: [PATCH 2/4] Apply ruff format --- litellm/a2a_protocol/main.py | 37 ++++++++++++------- .../proxy/agent_endpoints/a2a_endpoints.py | 2 +- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/litellm/a2a_protocol/main.py b/litellm/a2a_protocol/main.py index a88cca0cda0..07ee9d1cbe4 100644 --- a/litellm/a2a_protocol/main.py +++ b/litellm/a2a_protocol/main.py @@ -387,7 +387,10 @@ async def asend_message( api_base: str | None = None, litellm_params: dict[str, object] | None = None, agent_id: str | None = None, - agent_extra_headers: dict[str, str] | None = None, agent_card_params: dict[str, object] | None = None, **kwargs: object, ) -> LiteLLMSendMessageResponse: + agent_extra_headers: dict[str, str] | None = None, + agent_card_params: dict[str, object] | None = None, + **kwargs: object, +) -> LiteLLMSendMessageResponse: """ Async: Send a message to an A2A agent. @@ -472,7 +475,11 @@ async def asend_message( # Overlay agent-level headers (agent headers take precedence over LiteLLM internal ones) if agent_extra_headers: extra_headers.update(agent_extra_headers) - a2a_client = await create_a2a_client( base_url=api_base, extra_headers=extra_headers, agent_card_params=agent_card_params, ) + a2a_client = await create_a2a_client( + base_url=api_base, + extra_headers=extra_headers, + agent_card_params=agent_card_params, + ) # Type assertion: a2a_client is guaranteed to be non-None here assert a2a_client is not None @@ -607,9 +614,10 @@ async def asend_message_streaming( agent_id: str | None = None, metadata: dict[str, object] | None = None, proxy_server_request: dict[str, object] | None = None, - agent_extra_headers: dict[str, str] | None = None, - agent_card_params: dict[str, object] | None = None, - **kwargs: object, ) -> AsyncIterator[Any]: + agent_extra_headers: dict[str, str] | None = None, + agent_card_params: dict[str, object] | None = None, + **kwargs: object, +) -> AsyncIterator[Any]: """ Async: Send a streaming message to an A2A agent. @@ -693,7 +701,12 @@ async def asend_message_streaming( extra_headers["X-LiteLLM-Agent-Id"] = agent_id if agent_extra_headers: extra_headers.update(agent_extra_headers) - a2a_client = await create_a2a_client( base_url=api_base, extra_headers=extra_headers, streaming=True, agent_card_params=agent_card_params, ) + a2a_client = await create_a2a_client( + base_url=api_base, + extra_headers=extra_headers, + streaming=True, + agent_card_params=agent_card_params, + ) assert a2a_client is not None @@ -739,7 +752,7 @@ async def create_a2a_client( timeout: float = DEFAULT_A2A_AGENT_TIMEOUT, extra_headers: dict[str, str] | None = None, streaming: bool = False, - agent_card_params: dict[str, object] | None = None, + agent_card_params: dict[str, object] | None = None, ) -> "A2AClientType": """ Create an A2A client for the given agent URL. @@ -783,19 +796,15 @@ async def create_a2a_client( verbose_proxy_logger.debug("A2A client created with extra_headers=%s", list(extra_headers.keys())) agent_card: "AgentCard | None" = None - + if agent_card_params: from pydantic import ValidationError as _ValidationError from a2a.compat.v0_3.types import AgentCard as _AgentCard try: - agent_card = normalize_agent_card_interfaces( - _AgentCard.model_validate(agent_card_params) - ) - verbose_logger.info( - "Using pre-registered agent card for %s (skipping well-known discovery)", base_url - ) + agent_card = normalize_agent_card_interfaces(_AgentCard.model_validate(agent_card_params)) + verbose_logger.info("Using pre-registered agent card for %s (skipping well-known discovery)", base_url) except _ValidationError as e: verbose_logger.warning( "Stored agent_card_params for %s failed AgentCard validation (%s); " diff --git a/litellm/proxy/agent_endpoints/a2a_endpoints.py b/litellm/proxy/agent_endpoints/a2a_endpoints.py index 18ab3e6087d..9877042f034 100644 --- a/litellm/proxy/agent_endpoints/a2a_endpoints.py +++ b/litellm/proxy/agent_endpoints/a2a_endpoints.py @@ -475,7 +475,7 @@ async def _handle_stream_message( metadata=metadata, proxy_server_request=proxy_server_request, agent_extra_headers=agent_extra_headers, - agent_card_params=agent_card_params, + agent_card_params=agent_card_params, ) if ( From 22c9514b58ef7850cdff5e08e494a2167c8b697f Mon Sep 17 00:00:00 2001 From: AlphaRex-pixel Date: Fri, 11 Sep 2026 00:47:36 +0530 Subject: [PATCH 3/4] Fix ruff lint: unquote AgentCard annotation, sort imports --- litellm/a2a_protocol/main.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/litellm/a2a_protocol/main.py b/litellm/a2a_protocol/main.py index 07ee9d1cbe4..176e67214dc 100644 --- a/litellm/a2a_protocol/main.py +++ b/litellm/a2a_protocol/main.py @@ -795,12 +795,11 @@ async def create_a2a_client( if extra_headers: verbose_proxy_logger.debug("A2A client created with extra_headers=%s", list(extra_headers.keys())) - agent_card: "AgentCard | None" = None + agent_card: AgentCard | None = None if agent_card_params: - from pydantic import ValidationError as _ValidationError - from a2a.compat.v0_3.types import AgentCard as _AgentCard + from pydantic import ValidationError as _ValidationError try: agent_card = normalize_agent_card_interfaces(_AgentCard.model_validate(agent_card_params)) From 9523a8f888ca02fb4ea166a0a24bfca486b785a4 Mon Sep 17 00:00:00 2001 From: AlphaRex-pixel Date: Fri, 11 Sep 2026 01:44:53 +0530 Subject: [PATCH 4/4] 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"