This commit is contained in:
mlzerotoheroyoutube 2026-11-06 15:50:32 -05:00 • committed by GitHub
commit 638090d728
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 119 additions and 10 deletions

View file

@ -7,6 +7,7 @@ Extends the A2A SDK's card resolver to support multiple well-known paths.
from collections.abc import Mapping
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
@ -47,6 +48,14 @@ def _discovery_status_code(failures: tuple[tuple[str, Exception], ...]) -> int:
return statuses[0] if statuses else 404
def _origin_of_sub_path_url(url: str) -> str | None:
"""Return the origin of a URL that has a path, or None when the URL is already the origin."""
parts: Final = urlsplit(url)
if not parts.scheme or not parts.netloc or 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.
@ -192,6 +201,8 @@ class LiteLLMA2ACardResolver(_A2ACardResolver):
- /.well-known/agent-card.json (standard)
- /.well-known/agent.json (previous/alternative)
- /agentCard/v1.0
When the base URL has a path (an endpoint such as /jsonrpc), the two well-known paths are
then tried at the origin, where A2A 1.0 §8.2 places the card.
"""
async def get_agent_card(
@ -206,30 +217,45 @@ class LiteLLMA2ACardResolver(_A2ACardResolver):
http_kwargs=http_kwargs,
)
origin: Final = _origin_of_sub_path_url(self.base_url)
origin_attempts: Final = (
() if origin is None else ((origin, AGENT_CARD_WELL_KNOWN_PATH), (origin, PREV_AGENT_CARD_WELL_KNOWN_PATH))
)
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),
attempts=(
(None, AGENT_CARD_WELL_KNOWN_PATH),
(None, PREV_AGENT_CARD_WELL_KNOWN_PATH),
(None, FOUNDRY_AGENT_CARD_PATH),
*origin_attempts,
),
http_kwargs=http_kwargs,
failures=(),
)
async def _get_agent_card_from_first_reachable_path(
self,
paths: tuple[str, ...],
attempts: tuple[tuple[str | None, str], ...],
http_kwargs: Mapping[str, object] | None,
failures: tuple[tuple[str, Exception], ...],
) -> "AgentCard":
if not paths:
"""Try each (base, path) in order; a None base means the resolver's own base URL."""
if not attempts:
raise A2AAgentCardDiscoveryError(
base_url=self.base_url,
failures=failures,
status_code=_discovery_status_code(failures),
)
path: Final = paths[0]
base, path = attempts[0]
label: Final = path if base is None else f"{base}{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)
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("Attempting to fetch agent card from %s%s", base or self.base_url, path)
if base is None:
return await super().get_agent_card(relative_card_path=path, http_kwargs=http_kwargs)
return await _A2ACardResolver(httpx_client=self.httpx_client, base_url=base).get_agent_card(
relative_card_path=path, http_kwargs=http_kwargs
)
except Exception as e:
verbose_logger.debug("Failed to fetch agent card from %s%s: %s", base or self.base_url, path, e)
return await self._get_agent_card_from_first_reachable_path(
attempts=attempts[1:], http_kwargs=http_kwargs, failures=(*failures, (label, e))
)

View file

@ -315,3 +315,86 @@ 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
_RS_ORIGIN: Final = "http://rsagent:3000"
_RS_CARD_JSON: Final = {**_FOUNDRY_CARD_JSON, "name": "rs agent", "url": f"{_RS_ORIGIN}/jsonrpc"}
_NOT_FOUND: Final = (404, {"error": "not found"})
@pytest.mark.asyncio
async def test_card_resolver_falls_back_to_the_origin_well_known_path_for_a_sub_path_endpoint():
"""A2A 1.0 serves the card at the origin even when the JSON-RPC endpoint is a sub-path."""
httpx_client = _FakeHttpxClient(
base_url=_RS_ORIGIN,
responses={
"/jsonrpc/.well-known/agent-card.json": _NOT_FOUND,
"/jsonrpc/.well-known/agent.json": _NOT_FOUND,
"/jsonrpc/agentCard/v1.0": _NOT_FOUND,
"/.well-known/agent-card.json": (200, dict(_RS_CARD_JSON)),
},
)
resolver = LiteLLMA2ACardResolver(httpx_client=httpx_client, base_url=f"{_RS_ORIGIN}/jsonrpc")
result = await resolver.get_agent_card()
assert httpx_client.calls == [
"/jsonrpc/.well-known/agent-card.json",
"/jsonrpc/.well-known/agent.json",
"/jsonrpc/agentCard/v1.0",
"/.well-known/agent-card.json",
]
assert result.name == "rs agent"
@pytest.mark.asyncio
async def test_card_resolver_prefers_the_path_relative_card_over_the_origin():
httpx_client = _FakeHttpxClient(
base_url=_RS_ORIGIN,
responses={"/jsonrpc/.well-known/agent-card.json": (200, dict(_RS_CARD_JSON))},
)
resolver = LiteLLMA2ACardResolver(httpx_client=httpx_client, base_url=f"{_RS_ORIGIN}/jsonrpc")
await resolver.get_agent_card()
assert httpx_client.calls == ["/jsonrpc/.well-known/agent-card.json"]
@pytest.mark.asyncio
async def test_card_resolver_does_not_probe_the_origin_twice_when_the_base_url_is_the_origin():
httpx_client = _FakeHttpxClient(
base_url=_RS_ORIGIN,
responses={
"/.well-known/agent-card.json": _NOT_FOUND,
"/.well-known/agent.json": _NOT_FOUND,
"/agentCard/v1.0": _NOT_FOUND,
},
)
resolver = LiteLLMA2ACardResolver(httpx_client=httpx_client, base_url=f"{_RS_ORIGIN}/")
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"]
@pytest.mark.asyncio
async def test_card_resolver_names_the_origin_probes_when_discovery_fails():
httpx_client = _FakeHttpxClient(
base_url=_RS_ORIGIN,
responses={
"/jsonrpc/.well-known/agent-card.json": _NOT_FOUND,
"/jsonrpc/.well-known/agent.json": _NOT_FOUND,
"/jsonrpc/agentCard/v1.0": _NOT_FOUND,
"/.well-known/agent-card.json": _NOT_FOUND,
"/.well-known/agent.json": _NOT_FOUND,
},
)
resolver = LiteLLMA2ACardResolver(httpx_client=httpx_client, base_url=f"{_RS_ORIGIN}/jsonrpc")
with pytest.raises(A2AAgentCardDiscoveryError) as raised:
await resolver.get_agent_card()
assert raised.value.status_code == 404
assert f"{_RS_ORIGIN}/.well-known/agent-card.json (" in str(raised.value)
assert f"{_RS_ORIGIN}/.well-known/agent.json (" in str(raised.value)