mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(a2a): accept semver protocolVersion values like 0.3.0 in agent cards
This commit is contained in:
parent
fcd236097e
commit
062e58fb1d
6 changed files with 128 additions and 16 deletions
|
|
@ -8,22 +8,35 @@ and uses LiteLLM auth.
|
|||
"""
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def normalize_protocol_version(version: object) -> SupportedA2AVersion | None:
|
||||
"""Map a raw ``protocolVersion`` value to the supported canonical major.minor version.
|
||||
|
||||
Semver strings the A2A spec and Google a2a-sdk emit (e.g. ``"0.3.0"``, ``"1.0.1"``)
|
||||
canonicalize to their major.minor (``"0.3"``, ``"1.0"``). Anything outside the
|
||||
supported set, including non-strings, yields ``None``.
|
||||
"""
|
||||
if not isinstance(version, str):
|
||||
return None
|
||||
major_minor = ".".join(version.split(".")[:2])
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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=(
|
||||
|
|
|
|||
|
|
@ -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,47 @@ 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.2.6", None),
|
||||
("2.0", None),
|
||||
("0.30", 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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -540,6 +540,47 @@ 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()
|
||||
|
||||
|
||||
class TestCheckAgentManagementPermission:
|
||||
"""Unit tests for the _check_agent_management_permission helper."""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue