mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Merge e563c88cd3 into e9b1c11098
This commit is contained in:
commit
4a0cfd9596
3 changed files with 301 additions and 24 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue