diff --git a/litellm/a2a_protocol/card_resolver.py b/litellm/a2a_protocol/card_resolver.py index 441b326f4d2..512e2870874 100644 --- a/litellm/a2a_protocol/card_resolver.py +++ b/litellm/a2a_protocol/card_resolver.py @@ -7,9 +7,10 @@ Extends the A2A SDK's card resolver to support multiple well-known paths. from collections.abc import Mapping, Sequence from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Protocol, runtime_checkable +from urllib.parse import urlsplit from litellm._logging import verbose_logger -from litellm.a2a_protocol.exceptions import A2AAgentCardDiscoveryError +from litellm.a2a_protocol.exceptions import A2AAgentCardDiscoveryError, A2AAgentCardError from litellm.constants import LOCALHOST_URL_PATTERNS from litellm.litellm_core_utils.core_helpers import has_attributes @@ -18,18 +19,14 @@ if TYPE_CHECKING: # Runtime imports with availability check _A2ACardResolver: Any = None -AGENT_CARD_WELL_KNOWN_PATH: str = "/.well-known/agent-card.json" -PREV_AGENT_CARD_WELL_KNOWN_PATH: str = "/.well-known/agent.json" +AGENT_CARD_WELL_KNOWN_PATH: Final = "/.well-known/agent-card.json" +PREV_AGENT_CARD_WELL_KNOWN_PATH: Final = "/.well-known/agent.json" FOUNDRY_AGENT_CARD_PATH: Final = "/agentCard/v1.0" AGENT_CARD_PATH_PARAM: Final = "agent_card_path" A2A_PROTOCOL_VERSION_PARAM: Final = "a2a_protocol_version" try: from a2a.client import A2ACardResolver as _A2ACardResolver - from a2a.utils.constants import ( - AGENT_CARD_WELL_KNOWN_PATH, - PREV_AGENT_CARD_WELL_KNOWN_PATH, - ) except ImportError: pass @@ -48,6 +45,14 @@ def _discovery_status_code(failures: tuple[tuple[str, Exception], ...]) -> int: return statuses[0] if statuses else 404 +def _agent_origin(base_url: str) -> str | None: + """``scheme://host`` of an agent URL that has a path, where A2A 1.0 places the well-known card; None at the root.""" + parts: Final = urlsplit(base_url) + if not parts.path.strip("/"): + return None + return f"{parts.scheme}://{parts.netloc}" + + def is_localhost_or_internal_url(url: str | None) -> bool: """ Check if a URL is a localhost or internal URL. @@ -204,14 +209,38 @@ def fix_agent_card_url(agent_card: "AgentCard", base_url: str) -> "AgentCard": return agent_card +def _advertised_endpoints(agent_card: "AgentCard") -> tuple[str, ...]: + """Every interface URL of the card; the SDK folds a 0.3 card's ``url`` and ``additionalInterfaces`` into ``supported_interfaces``.""" + interfaces: Final = getattr(agent_card, "supported_interfaces", None) or () + urls: Final = (getattr(agent_card, "url", None), *(getattr(interface, "url", None) for interface in interfaces)) + return tuple(url.rstrip("/") for url in urls if url) + + +def _covers(endpoint: str, agent_url: str) -> bool: + """True when ``endpoint`` is ``agent_url`` or a path prefix of it (``http://h`` covers ``http://h/jsonrpc``).""" + agent: Final = agent_url.rstrip("/") + return agent == endpoint or agent.startswith(f"{endpoint}/") + + +_RELATIVE_CARD_PATHS: Final = (AGENT_CARD_WELL_KNOWN_PATH, PREV_AGENT_CARD_WELL_KNOWN_PATH, FOUNDRY_AGENT_CARD_PATH) +_ORIGIN_CARD_PATHS: Final = (AGENT_CARD_WELL_KNOWN_PATH, PREV_AGENT_CARD_WELL_KNOWN_PATH) + + class LiteLLMA2ACardResolver(_A2ACardResolver): """ Custom A2A card resolver that supports multiple well-known paths. - Extends the base A2ACardResolver to try, in order: + Extends the base A2ACardResolver to try, in order, relative to the agent URL: - /.well-known/agent-card.json (standard) - /.well-known/agent.json (previous/alternative) - /agentCard/v1.0 + + and then, when the agent URL has a path (its JSON-RPC endpoint is a sub-path such as + ``/jsonrpc``), the two well-known paths at the URL's origin, where A2A 1.0 places the card. + The origin probes carry none of the agent's headers, since the public card at the well-known + URI is unauthenticated by spec and the origin may host other applications. A card found there + is accepted only when one of its interface URLs is the agent URL or a path prefix of it, so a + host that serves several agents cannot hand this agent's messages and headers to another one. """ if TYPE_CHECKING: @@ -229,30 +258,53 @@ class LiteLLMA2ACardResolver(_A2ACardResolver): http_kwargs=http_kwargs, ) - return await self._get_agent_card_from_first_reachable_path( - paths=(AGENT_CARD_WELL_KNOWN_PATH, PREV_AGENT_CARD_WELL_KNOWN_PATH, FOUNDRY_AGENT_CARD_PATH), + origin: Final = _agent_origin(self.base_url) + return await self._get_agent_card_from_first_reachable_url( + candidates=( + *((self.base_url, path) for path in _RELATIVE_CARD_PATHS), + *(((origin, path) for path in _ORIGIN_CARD_PATHS) if origin else ()), + ), http_kwargs=http_kwargs, failures=(), ) - async def _get_agent_card_from_first_reachable_path( + async def _get_agent_card_from_first_reachable_url( self, - paths: tuple[str, ...], + candidates: tuple[tuple[str, str], ...], http_kwargs: Mapping[str, object] | None, failures: tuple[tuple[str, Exception], ...], ) -> "AgentCard": - if not paths: + if not candidates: raise A2AAgentCardDiscoveryError( base_url=self.base_url, failures=failures, status_code=_discovery_status_code(failures), ) - path: Final = paths[0] + base_url, path = candidates[0] + attempt: Final = path if base_url == self.base_url else f"{base_url}{path}" try: - verbose_logger.debug("Attempting to fetch agent card from %s%s", self.base_url, path) - return await super().get_agent_card(relative_card_path=path, http_kwargs=http_kwargs) + verbose_logger.debug("Attempting to fetch agent card from %s%s", base_url, path) + return await self._get_agent_card_at(base_url, path, http_kwargs) except Exception as e: - verbose_logger.debug("Failed to fetch agent card from %s%s: %s", self.base_url, path, e) - return await self._get_agent_card_from_first_reachable_path( - paths=paths[1:], http_kwargs=http_kwargs, failures=(*failures, (path, e)) + verbose_logger.debug("Failed to fetch agent card from %s%s: %s", base_url, path, e) + return await self._get_agent_card_from_first_reachable_url( + candidates=candidates[1:], http_kwargs=http_kwargs, failures=(*failures, (attempt, e)) ) + + async def _get_agent_card_at( + self, base_url: str, path: str, http_kwargs: Mapping[str, object] | None + ) -> "AgentCard": + if base_url == self.base_url: + return await super().get_agent_card(relative_card_path=path, http_kwargs=http_kwargs) + origin_resolver: Final = _A2ACardResolver(httpx_client=self.httpx_client, base_url=base_url) + card: Final = await origin_resolver.get_agent_card(relative_card_path=path) + endpoints: Final = _advertised_endpoints(card) + if not any(_covers(endpoint, self.base_url) for endpoint in endpoints): + raise A2AAgentCardError( + message=( + f"the card at the origin advertises {endpoints!r}, none of which is the agent URL " + f"{self.base_url!r} or a path prefix of it" + ), + url=f"{base_url}{path}", + ) + return card diff --git a/tests/unit/a2a_protocol/test_card_resolver.py b/tests/unit/a2a_protocol/test_card_resolver.py index cdf651bd259..78a58581889 100644 --- a/tests/unit/a2a_protocol/test_card_resolver.py +++ b/tests/unit/a2a_protocol/test_card_resolver.py @@ -7,6 +7,7 @@ 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 +from urllib.parse import urlsplit import httpx import pytest @@ -259,17 +260,31 @@ _FOUNDRY_CARD_JSON: Final = { class _FakeHttpxClient: - """Answers GETs from a path -> (status, body) map and records the path of each call.""" + """Answers GETs under ``base_url`` from a path -> (status, body) map, and GETs at the URL's + origin from ``origin_responses``; records the path (or the full origin URL) of each call.""" - def __init__(self, base_url: str, responses: dict[str, tuple[int, dict[str, Any]]]) -> None: + def __init__( + self, + base_url: str, + responses: dict[str, tuple[int, dict[str, Any]]], + origin_responses: dict[str, tuple[int, dict[str, Any]]] | None = None, + ) -> None: self._base_url = base_url.rstrip("/") + self._origin = f"{urlsplit(base_url).scheme}://{urlsplit(base_url).netloc}" self._responses = responses + self._origin_responses = origin_responses or {} self.calls: list[str] = [] + self.headers_sent: list[Any] = [] 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] + self.headers_sent.append(kwargs.get("headers")) + if url.startswith(self._base_url + "/"): + self.calls.append(url.removeprefix(self._base_url)) + status_code, body = self._responses[url.removeprefix(self._base_url)] + else: + assert url.startswith(self._origin + "/"), url + self.calls.append(url) + status_code, body = self._origin_responses[url.removeprefix(self._origin)] return httpx.Response(status_code, json=body, request=httpx.Request("GET", url)) @@ -315,6 +330,10 @@ async def test_card_resolver_names_every_probed_path_when_discovery_fails(): "/.well-known/agent.json": (401, {"error": "unauthorized"}), "/agentCard/v1.0": (404, {"error": "not found"}), }, + origin_responses={ + "/.well-known/agent-card.json": (404, {"error": "not found"}), + "/.well-known/agent.json": (404, {"error": "not found"}), + }, ) resolver = LiteLLMA2ACardResolver(httpx_client=httpx_client, base_url=_FOUNDRY_BASE_URL) @@ -327,6 +346,7 @@ async def test_card_resolver_names_every_probed_path_when_discovery_fails(): 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 + assert "https://foundry.example.com/.well-known/agent-card.json (" in message @pytest.mark.asyncio @@ -339,6 +359,10 @@ async def test_card_resolver_discovery_error_is_404_when_every_probe_is_404(): "/.well-known/agent.json": (404, {"error": "not found"}), "/agentCard/v1.0": (404, {"error": "not found"}), }, + origin_responses={ + "/.well-known/agent-card.json": (404, {"error": "not found"}), + "/.well-known/agent.json": (404, {"error": "not found"}), + }, ), base_url=_FOUNDRY_BASE_URL, ) @@ -347,3 +371,173 @@ async def test_card_resolver_discovery_error_is_404_when_every_probe_is_404(): await resolver.get_agent_card() assert raised.value.status_code == 404 + + +_SUBPATH_AGENT_URL: Final = "http://rsagent:3000/jsonrpc" + +_SUBPATH_CARD_JSON: Final = { + **_FOUNDRY_CARD_JSON, + "name": "rspath", + "url": _SUBPATH_AGENT_URL, + "supportedInterfaces": [{"url": _SUBPATH_AGENT_URL, "protocolBinding": "JSONRPC", "protocolVersion": "1.0"}], +} + + +_NO_CARD_UNDER_THE_AGENT_PATH: Final = { + "/.well-known/agent-card.json": (404, {"error": "not found"}), + "/.well-known/agent.json": (404, {"error": "not found"}), + "/agentCard/v1.0": (404, {"error": "not found"}), +} + + +def _subpath_card_json(endpoint: str) -> dict[str, Any]: + return { + **_SUBPATH_CARD_JSON, + "url": endpoint, + "supportedInterfaces": [{"url": endpoint, "protocolBinding": "JSONRPC", "protocolVersion": "1.0"}], + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "card_endpoint", + [_SUBPATH_AGENT_URL, _SUBPATH_AGENT_URL + "/", "http://rsagent:3000", "http://rsagent:3000/"], + ids=["agent url", "agent url with slash", "origin root", "origin root with slash"], +) +async def test_card_resolver_falls_back_to_the_well_known_card_at_the_agents_origin(card_endpoint): + """A2A 1.0 places the card at ``{origin}/.well-known/agent-card.json`` while the JSON-RPC + endpoint is routinely a sub-path, so an agent registered with that endpoint as its URL + must still be resolvable, from a card that names that endpoint or a path prefix of it.""" + httpx_client = _FakeHttpxClient( + base_url=_SUBPATH_AGENT_URL, + responses=dict(_NO_CARD_UNDER_THE_AGENT_PATH), + origin_responses={"/.well-known/agent-card.json": (200, _subpath_card_json(card_endpoint))}, + ) + + resolver = LiteLLMA2ACardResolver(httpx_client=httpx_client, base_url=_SUBPATH_AGENT_URL) + result = await resolver.get_agent_card() + + assert httpx_client.calls[-1] == "http://rsagent:3000/.well-known/agent-card.json" + assert result.name == "rspath" + assert result.supported_interfaces[0].url == card_endpoint + + +@pytest.mark.asyncio +async def test_card_resolver_keeps_the_agents_headers_off_the_origin_probes(): + """The public card at the origin's well-known URI is unauthenticated by spec, and the origin + may host other applications, so the agent's own headers only go to the agent's paths.""" + httpx_client = _FakeHttpxClient( + base_url=_SUBPATH_AGENT_URL, + responses=dict(_NO_CARD_UNDER_THE_AGENT_PATH), + origin_responses={"/.well-known/agent-card.json": (200, _subpath_card_json(_SUBPATH_AGENT_URL))}, + ) + agent_headers = {"x-agent-token": "secret-for-this-agent"} + + resolver = LiteLLMA2ACardResolver(httpx_client=httpx_client, base_url=_SUBPATH_AGENT_URL) + await resolver.get_agent_card(http_kwargs={"headers": agent_headers}) + + assert httpx_client.calls[-1] == "http://rsagent:3000/.well-known/agent-card.json" + assert httpx_client.headers_sent[:-1] == [agent_headers] * 3 + assert httpx_client.headers_sent[-1] is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "card_json", + [ + { + **_subpath_card_json("http://rsagent:3000/grpc"), + "supportedInterfaces": [ + {"url": "http://rsagent:3000/grpc", "protocolBinding": "GRPC", "protocolVersion": "1.0"}, + {"url": _SUBPATH_AGENT_URL, "protocolBinding": "JSONRPC", "protocolVersion": "1.0"}, + ], + }, + { + **_FOUNDRY_CARD_JSON, + "name": "rspath", + "url": "http://rsagent:3000/grpc", + "preferredTransport": "GRPC", + "additionalInterfaces": [{"url": _SUBPATH_AGENT_URL, "transport": "JSONRPC"}], + }, + ], + ids=["supportedInterfaces", "additionalInterfaces"], +) +async def test_card_resolver_accepts_an_origin_card_that_names_the_agent_url_in_a_later_interface(card_json): + """The agent URL may be any of the card's interfaces, not only the first one.""" + httpx_client = _FakeHttpxClient( + base_url=_SUBPATH_AGENT_URL, + responses=dict(_NO_CARD_UNDER_THE_AGENT_PATH), + origin_responses={"/.well-known/agent-card.json": (200, card_json)}, + ) + + resolver = LiteLLMA2ACardResolver(httpx_client=httpx_client, base_url=_SUBPATH_AGENT_URL) + result = await resolver.get_agent_card() + + assert httpx_client.calls[-1] == "http://rsagent:3000/.well-known/agent-card.json" + assert result.name == "rspath" + assert [interface.url for interface in result.supported_interfaces] == ["http://rsagent:3000/grpc", _SUBPATH_AGENT_URL] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "rejected_endpoint", + ["http://rsagent:3000/other", "http://rsagent:3000/json", "http://elsewhere:9/jsonrpc"], + ids=["sibling path", "string prefix but not a path prefix", "other host"], +) +async def test_card_resolver_rejects_an_origin_card_that_advertises_another_endpoint(rejected_endpoint): + """On a host serving several agents the origin card can belong to another agent, and its + endpoint would receive this agent's messages and headers, so it is only taken when one of its + interfaces names the configured agent URL or a path prefix of it; otherwise the card counts + as not found.""" + httpx_client = _FakeHttpxClient( + base_url=_SUBPATH_AGENT_URL, + responses=dict(_NO_CARD_UNDER_THE_AGENT_PATH), + origin_responses={ + "/.well-known/agent-card.json": (200, _subpath_card_json(rejected_endpoint)), + "/.well-known/agent.json": (404, {"error": "not found"}), + }, + ) + + resolver = LiteLLMA2ACardResolver(httpx_client=httpx_client, base_url=_SUBPATH_AGENT_URL) + with pytest.raises(A2AAgentCardDiscoveryError) as raised: + await resolver.get_agent_card() + + assert raised.value.status_code == 404 + message = str(raised.value) + assert "http://rsagent:3000/.well-known/agent-card.json (" in message and repr(rejected_endpoint) in message + + +@pytest.mark.asyncio +async def test_card_resolver_prefers_the_card_relative_to_the_agent_url(): + """Deployments that serve the card under the agent path keep resolving without an extra + round trip, and a host that serves a different card at its origin never wins over them.""" + httpx_client = _FakeHttpxClient( + base_url=_SUBPATH_AGENT_URL, + responses={"/.well-known/agent-card.json": (200, dict(_SUBPATH_CARD_JSON))}, + origin_responses={"/.well-known/agent-card.json": (200, {**_SUBPATH_CARD_JSON, "name": "other agent"})}, + ) + + resolver = LiteLLMA2ACardResolver(httpx_client=httpx_client, base_url=_SUBPATH_AGENT_URL) + result = await resolver.get_agent_card() + + assert httpx_client.calls == ["/.well-known/agent-card.json"] + assert result.name == "rspath" + + +@pytest.mark.asyncio +async def test_card_resolver_probes_only_once_when_the_agent_url_is_the_origin(): + httpx_client = _FakeHttpxClient( + base_url="http://rsagent:3000", + 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"}), + }, + ) + + resolver = LiteLLMA2ACardResolver(httpx_client=httpx_client, base_url="http://rsagent:3000/") + with pytest.raises(A2AAgentCardDiscoveryError): + await resolver.get_agent_card() + + assert httpx_client.calls == ["/.well-known/agent-card.json", "/.well-known/agent.json", "/agentCard/v1.0"] + diff --git a/tests/unit/a2a_protocol/test_main.py b/tests/unit/a2a_protocol/test_main.py index c0cabee1127..2697254f6d3 100644 --- a/tests/unit/a2a_protocol/test_main.py +++ b/tests/unit/a2a_protocol/test_main.py @@ -334,6 +334,7 @@ class _RequestRecorder: self.card_requests = [] self.card_urls = [] self.rpc_requests = [] + self.rpc_urls = [] self.rpc_bodies = [] self.client = None @@ -344,6 +345,7 @@ class _RequestRecorder: self.card_urls.append(str(request.url)) return httpx.Response(200, json=self.card) self.rpc_requests.append(headers) + self.rpc_urls.append(str(request.url)) self.rpc_bodies.append(json.loads(request.content)) if self.rpc_stream_events is not None: body = "".join(f"data: {json.dumps(event)}\n\n" for event in self.rpc_stream_events) @@ -646,6 +648,35 @@ async def test_agent_card_path_param_fetches_that_path_with_the_agents_headers(i assert recorder.card_requests[-1]["x-agent-token"] == "token-for-a" +_SUBPATH_AGENT_URL = "http://127.0.0.1:9/jsonrpc" +_SUBPATH_AGENT_CARD = {**_AGENT_CARD, "url": _SUBPATH_AGENT_URL} + + +class _CardAtOriginRecorder(_RequestRecorder): + """An agent whose JSON-RPC endpoint is a sub-path and whose card sits at the origin, as A2A 1.0 places it.""" + + def __init__(self) -> None: + super().__init__(card=_SUBPATH_AGENT_CARD) + + def __call__(self, request: httpx.Request) -> httpx.Response: + if request.method == "GET" and request.url.path != "/.well-known/agent-card.json": + self.card_urls.append(str(request.url)) + return httpx.Response(404, json={"error": "not found"}, request=request) + return super().__call__(request) + + +@pytest.mark.asyncio +async def test_asend_message_resolves_the_card_at_the_origin_of_a_sub_path_agent_url(isolated_client_cache): + recorder = await _seed_shared_a2a_client(recorder=_CardAtOriginRecorder()) + + response = await asend_message(request=_send_request("req-subpath"), api_base=_SUBPATH_AGENT_URL) + + assert recorder.card_urls[-1] == "http://127.0.0.1:9/.well-known/agent-card.json" + assert response.error is None + assert recorder.rpc_bodies[-1]["method"] == "message/send" + assert recorder.rpc_urls[-1] == _SUBPATH_AGENT_URL + + @pytest.mark.asyncio async def test_aget_agent_card_carries_the_callers_headers_and_path(isolated_client_cache): recorder = await _seed_shared_a2a_client(provider=httpxSpecialProvider.A2A)