mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
* feat(a2a): support a2a-sdk 1.x proxy routing for 0.3 and 1.0 agents Bump a2a-sdk to 1.x and wire send/stream through compat conversions so the proxy accepts A2A 1.0 JSON-RPC while preserving 0.3 wire clients. Co-authored-by: Cursor <cursoragent@cursor.com> * Add user controlled protocol version in agents * Fix exeception mapping * Fix a2a base url * Add e2e test for a2a * Fix lint * Fix lint * fix(a2a): harden card version detection and header isolation coverage Use protocolVersion when inferring agent card wire format, assert distinct httpx cache keys in the header-isolation test, and suppress targeted basedpyright errors for optional SDK imports. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(a2a): suppress reportArgumentType for SDK compat types and fix streaming trace ID - Add pyright: ignore[reportArgumentType] to SendMessageSuccessResponse id= and result= args in _send_message, and SendStreamingMessageResponse root= in _stream_messages, where a2a-sdk compat types diverge from basedpyright's inferred signature, reducing the reportArgumentType count back within budget. - Fix streaming trace ID in astream_a2a_message to use str(request.id) when available instead of always generating a new uuid4(), restoring JSON-RPC request-ID correlation for observability. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * style(a2a): expand SendStreamingMessageResponse for black formatting Move pyright: ignore comment to the root= argument line so Black accepts the expanded multi-line form. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(a2a): fix 2 reportArgumentType errors without suppression - main.py: narrow logging_obj from object|None to Optional[Logging] via isinstance check before A2AStreamingIterator call, fixing the "Logging | object" argument type mismatch at line 699. - a2a_endpoints.py: extract response_dict with explicit isinstance(dict) guard before passing to normalize_jsonrpc_response, fixing the "LLMResponseTypes | dict[str, Any]" type mismatch at line 835. - Remove spurious pyright: ignore comments added in previous commits that were not suppressing the actual errors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(a2a): rewrite upstream URL for 1.0 agent cards in getAuthenticatedExtendedCard 1.0 upstream agent cards store the endpoint URL in supportedInterfaces[0].url rather than a top-level url field. The previous guard only rewrote url when it existed at the top level, so after normalize_agent_card lowered a 1.0 card to 0.3 the upstream internal address leaked into the url field of the 0.3 response. Fix: rewrite both url and supportedInterfaces[0].url to the proxy address before calling normalize_agent_card, ensuring the upstream address is never visible to downstream clients regardless of the upstream card's wire format. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: extend _served_version to all PascalCase methods; add direct httpx-client isolation proof - _served_version now checks `_PASCAL_TO_WIRE` membership instead of two hardcoded names, so GetTask/CancelTask/etc. are promoted to 1.0 wire format alongside SendMessage — prevents mixed wire formats mid-session - test_create_a2a_client_uses_fresh_httpx_client now asserts a2a_client_a._litellm_httpx_client is not a2a_client_b._litellm_httpx_client (direct proof that header bleed cannot occur), in addition to the cache-key inequality check Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: id:0 silently dropped in version_convert; explicit continue in stream retry - version_convert.py: replace `request_id or ""` with `str(request_id) if request_id is not None else ""` in both _send_result_to and _stream_result_to; id=0 is valid JSON-RPC and must not be coerced to "" which breaks response correlation - main.py: add explicit `continue` after the A2ALocalhostURLError retry in _execute_a2a_stream_with_retry so the control flow (retry → next iteration → stream_succeeded guard) is unambiguous Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: preserve a2a retry and discovery card urls * Fix black * Fix test * fix(a2a): avoid KeyError in discovery log after 0.3→1.0 card normalization When a 0.3-style agent card is normalized to 1.0, the top-level url key is replaced by supportedInterfaces; log the already-computed proxy_url instead. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(a2a): preserve taskId when lowering push notification config set params Flatten 1.x create envelope fields before parsing into TaskPushNotificationConfig so 1.0 clients forwarding to 0.3 upstream keep taskId and config. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(a2a): ignore unknown fields in message/send proto fallback ParseDict in _build_message_send_params now matches other inbound paths so 1.0 clients with extra proto fields are not rejected with -32602. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(a2a): normalize tasks/list params and response across protocol versions Convert list task entries on the response path and lower ListTasksRequest params including status filters when forwarding 1.0 clients to 0.3 upstream. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(a2a): avoid reportArgumentType in _lower_list_tasks_params; use local var instead of _parse return Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(a2a): drop private SDK symbol in tasks/list status lowering _lower_list_tasks_params imported _CORE_TO_COMPAT_TASK_STATE, a private a2a-sdk symbol that could disappear on a patch release and silently break status-filter lowering. Derive the 0.3 wire string from the public protobuf enum name instead (TASK_STATE_<NAME> maps to the 0.3 value once the prefix is dropped and underscores become dashes) and validate the result against the 0.3 TaskState enum's own values via a fully-typed pure helper. Behavior is unchanged for every state; unspecified or unrecognized states still drop the filter. Adds parametrized regression tests covering dashed wire values (input-required, auth-required) and the unspecified drop. * fix(a2a): drop redundant push-notification envelope key; unify MessageToDict import _flatten_create_push_notification_params used `config or pushNotificationConfig`, which short-circuits so a co-present pushNotificationConfig key was never popped and leaked into the flattened params. Pop both keys unconditionally and prefer config when present. Adds a regression test on the helper that fails on the old leak. Also import MessageToDict from a2a.compat.v0_3.conversions in _lower_list_tasks_params to match every other conversion helper in the module instead of pulling it straight from google.protobuf.json_format. * fix(a2a): reject invalid message/stream params early with -32602 _handle_stream_message built MessageSendParams lazily inside the stream_response() generator, so malformed 1.0 params surfaced as a generic -32603 after the 200 status line was already committed. The non-streaming path validates up front and returns -32602 (Invalid params). Validate eagerly before returning the StreamingResponse and emit -32602 on failure so both paths reject malformed params identically. Adds a regression test asserting the streamed error code is -32602. * fix(a2a): raise clear error when non-streaming send ends on an update event _send_message fed the SDK iterator's last event straight into SendMessageSuccessResponse, whose result only accepts Message or Task. A non-standard upstream whose final event is a TaskStatusUpdateEvent or TaskArtifactUpdateEvent made the response construction raise an opaque pydantic ValidationError. Guard the converted result and raise a clear RuntimeError instead, consistent with the no-response guard above it. Adds regression tests for the Message happy path and the update-event rejection via an injected fake client. * test(a2a): lock in clean merged agent-card URL without PROXY_BASE_URL Regression coverage proving _build_merged_agent_card produces no double slash in supportedInterfaces[0].url when PROXY_BASE_URL is unset and request.base_url carries a trailing slash. get_custom_url routes through join_paths, which rstrips the base, so the f-string join stays clean. * style(a2a): modernize type annotations to satisfy strict ruff budget After merging the black->ruff-format migration from base, the A2A files owned by this PR still used Optional[X]/quoted annotations that pushed UP037/UP045 over their lowered ceilings. Convert to X | None, drop the now-unnecessary quoted local annotation in _send_message, and remove the imports left unused by the rewrite. Type semantics are unchanged. * style(a2a): type a2a_endpoints dict params as dict[str, Any] The merge with the formatter-migration baseline tightened the reportUnknownArgumentType ceiling; bare dict annotations made every value Unknown and pushed the codebase total over cap. Annotate the JSON-RPC params, body, metadata, and litellm_params dicts as dict[str, Any] so their values are typed, dropping the unknown-argument count back under the ceiling. No behavior change. * fix(a2a): guard localhost retry against a missing agent card handle_a2a_localhost_retry rewrote the card URL and called create_client with whatever agent_card it received. The caller resolves the card from the SDK client (Optional), so a None card reached set_agent_card_url and create_client, surfacing an opaque SDK error instead of a clear one. Add an early RuntimeError guard mirroring the httpx-client check, drop the now always-true card None-check on the stash line, and cover it with a regression test. * style(a2a): disable reportUnknownArgumentType in a2a-sdk boundary modules The lint env type-checks without the optional a2a-sdk/protobuf installed, so every call into the protobuf-generated compat conversions counts as an Unknown-typed argument and the new A2A code pushed the codebase reportUnknownArgumentType total over its ceiling. These three modules are the A2A SDK boundary; turn the rule off file-wide with a documented reason instead of scattering dozens of per-line ignores across every SDK call. * fix(a2a): tolerate unknown fields when lowering 1.0->0.3; align streaming trace id Two issues greptile flagged: version_convert: the 1.0->0.3 lowering paths (_send_result_to, _task_to, _stream_result_to) called ParseDict without ignore_unknown_fields=True, so a 1.0 upstream response carrying vendor extensions raised and best-effort fell back to passing the un-lowered 1.0 shape to a 0.3 client. Set the flag to match the agent-card path and every inbound path; unknown fields are now dropped and the result is correctly lowered. main.py: asend_message_streaming derived X-LiteLLM-Trace-Id from the JSON-RPC request id, unlike asend_message which uses the logging object's litellm_trace_id. Prefer the logging trace id (then request id, then a uuid) so streamed and non-streamed calls correlate under the same trace. Adds regression tests for both, including the stream-event lowering path. * style(a2a): apply ruff format to a2a protocol and proxy modules Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
207 lines
7.5 KiB
Python
207 lines
7.5 KiB
Python
"""Unit tests for the pure merge logic in litellm/proxy/a2a/agent_card.py."""
|
|
|
|
from litellm.proxy.a2a.agent_card import (
|
|
LITELLM_A2A_PROTOCOL_VERSION,
|
|
LITELLM_SECURITY_REQUIREMENTS,
|
|
LITELLM_SECURITY_SCHEMES,
|
|
merge_agent_card,
|
|
)
|
|
|
|
PROXY_URL = "https://proxy.example/a2a/agent-xyz"
|
|
PROXY_BASE = "https://proxy.example"
|
|
|
|
|
|
def _full_upstream_card() -> dict:
|
|
return {
|
|
"protocolVersion": "0.9",
|
|
"name": "Upstream Name",
|
|
"description": "Upstream description",
|
|
"url": "http://internal:9999/",
|
|
"version": "1.2.3",
|
|
"capabilities": {
|
|
"streaming": True,
|
|
"pushNotifications": True,
|
|
"stateTransitionHistory": True,
|
|
"extensions": [{"uri": "x"}],
|
|
},
|
|
"skills": [
|
|
{"id": "s1", "name": "skill one", "description": "d", "tags": ["t"]}
|
|
],
|
|
"defaultInputModes": ["text", "audio"],
|
|
"defaultOutputModes": ["text"],
|
|
"securitySchemes": {"upstreamKey": {"type": "apiKey"}},
|
|
"security": [{"upstreamKey": []}],
|
|
"provider": {"organization": "UpstreamCo", "url": "https://upstream.example"},
|
|
"iconUrl": "https://upstream.example/icon.png",
|
|
"documentationUrl": "https://upstream.example/docs",
|
|
"somethingNotInSchema": "should be stripped",
|
|
}
|
|
|
|
|
|
def test_preserves_top_level_url_for_runtime_invocation():
|
|
# The runtime A2A invocation path reads ``agent_card_params['url']`` to
|
|
# know where to proxy requests, so the merge must keep the upstream URL
|
|
# on the stored card. The public well-known endpoint rewrites this field
|
|
# to the proxy URL before exposing it to clients.
|
|
merged = merge_agent_card(
|
|
_full_upstream_card(), proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE
|
|
)
|
|
assert merged["url"] == "http://internal:9999/"
|
|
|
|
|
|
def test_unsupported_protocol_version_defaults_to_1_0():
|
|
# The fixture card pins "0.9", which LiteLLM does not serve; it falls back to
|
|
# the default rather than advertising a version the proxy can't honor.
|
|
merged = merge_agent_card(
|
|
_full_upstream_card(), proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE
|
|
)
|
|
assert merged["protocolVersion"] == LITELLM_A2A_PROTOCOL_VERSION
|
|
|
|
|
|
def test_serves_pinned_protocol_version():
|
|
for version in ("0.3", "1.0"):
|
|
card = _full_upstream_card()
|
|
card["protocolVersion"] = version
|
|
merged = merge_agent_card(card, proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE)
|
|
assert merged["protocolVersion"] == version
|
|
assert merged["supportedInterfaces"][0]["protocolVersion"] == version
|
|
|
|
|
|
def test_absent_protocol_version_defaults_to_1_0():
|
|
card = _full_upstream_card()
|
|
card.pop("protocolVersion", None)
|
|
merged = merge_agent_card(card, proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE)
|
|
assert merged["protocolVersion"] == "1.0"
|
|
|
|
|
|
def test_overrides_name_and_description_when_provided():
|
|
merged = merge_agent_card(
|
|
_full_upstream_card(),
|
|
proxy_url=PROXY_URL,
|
|
proxy_base_url=PROXY_BASE,
|
|
name="UI Name",
|
|
description="UI Description",
|
|
)
|
|
assert merged["name"] == "UI Name"
|
|
assert merged["description"] == "UI Description"
|
|
|
|
|
|
def test_keeps_upstream_name_and_description_when_not_overridden():
|
|
merged = merge_agent_card(
|
|
_full_upstream_card(), proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE
|
|
)
|
|
assert merged["name"] == "Upstream Name"
|
|
assert merged["description"] == "Upstream description"
|
|
|
|
|
|
def test_filters_capabilities_to_allowlist():
|
|
merged = merge_agent_card(
|
|
_full_upstream_card(), proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE
|
|
)
|
|
# Only ``streaming`` is allowlisted today.
|
|
assert merged["capabilities"] == {"streaming": True}
|
|
|
|
|
|
def test_drops_streaming_when_upstream_disables_it():
|
|
upstream = _full_upstream_card()
|
|
upstream["capabilities"]["streaming"] = False
|
|
merged = merge_agent_card(upstream, proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE)
|
|
assert merged["capabilities"] == {}
|
|
|
|
|
|
def test_replaces_security_schemes_and_requirements():
|
|
merged = merge_agent_card(
|
|
_full_upstream_card(), proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE
|
|
)
|
|
assert merged["securitySchemes"] == LITELLM_SECURITY_SCHEMES
|
|
assert merged["security"] == LITELLM_SECURITY_REQUIREMENTS
|
|
assert "securityRequirements" not in merged
|
|
|
|
|
|
def test_emits_supported_interfaces_pointing_at_proxy():
|
|
merged = merge_agent_card(
|
|
_full_upstream_card(), proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE
|
|
)
|
|
assert merged["supportedInterfaces"] == [
|
|
{
|
|
"url": PROXY_URL,
|
|
"protocolBinding": "JSONRPC",
|
|
"protocolVersion": LITELLM_A2A_PROTOCOL_VERSION,
|
|
}
|
|
]
|
|
|
|
|
|
def test_passes_through_skills_modes_provider_icon_docs():
|
|
merged = merge_agent_card(
|
|
_full_upstream_card(), proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE
|
|
)
|
|
assert merged["skills"] == _full_upstream_card()["skills"]
|
|
assert merged["defaultInputModes"] == ["text", "audio"]
|
|
assert merged["defaultOutputModes"] == ["text"]
|
|
assert merged["provider"] == {
|
|
"organization": "UpstreamCo",
|
|
"url": "https://upstream.example",
|
|
}
|
|
assert merged["iconUrl"] == "https://upstream.example/icon.png"
|
|
assert merged["documentationUrl"] == "https://upstream.example/docs"
|
|
|
|
|
|
def test_strips_fields_not_in_v1_schema():
|
|
merged = merge_agent_card(
|
|
_full_upstream_card(), proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE
|
|
)
|
|
assert "somethingNotInSchema" not in merged
|
|
|
|
|
|
def test_defaults_for_missing_skills_and_modes():
|
|
sparse = {"name": "x", "description": "y", "version": "1"}
|
|
merged = merge_agent_card(sparse, proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE)
|
|
assert merged["skills"] and merged["skills"][0]["id"] == "chat"
|
|
assert merged["defaultInputModes"] == ["text"]
|
|
assert merged["defaultOutputModes"] == ["text"]
|
|
|
|
|
|
def test_defaults_version_when_upstream_omits_it():
|
|
sparse = {"name": "x", "description": "y"}
|
|
merged = merge_agent_card(sparse, proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE)
|
|
assert merged["version"] == "1.0.0"
|
|
|
|
|
|
def test_preserves_upstream_version_when_present():
|
|
merged = merge_agent_card(
|
|
_full_upstream_card(), proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE
|
|
)
|
|
assert merged["version"] == "1.2.3"
|
|
|
|
|
|
def test_falls_back_to_litellm_provider_when_upstream_lacks_one():
|
|
sparse = {"name": "x", "description": "y", "version": "1"}
|
|
merged = merge_agent_card(sparse, proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE)
|
|
assert merged["provider"] == {
|
|
"organization": "LiteLLM Proxy",
|
|
"url": PROXY_BASE,
|
|
}
|
|
|
|
|
|
def test_handles_none_upstream_card():
|
|
merged = merge_agent_card(None, proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE)
|
|
assert merged["protocolVersion"] == LITELLM_A2A_PROTOCOL_VERSION
|
|
assert merged["supportedInterfaces"][0]["url"] == PROXY_URL
|
|
assert merged["securitySchemes"] == LITELLM_SECURITY_SCHEMES
|
|
|
|
|
|
def test_does_not_mutate_input():
|
|
upstream = _full_upstream_card()
|
|
snapshot = dict(upstream)
|
|
merge_agent_card(upstream, proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE)
|
|
assert upstream == snapshot
|
|
|
|
|
|
def test_strips_additional_interfaces_to_prevent_backend_url_leak():
|
|
upstream = _full_upstream_card()
|
|
upstream["additionalInterfaces"] = [
|
|
{"url": "http://internal-backend:8080/", "transport": "JSONRPC"},
|
|
{"url": "grpc://internal-backend:50051", "transport": "GRPC"},
|
|
]
|
|
merged = merge_agent_card(upstream, proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE)
|
|
assert "additionalInterfaces" not in merged
|