mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(a2a): speak the 0.3 dialect to servers with mis-cased protocol bindings
This commit is contained in:
parent
de0d8ceb25
commit
fe567bd846
4 changed files with 84 additions and 15 deletions
|
|
@ -57,22 +57,32 @@ _CANONICAL_PROTOCOL_BINDINGS: Final = MappingProxyType(
|
|||
}
|
||||
)
|
||||
|
||||
_LEGACY_PROTOCOL_VERSION: Final = "0.3"
|
||||
|
||||
def normalize_agent_card_protocol_bindings(agent_card: "AgentCard") -> "AgentCard":
|
||||
|
||||
def normalize_agent_card_interfaces(agent_card: "AgentCard") -> "AgentCard":
|
||||
"""
|
||||
Canonicalize protocolBinding casing on the card's supported interfaces.
|
||||
Canonicalize the supported interfaces of spec-adjacent agent cards.
|
||||
|
||||
Some A2A servers (e.g. LangGraph Platform) serve agent cards with lowercase
|
||||
bindings like "jsonrpc", but a2a-sdk's ClientFactory matches bindings
|
||||
case-sensitively against its uppercase TransportProtocol constants and fails
|
||||
with "no compatible transports found." for spec-adjacent casings.
|
||||
|
||||
The same servers also speak the A2A 0.3 JSON dialect ("kind"-discriminated
|
||||
payloads) while declaring protocolVersion "1.0", which a2a-sdk's strict v1
|
||||
proto parsing rejects. A mis-cased binding fingerprints such a server, so its
|
||||
declared version is downgraded to 0.3 to route the SDK's ClientFactory onto
|
||||
its v0.3 compat transport, which speaks that dialect.
|
||||
"""
|
||||
normalized: Final = type(agent_card)()
|
||||
normalized.CopyFrom(agent_card)
|
||||
for interface in normalized.supported_interfaces:
|
||||
canonical: str | None = _CANONICAL_PROTOCOL_BINDINGS.get(interface.protocol_binding.lower())
|
||||
if canonical is not None:
|
||||
interface.protocol_binding = canonical
|
||||
if canonical is None or canonical == interface.protocol_binding:
|
||||
continue
|
||||
interface.protocol_binding = canonical
|
||||
interface.protocol_version = _LEGACY_PROTOCOL_VERSION
|
||||
return normalized
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ except ImportError:
|
|||
from litellm.a2a_protocol.card_resolver import (
|
||||
LiteLLMA2ACardResolver,
|
||||
get_agent_card_url,
|
||||
normalize_agent_card_protocol_bindings,
|
||||
normalize_agent_card_interfaces,
|
||||
)
|
||||
from litellm.a2a_protocol.exception_mapping_utils import (
|
||||
handle_a2a_localhost_retry,
|
||||
|
|
@ -784,7 +784,7 @@ async def create_a2a_client(
|
|||
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_protocol_bindings(
|
||||
agent_card: Final = normalize_agent_card_interfaces(
|
||||
await resolver.get_agent_card(http_kwargs={"headers": extra_headers} if extra_headers else None)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from litellm.a2a_protocol.card_resolver import (
|
|||
LiteLLMA2ACardResolver,
|
||||
fix_agent_card_url,
|
||||
is_localhost_or_internal_url,
|
||||
normalize_agent_card_interfaces,
|
||||
set_agent_card_url,
|
||||
)
|
||||
|
||||
|
|
@ -114,3 +115,26 @@ def test_fix_agent_card_url_updates_interface_when_top_level_is_localhost():
|
|||
|
||||
assert result.url == "https://my-public-agent.example.com/"
|
||||
assert result.supported_interfaces[0].url == "https://my-public-agent.example.com/"
|
||||
|
||||
|
||||
def test_normalize_agent_card_interfaces_downgrades_miscased_interfaces_to_the_0_3_dialect():
|
||||
pb2 = pytest.importorskip("a2a.types.a2a_pb2")
|
||||
|
||||
card = pb2.AgentCard(
|
||||
name="langgraph",
|
||||
supported_interfaces=[
|
||||
pb2.AgentInterface(url="http://a/", protocol_binding="jsonrpc", protocol_version="1.0"),
|
||||
pb2.AgentInterface(url="http://b/", protocol_binding="JSONRPC", protocol_version="1.0"),
|
||||
pb2.AgentInterface(url="http://c/", protocol_binding="websocket", protocol_version="1.0"),
|
||||
],
|
||||
)
|
||||
|
||||
normalized = normalize_agent_card_interfaces(card)
|
||||
|
||||
assert [(i.protocol_binding, i.protocol_version) for i in normalized.supported_interfaces] == [
|
||||
("JSONRPC", "0.3"),
|
||||
("JSONRPC", "1.0"),
|
||||
("websocket", "1.0"),
|
||||
]
|
||||
assert card.supported_interfaces[0].protocol_binding == "jsonrpc"
|
||||
assert card.supported_interfaces[0].protocol_version == "1.0"
|
||||
|
|
|
|||
|
|
@ -176,10 +176,40 @@ _AGENT_A_HEADERS = {"x-agent-token": "token-for-a", "x-tenant": "tenant-a"}
|
|||
_AGENT_B_HEADERS = {"x-agent-token": "token-for-b", "x-tenant": "tenant-b"}
|
||||
|
||||
|
||||
_V1_RPC_REPLY = {
|
||||
_LANGGRAPH_TASK_REPLY = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "reply",
|
||||
"result": {"message": {"messageId": "reply-1", "role": "ROLE_AGENT", "parts": [{"text": "pong"}]}},
|
||||
"result": {
|
||||
"kind": "task",
|
||||
"id": "run-1:task-1",
|
||||
"contextId": "thread-1",
|
||||
"history": [
|
||||
{
|
||||
"kind": "message",
|
||||
"role": "user",
|
||||
"parts": [{"kind": "text", "text": "hi"}],
|
||||
"messageId": "m-user",
|
||||
"taskId": "run-1:task-1",
|
||||
"contextId": "thread-1",
|
||||
},
|
||||
{
|
||||
"kind": "message",
|
||||
"role": "agent",
|
||||
"parts": [{"kind": "text", "text": "langgraph echo: hi"}],
|
||||
"messageId": "m-agent",
|
||||
"taskId": "run-1:task-1",
|
||||
"contextId": "thread-1",
|
||||
},
|
||||
],
|
||||
"status": {"state": "completed", "timestamp": "2026-08-24T00:00:00+00:00"},
|
||||
"artifacts": [
|
||||
{
|
||||
"artifactId": "art-1",
|
||||
"name": "Assistant Response",
|
||||
"parts": [{"kind": "text", "text": "langgraph echo: hi"}],
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -334,17 +364,22 @@ async def test_streaming_send_carries_only_its_own_caller_headers(isolated_clien
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lowercase_protocol_binding_in_agent_card_still_gets_a_client(isolated_client_cache):
|
||||
"""LangGraph Platform serves cards with protocolBinding "jsonrpc"; a2a-sdk matches
|
||||
bindings case-sensitively, so without normalization client creation raises
|
||||
ValueError("no compatible transports found.")."""
|
||||
await _seed_shared_a2a_client(card=_LOWERCASE_BINDING_CARD, rpc_reply=_V1_RPC_REPLY)
|
||||
async def test_lowercase_protocol_binding_card_round_trips_the_langgraph_dialect(isolated_client_cache):
|
||||
"""LangGraph Platform serves cards with protocolBinding "jsonrpc" and answers in the
|
||||
A2A 0.3 JSON dialect ("kind"-discriminated) while declaring protocolVersion "1.0".
|
||||
Without binding normalization client creation raises ValueError("no compatible
|
||||
transports found."); without the version downgrade the SDK's strict v1 transport
|
||||
rejects the reply with 'Message type "lf.a2a.v1.Task" has no field named "kind"'."""
|
||||
await _seed_shared_a2a_client(card=_LOWERCASE_BINDING_CARD, rpc_reply=_LANGGRAPH_TASK_REPLY)
|
||||
|
||||
a2a_client = await create_a2a_client(base_url="http://127.0.0.1:9")
|
||||
response = await _send_message(a2a_client, _send_request("lc"))
|
||||
|
||||
assert type(response.root.result).__name__ == "Message"
|
||||
assert a2a_client._litellm_agent_card.supported_interfaces[0].protocol_binding == "JSONRPC"
|
||||
assert type(response.root.result).__name__ == "Task"
|
||||
assert response.root.result.artifacts[0].parts[0].root.text == "langgraph echo: hi"
|
||||
interface = a2a_client._litellm_agent_card.supported_interfaces[0]
|
||||
assert interface.protocol_binding == "JSONRPC"
|
||||
assert interface.protocol_version == "0.3"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue