Merge pull request #34154 from BerriAI/litellm_a2a_protocol_version_semver

fix(a2a): accept semver protocolVersion values like 0.3.0 in agent cards
This commit is contained in:
Mateo Wang 2026-07-21 13:37:05 -07:00 • committed by GitHub
commit a7e4c83013
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 151 additions and 16 deletions

View file

@ -7,23 +7,46 @@ the base; specific fields are replaced so all traffic flows through the proxy
and uses LiteLLM auth.
"""
import re
from copy import deepcopy
from typing import Any, Dict, List, Mapping
from typing import Any, Dict, List, Literal, Mapping
SupportedA2AVersion = Literal["0.3", "1.0"]
# Protocol versions LiteLLM can serve to A2A clients. The admin pins one per agent;
# responses are normalized to it regardless of the upstream agent's own version.
SUPPORTED_A2A_PROTOCOL_VERSIONS = ("0.3", "1.0")
SUPPORTED_A2A_PROTOCOL_VERSIONS: tuple[SupportedA2AVersion, ...] = ("0.3", "1.0")
# Default served version when the agent card does not pin one.
LITELLM_A2A_PROTOCOL_VERSION = "1.0"
_PROTOCOL_VERSION_PATTERN = re.compile(
r"^(\d+\.\d+)(?:\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)?$"
)
def normalize_protocol_version(version: object) -> SupportedA2AVersion | None:
"""Map a raw ``protocolVersion`` value to the supported canonical major.minor version.
Accepts the bare major.minor convention of the 1.0 spec (``"0.3"``, ``"1.0"``) and the
full semver forms older SDKs emit (``"0.3.0"``, ``"1.0.1"``, including prerelease and
build suffixes like ``"0.3.0-rc1"``). Malformed strings, versions outside the
supported set, and non-strings yield ``None``.
"""
if not isinstance(version, str):
return None
match = _PROTOCOL_VERSION_PATTERN.match(version)
if match is None:
return None
major_minor = match.group(1)
return next((supported for supported in SUPPORTED_A2A_PROTOCOL_VERSIONS if supported == major_minor), None)
def resolve_served_protocol_version(card: Mapping[str, Any] | None) -> str:
"""Return the validated protocol version an agent card pins, else the default."""
version = card.get("protocolVersion") if card else None
if version in SUPPORTED_A2A_PROTOCOL_VERSIONS:
return version
return LITELLM_A2A_PROTOCOL_VERSION
normalized = normalize_protocol_version(card.get("protocolVersion") if card else None)
return normalized if normalized is not None else LITELLM_A2A_PROTOCOL_VERSION
# Security scheme exposed by the LiteLLM-fronted agent card. Always replaces

View file

@ -30,6 +30,7 @@ from typing import Callable, Literal, Union
from pydantic import BaseModel
from litellm._logging import verbose_proxy_logger
from litellm.proxy.a2a.agent_card import normalize_protocol_version
A2AVersion = Literal["0.3", "1.0"]
RequestId = Union[str, int, None]
@ -103,16 +104,14 @@ def normalize_request_params(params: JsonDict, served: A2AVersion, *, method: st
def _detect_card_version(card: JsonDict) -> A2AVersion:
"""Infer the wire version of an agent card dict.
``protocolVersion`` is the authoritative indicator; fall back to presence of
``supportedInterfaces`` (a 1.0-only field) only when the explicit field is absent.
Cards that set ``protocolVersion: "0.3"`` or carry neither signal are treated as 0.3.
``protocolVersion`` is the authoritative indicator; semver values normalize to
their major.minor (``"0.3.0"`` -> ``"0.3"``). Fall back to presence of
``supportedInterfaces`` (a 1.0-only field) only when the explicit field is
absent or unrecognized; cards carrying neither signal are treated as 0.3.
"""
pv = card.get("protocolVersion")
if pv == "1.0":
return "1.0"
if pv == "0.3":
return "0.3"
# No protocolVersion field: use structural heuristic.
normalized = normalize_protocol_version(card.get("protocolVersion"))
if normalized is not None:
return normalized
return "1.0" if "supportedInterfaces" in card else "0.3"

View file

@ -23,6 +23,7 @@ from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKey
from litellm.proxy.a2a.agent_card import (
SUPPORTED_A2A_PROTOCOL_VERSIONS,
merge_agent_card,
normalize_protocol_version,
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.rbac_utils import check_feature_access_for_user
@ -51,7 +52,7 @@ def _proxy_base_url(http_request: Request) -> str:
def _validate_protocol_version(upstream_card: Mapping[str, Any] | None) -> None:
"""Reject an agent card pinning an unsupported A2A protocol version."""
version = upstream_card.get("protocolVersion") if upstream_card else None
if version is not None and version not in SUPPORTED_A2A_PROTOCOL_VERSIONS:
if version is not None and normalize_protocol_version(version) is None:
raise HTTPException(
status_code=400,
detail=(

View file

@ -1,10 +1,14 @@
"""Unit tests for the pure merge logic in litellm/proxy/a2a/agent_card.py."""
import pytest
from litellm.proxy.a2a.agent_card import (
LITELLM_A2A_PROTOCOL_VERSION,
LITELLM_SECURITY_REQUIREMENTS,
LITELLM_SECURITY_SCHEMES,
merge_agent_card,
normalize_protocol_version,
resolve_served_protocol_version,
)
PROXY_URL = "https://proxy.example/a2a/agent-xyz"
@ -205,3 +209,54 @@ def test_strips_additional_interfaces_to_prevent_backend_url_leak():
]
merged = merge_agent_card(upstream, proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE)
assert "additionalInterfaces" not in merged
@pytest.mark.parametrize(
("raw", "expected"),
[
("0.3", "0.3"),
("0.3.0", "0.3"),
("1.0", "1.0"),
("1.0.0", "1.0"),
("1.0.1", "1.0"),
("0.3.0-rc1", "0.3"),
("1.0.0-rc.1+build.5", "1.0"),
("0.2.6", None),
("2.0", None),
("0.30", None),
("0.3.garbage", None),
("0.3.", None),
("1.0.not-semver", None),
("0.3.0.0", None),
("0.3-rc1", None),
("garbage", None),
("", None),
(None, None),
(1.0, None),
],
)
def test_normalize_protocol_version(raw, expected):
assert normalize_protocol_version(raw) == expected
def test_resolve_served_protocol_version_canonicalizes_semver_pins():
assert resolve_served_protocol_version({"protocolVersion": "0.3.0"}) == "0.3"
assert resolve_served_protocol_version({"protocolVersion": "1.0.0"}) == "1.0"
assert resolve_served_protocol_version({"protocolVersion": "0.3"}) == "0.3"
assert resolve_served_protocol_version({"protocolVersion": "1.0"}) == "1.0"
def test_resolve_served_protocol_version_falls_back_for_unsupported():
assert (
resolve_served_protocol_version({"protocolVersion": "0.2.6"})
== LITELLM_A2A_PROTOCOL_VERSION
)
assert resolve_served_protocol_version(None) == LITELLM_A2A_PROTOCOL_VERSION
def test_serves_semver_pinned_protocol_version_as_major_minor():
card = _full_upstream_card()
card["protocolVersion"] = "0.3.0"
merged = merge_agent_card(card, proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE)
assert merged["protocolVersion"] == "0.3"
assert merged["supportedInterfaces"][0]["protocolVersion"] == "0.3"

View file

@ -313,3 +313,13 @@ def test_agent_card_with_0_3_pin_and_supported_interfaces_is_lowered():
def test_agent_card_same_version_passthrough():
card = _extended_card_1_0()
assert normalize_agent_card(card, "1.0") is card
def test_detect_card_version_normalizes_semver_protocol_version():
from litellm.proxy.a2a.version_convert import _detect_card_version
assert _detect_card_version({"protocolVersion": "1.0.0"}) == "1.0"
assert (
_detect_card_version({"protocolVersion": "0.3.0", "supportedInterfaces": []})
== "0.3"
)

View file

@ -540,6 +540,53 @@ class TestAgentRBACProxyAdmin:
assert resp.status_code == 200
class TestAgentProtocolVersionValidation:
"""Registration accepts spec-default semver protocolVersion values and still
rejects genuinely unsupported versions."""
@pytest.fixture(autouse=True)
def _setup(self, monkeypatch):
self.admin_client = _make_app_with_role(LitellmUserRoles.PROXY_ADMIN)
self.mock_registry = MagicMock()
monkeypatch.setattr(agent_endpoints, "AGENT_REGISTRY", self.mock_registry)
def _create_agent_with_protocol_version(self, protocol_version: str):
config = _sample_agent_config()
config["agent_card_params"]["protocolVersion"] = protocol_version
with patch("litellm.proxy.proxy_server.prisma_client"):
self.mock_registry.get_agent_by_name = MagicMock(return_value=None)
self.mock_registry.add_agent_to_db = AsyncMock(
return_value=_sample_agent_response()
)
self.mock_registry.register_agent = MagicMock()
return self.admin_client.post(
"/v1/agents",
json=config,
headers={"Authorization": "Bearer k"},
)
def test_semver_protocol_version_registers_and_stores_major_minor(self):
resp = self._create_agent_with_protocol_version("0.3.0")
assert resp.status_code == 200
stored_card = self.mock_registry.add_agent_to_db.await_args.kwargs["agent"][
"agent_card_params"
]
assert stored_card["protocolVersion"] == "0.3"
assert stored_card["supportedInterfaces"][0]["protocolVersion"] == "0.3"
def test_unsupported_protocol_version_is_rejected(self):
resp = self._create_agent_with_protocol_version("0.2.6")
assert resp.status_code == 400
assert "Unsupported protocolVersion '0.2.6'" in resp.json()["detail"]
self.mock_registry.add_agent_to_db.assert_not_awaited()
def test_malformed_protocol_version_is_rejected(self):
resp = self._create_agent_with_protocol_version("0.3.garbage")
assert resp.status_code == 400
assert "Unsupported protocolVersion '0.3.garbage'" in resp.json()["detail"]
self.mock_registry.add_agent_to_db.assert_not_awaited()
class TestCheckAgentManagementPermission:
"""Unit tests for the _check_agent_management_permission helper."""