diff --git a/litellm/proxy/a2a/agent_card.py b/litellm/proxy/a2a/agent_card.py index 0238815ff90..57d360ab5af 100644 --- a/litellm/proxy/a2a/agent_card.py +++ b/litellm/proxy/a2a/agent_card.py @@ -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"): diff --git a/litellm/proxy/agent_endpoints/a2a_endpoints.py b/litellm/proxy/agent_endpoints/a2a_endpoints.py index db931d0a11b..993d30e3811 100644 --- a/litellm/proxy/agent_endpoints/a2a_endpoints.py +++ b/litellm/proxy/agent_endpoints/a2a_endpoints.py @@ -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 {} diff --git a/tests/test_litellm/proxy/a2a/test_agent_card.py b/tests/test_litellm/proxy/a2a/test_agent_card.py index 0600776c5e8..0022053d8d1 100644 --- a/tests/test_litellm/proxy/a2a/test_agent_card.py +++ b/tests/test_litellm/proxy/a2a/test_agent_card.py @@ -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) diff --git a/tests/test_litellm/proxy/agent_endpoints/test_a2a_endpoints.py b/tests/test_litellm/proxy/agent_endpoints/test_a2a_endpoints.py index dec2e66710d..268e6d2dc13 100644 --- a/tests/test_litellm/proxy/agent_endpoints/test_a2a_endpoints.py +++ b/tests/test_litellm/proxy/agent_endpoints/test_a2a_endpoints.py @@ -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"] diff --git a/ui/litellm-dashboard/src/components/agents/agent_discovery_utils.ts b/ui/litellm-dashboard/src/components/agents/agent_discovery_utils.ts index 0e57eda8c86..2520102a5a9 100644 --- a/ui/litellm-dashboard/src/components/agents/agent_discovery_utils.ts +++ b/ui/litellm-dashboard/src/components/agents/agent_discovery_utils.ts @@ -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; };