mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Merge 58977c2ca0 into aeede53112
This commit is contained in:
commit
638090d728
2 changed files with 119 additions and 10 deletions
|
|
@ -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))
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue