mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-30 01:52:18 +00:00
fix(a2a): default merged agent card version, null-guard runtime URL lookup, scope discovery auto-fire to A2A types
- merge_agent_card now defaults version to 1.0.0 when upstream omits it (A2A v1.0 schema requires the field). - invoke_agent_a2a guards against agent_card_params being None so plain chat agents routed via the A2A path return a JSON-RPC error instead of AttributeError. - buildDiscoveryRequest no longer falls back to any URL-shaped credential field for non-A2A agent types (Azure AI Foundry, Bedrock AgentCore, Vertex). Discovery only auto-fires for pure A2A and use_a2a_form_fields runtimes; the manual URL input remains available as an escape hatch.
This commit is contained in:
parent
84e778e735
commit
8145ece4ca
5 changed files with 95 additions and 14 deletions
|
|
@ -86,6 +86,11 @@ _DEFAULT_SKILLS: List[Dict[str, Any]] = [
|
|||
|
||||
_DEFAULT_MODES: List[str] = ["text"]
|
||||
|
||||
# Fallback ``version`` when the upstream card omits the field. The A2A v1.0
|
||||
# schema requires ``version`` on every card, so without this default the
|
||||
# merged card would fail validation on clients that ``model_validate`` it.
|
||||
_DEFAULT_AGENT_VERSION = "1.0.0"
|
||||
|
||||
|
||||
def _filter_capabilities(upstream_capabilities: Any) -> Dict[str, Any]:
|
||||
"""Return a capabilities dict containing only allowlisted, truthy keys."""
|
||||
|
|
@ -143,6 +148,9 @@ def merge_agent_card(
|
|||
if description:
|
||||
base["description"] = description
|
||||
|
||||
if not base.get("version"):
|
||||
base["version"] = _DEFAULT_AGENT_VERSION
|
||||
|
||||
base["capabilities"] = _filter_capabilities(base.get("capabilities"))
|
||||
|
||||
if not base.get("skills"):
|
||||
|
|
|
|||
|
|
@ -381,8 +381,9 @@ async def invoke_agent_a2a( # noqa: PLR0915
|
|||
_enforce_inbound_trace_id(agent, request)
|
||||
|
||||
# Get backend URL and agent name
|
||||
agent_url = agent.agent_card_params.get("url")
|
||||
agent_name = agent.agent_card_params.get("name", agent_id)
|
||||
agent_card_params = agent.agent_card_params or {}
|
||||
agent_url = agent_card_params.get("url")
|
||||
agent_name = agent_card_params.get("name", agent_id)
|
||||
|
||||
# Get litellm_params (may include custom_llm_provider for completion bridge)
|
||||
litellm_params = agent.litellm_params or {}
|
||||
|
|
|
|||
|
|
@ -143,6 +143,19 @@ def test_defaults_for_missing_skills_and_modes():
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ Mock tests for A2A endpoints.
|
|||
Tests that invoke_agent_a2a properly integrates with add_litellm_data_to_request.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
|
@ -181,3 +182,67 @@ async def test_invoke_agent_a2a_adds_litellm_data():
|
|||
# Verify proxy_server_request was added
|
||||
assert "proxy_server_request" in captured_data
|
||||
assert captured_data["proxy_server_request"]["method"] == "POST"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_agent_a2a_handles_none_agent_card_params():
|
||||
"""Agents without ``agent_card_params`` (e.g. plain chat agents routed
|
||||
through the A2A endpoint by mistake) must not raise ``AttributeError`` on
|
||||
``agent_card_params.get(...)`` — they should return a JSON-RPC error.
|
||||
"""
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.agent_card_params = None
|
||||
mock_agent.litellm_params = None
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.json = AsyncMock(
|
||||
return_value={
|
||||
"jsonrpc": "2.0",
|
||||
"id": "test-id",
|
||||
"method": "message/send",
|
||||
"params": {
|
||||
"message": {
|
||||
"role": "user",
|
||||
"parts": [{"kind": "text", "text": "Hello"}],
|
||||
"messageId": "msg-123",
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
mock_user_api_key_dict = UserAPIKeyAuth(
|
||||
api_key="sk-test-key",
|
||||
user_id="test-user",
|
||||
team_id="test-team",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.agent_endpoints.a2a_endpoints._get_agent",
|
||||
return_value=mock_agent,
|
||||
),
|
||||
patch(
|
||||
"litellm.a2a_protocol.main.A2A_SDK_AVAILABLE",
|
||||
True,
|
||||
),
|
||||
patch.dict(sys.modules, {"a2a": MagicMock(), "a2a.types": MagicMock()}),
|
||||
):
|
||||
from litellm.proxy.agent_endpoints.a2a_endpoints import invoke_agent_a2a
|
||||
|
||||
mock_fastapi_response = MagicMock()
|
||||
|
||||
response = await invoke_agent_a2a(
|
||||
agent_id="test-agent",
|
||||
request=mock_request,
|
||||
fastapi_response=mock_fastapi_response,
|
||||
user_api_key_dict=mock_user_api_key_dict,
|
||||
)
|
||||
|
||||
# JSONResponse exposes the body bytes; decode and verify it's a
|
||||
# JSON-RPC error, not an "internal error" from a Python exception.
|
||||
body = json.loads(response.body.decode())
|
||||
assert body["jsonrpc"] == "2.0"
|
||||
assert body["error"]["code"] == -32000
|
||||
assert "no URL configured" in body["error"]["message"]
|
||||
|
|
|
|||
|
|
@ -125,16 +125,10 @@ export const buildDiscoveryRequest = (
|
|||
};
|
||||
}
|
||||
|
||||
const credentialFields = selectedAgentTypeInfo?.credential_fields ?? [];
|
||||
const baseKey = credentialFields.find((f) =>
|
||||
/(^|_)(url|api_base|endpoint)$/i.test(f.key),
|
||||
)?.key;
|
||||
if (!baseKey) return undefined;
|
||||
const base = stripTrailingSlash(trim(values[baseKey]));
|
||||
if (!base) return undefined;
|
||||
return {
|
||||
url: base,
|
||||
discovery_mode: "well_known_fallback",
|
||||
display_url: `${base}/.well-known/agent-card.json`,
|
||||
};
|
||||
// Non-A2A agent runtimes (Azure AI Foundry, Bedrock AgentCore, Vertex,
|
||||
// etc.) don't expose well-known agent cards on their credential URLs, so
|
||||
// we deliberately don't auto-fire discovery for them. The
|
||||
// ``AgentCardDiscovery`` widget falls back to a manual URL input the admin
|
||||
// can use as an escape hatch.
|
||||
return undefined;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue