fix(a2a): read a stored card's capabilities the way the spec does and keep one Authorization line

This commit is contained in:
mateo-berri 2026-09-16 17:10:34 -07:00
parent 75eec8712c
commit dee5724c21
4 changed files with 51 additions and 8 deletions

View file

@ -38,7 +38,7 @@ _REGISTRY_PARAMS_KEPT_OUT_OF_OPTIONAL_PARAMS: Final = (
def _card_declares_no_streaming(agent_card_params: Mapping[str, object]) -> bool:
capabilities: Final = agent_card_params.get("capabilities")
return isinstance(capabilities, Mapping) and capabilities.get("streaming") is False
return isinstance(capabilities, Mapping) and not capabilities.get("streaming")
def _registry_api_key(agent_litellm_params: dict[str, object]) -> str | None:

View file

@ -176,14 +176,15 @@ def _forwarding_headers(
agent_extra_headers: Mapping[str, str] | None,
backend_auth_header: Mapping[str, str] | None,
) -> dict[str, str] | None:
backend_auth: Final = tuple(backend_auth_header.items()) if backend_auth_header else ()
minted_names: Final = frozenset(name.lower() for name, _ in backend_auth)
passthrough: Final = tuple(
(name, value)
for name, value in (agent_extra_headers.items() if agent_extra_headers else ())
if not name.lower().startswith("x-litellm-")
if not name.lower().startswith("x-litellm-") and name.lower() not in minted_names
)
trace_id: Final = request_data.get("litellm_trace_id")
trace: Final = (("X-LiteLLM-Trace-Id", str(trace_id)),) if trace_id else ()
backend_auth: Final = backend_auth_header.items() if backend_auth_header else ()
merged: Final = dict((*passthrough, *caller_identity.items(), *trace, *backend_auth))
return merged or None

View file

@ -2642,3 +2642,18 @@ async def test_handle_stream_message_is_untouched_while_keepalives_are_unconfigu
assert not any(chunk.startswith(":") for chunk in chunks)
assert json.loads(chunks[-1].removeprefix("data: "))["result"]["kind"] == "task"
def test_forwarding_headers_minted_bearer_replaces_a_forwarded_authorization_of_any_case():
"""A client header the admin chose to forward keeps the casing the config named it with, so a forwarded
`authorization` must not travel next to the minted `Authorization` as a second header line."""
from litellm.proxy.agent_endpoints.a2a_endpoints import _forwarding_headers
merged = _forwarding_headers(
caller_identity={},
request_data={},
agent_extra_headers={"authorization": "Bearer client-token", "X-Custom": "kept"},
backend_auth_header={"Authorization": "Bearer minted-token"},
)
assert merged == {"X-Custom": "kept", "Authorization": "Bearer minted-token"}

View file

@ -75,10 +75,29 @@ def test_a2a_registry_integration():
assert post.call_args.kwargs["headers"]["X-Agent"] == "static"
def test_streaming_chat_to_an_agent_whose_card_declines_streaming_uses_a_blocking_send():
def _foundry_card_stored_through_the_agents_api() -> dict:
from litellm.proxy.a2a.agent_card import merge_agent_card
return merge_agent_card(
{"name": "Foundry", "url": "https://foundry.example.com/a2a", "capabilities": {"streaming": False}},
proxy_url="http://localhost:4000/a2a/foundry-agent",
proxy_base_url="http://localhost:4000",
)
@pytest.mark.parametrize(
"agent_card_params",
[
{"url": "https://foundry.example.com/a2a", "capabilities": {"streaming": False}},
_foundry_card_stored_through_the_agents_api(),
],
ids=["card registered verbatim from config.yaml", "card stored through POST /v1/agents"],
)
def test_streaming_chat_to_an_agent_whose_card_declines_streaming_uses_a_blocking_send(agent_card_params: dict):
"""Microsoft Foundry agents publish `capabilities.streaming: false` and answer message/stream with a
JSON-RPC error. A streaming chat call to such an agent must post a blocking message/send and hand the
caller the answer as a stream, and an agent whose card is silent about streaming keeps message/stream."""
caller the answer as a stream, whether the card was registered verbatim from config.yaml or stored
through POST /v1/agents, which keeps only truthy capabilities and so drops the `false` itself."""
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry
from litellm.types.agents import AgentResponse
@ -86,7 +105,7 @@ def test_streaming_chat_to_an_agent_whose_card_declines_streaming_uses_a_blockin
foundry_agent = AgentResponse(
agent_id="foundry-id",
agent_name="foundry-agent",
agent_card_params={"url": "https://foundry.example.com/a2a", "capabilities": {"streaming": False}},
agent_card_params=agent_card_params,
litellm_params={"api_key": "registry-key"},
)
client = HTTPHandler()
@ -126,14 +145,22 @@ def test_streaming_chat_to_an_agent_whose_card_declines_streaming_uses_a_blockin
assert chunks[-1].choices[0].finish_reason == "stop"
def test_registry_lookup_leaves_streaming_alone_when_the_card_does_not_decline_it():
@pytest.mark.parametrize(
"agent_card_params",
[
{"url": "https://agent.example.com/a2a"},
{"url": "https://agent.example.com/a2a", "capabilities": {"streaming": True}},
],
ids=["card without a capabilities block", "card says streaming true"],
)
def test_registry_lookup_leaves_streaming_alone_when_the_card_does_not_decline_it(agent_card_params: dict):
from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry
from litellm.types.agents import AgentResponse
silent_agent = AgentResponse(
agent_id="silent-id",
agent_name="silent-agent",
agent_card_params={"url": "https://agent.example.com/a2a"},
agent_card_params=agent_card_params,
litellm_params={"api_key": "registry-key"},
)
original_agents = global_agent_registry.agent_list.copy()