diff --git a/litellm/a2a_protocol/main.py b/litellm/a2a_protocol/main.py index 0e8b8136c19..759dc4bbf76 100644 --- a/litellm/a2a_protocol/main.py +++ b/litellm/a2a_protocol/main.py @@ -388,6 +388,7 @@ async def asend_message( 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: """ @@ -474,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) + 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 @@ -610,6 +615,7 @@ async def asend_message_streaming( 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]: """ @@ -699,6 +705,7 @@ async def asend_message_streaming( base_url=api_base, extra_headers=extra_headers, streaming=True, + agent_card_params=agent_card_params, ) assert a2a_client is not None @@ -745,6 +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, ) -> "A2AClientType": """ Create an A2A client for the given agent URL. @@ -787,10 +795,31 @@ 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 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: + 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( + "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..9877042f034 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..61c368f9c14 --- /dev/null +++ b/tests/test_litellm/a2a_protocol/test_agent_card_params_skip_discovery.py @@ -0,0 +1,148 @@ +""" +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 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": "pre-registered-agent", + "description": "A test assistant.", + "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"], + "defaultOutputModes": ["text"], + "skills": [], +} + + +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 client from it + directly instead of making a well-known-path HTTP request. + """ + requests_made: list[str] = [] + + 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 real, observable proof of the fix: no HTTP request went out at all. + assert requests_made == [] + + 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, 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( # 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) + + 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" + + +@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"