feat(a2a): well-known agent-card discovery + LangGraph Platform mode

Adds a registration-time discovery flow so admins can paste an upstream
agent URL, see its skills/capabilities, pick what to expose, and have the
proxy front it with a LiteLLM-shaped agent card.

Backend (new litellm/proxy/a2a/ module):
- fetch_well_known_card walks /.well-known/agent-card.json,
  /.well-known/agent.json, /agent.json by default. langgraph_platform
  mode hits the canonical path with ?assistant_id=<id> (LangGraph
  serves one shared endpoint per deployment).
- merge_agent_card overlays LiteLLM overrides on the upstream card:
  drops upstream url, forces protocolVersion=1.0, replaces
  securitySchemes with LiteLLMKey bearer, emits supportedInterfaces
  pointing at the proxy, filters capabilities to a small allowlist,
  strips non-v1.0 fields.
- POST /v1/a2a/discover returns the raw upstream card (admin-only) so
  the UI can render skills/capabilities for selection.
- create/update/patch agent endpoints pre-generate the agent_id and
  run merge_agent_card before storing, so DB.agent_card_params already
  embeds the proxy-fronted URL.

UI (ui/litellm-dashboard):
- New AgentCardDiscovery component with a parent-driven plan:
  discovery_mode + params + display URL. For LangGraph the parent
  composes (api_base, assistant_id); for pure A2A it uses the url
  field. Component hides the manual URL input when the parent drives.
- add_agent_form wires discovery for every non-custom agent type and
  overlays the user's selections onto agent_card_params at submit,
  fixing the bug where dynamic agent forms ignored discovery picks.

Completion-bridge fixes (paired):
- Add kind: "message" to A2A response messages and unwrap result
  so it's a Message directly per spec (matches a2a SDK
  SendMessageResponse validation).
- Forward A2A metadata to LangGraph runs via extra_body.metadata.
This commit is contained in:
Sameer Kankute 2026-05-26 14:52:14 +05:30
parent 96a2e8b16d
commit 79ffe24c6f
No known key found for this signature in database
24 changed files with 2112 additions and 39 deletions

View file

@ -100,6 +100,11 @@ class A2ACompletionBridgeHandler:
"api_base": api_base,
"stream": False,
}
A2ACompletionBridgeTransformation.apply_forward_metadata_to_completion_params(
completion_params=completion_params,
a2a_message=message,
params=params,
)
# Add litellm_params (contains api_key, client_id, client_secret, tenant_id, etc.)
litellm_params_to_add = {
k: v
@ -207,6 +212,11 @@ class A2ACompletionBridgeHandler:
"api_base": api_base,
"stream": True,
}
A2ACompletionBridgeTransformation.apply_forward_metadata_to_completion_params(
completion_params=completion_params,
a2a_message=message,
params=params,
)
# Add litellm_params (contains api_key, client_id, client_secret, tenant_id, etc.)
litellm_params_to_add = {
k: v

View file

@ -45,10 +45,72 @@ class A2ACompletionBridgeTransformation:
Static methods for transforming between A2A and OpenAI message formats.
"""
@staticmethod
def _extract_text_from_a2a_parts(parts: List[Dict[str, Any]]) -> str:
"""Extract text from A2A parts (with or without explicit ``kind``)."""
content_parts: List[str] = []
for part in parts:
if not isinstance(part, dict):
continue
kind = part.get("kind")
text = part.get("text")
if text is None:
continue
if kind in (None, "", "text"):
content_parts.append(str(text))
return "\n".join(content_parts)
@staticmethod
def get_forward_metadata(
a2a_message: Dict[str, Any],
params: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
"""
Merge A2A metadata from MessageSendParams and the message for downstream providers.
Forwarded once on the LangGraph run payload (``metadata``), not duplicated on
each input message — see ``apply_forward_metadata_to_completion_params``.
"""
merged: Dict[str, Any] = {}
if params and isinstance(params.get("metadata"), dict):
merged.update(params["metadata"])
message_metadata = a2a_message.get("metadata")
if isinstance(message_metadata, dict):
merged.update(message_metadata)
return merged or None
@staticmethod
def apply_forward_metadata_to_completion_params(
completion_params: Dict[str, Any],
a2a_message: Dict[str, Any],
params: Optional[Dict[str, Any]] = None,
) -> None:
"""
Attach A2A metadata to completion kwargs for provider bridges (e.g. LangGraph).
Uses ``extra_body`` so we do not collide with LiteLLM's spend-log ``metadata`` kwarg.
"""
forward_metadata = A2ACompletionBridgeTransformation.get_forward_metadata(
a2a_message=a2a_message,
params=params,
)
if not forward_metadata:
return
extra_body = completion_params.get("extra_body")
if not isinstance(extra_body, dict):
extra_body = {}
extra_body = {**extra_body, "metadata": forward_metadata}
completion_params["extra_body"] = extra_body
verbose_logger.debug(
f"A2A -> completion forward metadata keys={list(forward_metadata.keys())}"
)
@staticmethod
def a2a_message_to_openai_messages(
a2a_message: Dict[str, Any],
) -> List[Dict[str, str]]:
) -> List[Dict[str, Any]]:
"""
Transform an A2A message to OpenAI message format.
@ -70,21 +132,20 @@ class A2ACompletionBridgeTransformation:
elif role == "system":
openai_role = "system"
# Extract text content from parts
content_parts = []
for part in parts:
kind = part.get("kind", "")
if kind == "text":
text = part.get("text", "")
content_parts.append(text)
if not isinstance(parts, list):
parts = []
content = "\n".join(content_parts) if content_parts else ""
content = A2ACompletionBridgeTransformation._extract_text_from_a2a_parts(parts)
# Do not attach A2A message.metadata here — the completion bridge forwards it
# once at run level via extra_body.metadata (LangGraph POST /runs/wait shape).
openai_message: Dict[str, Any] = {"role": openai_role, "content": content}
verbose_logger.debug(
f"A2A -> OpenAI transform: role={role} -> {openai_role}, content_length={len(content)}"
)
return [{"role": openai_role, "content": content}]
return [openai_message]
@staticmethod
def openai_response_to_a2a_response(
@ -110,6 +171,7 @@ class A2ACompletionBridgeTransformation:
# Build A2A message
a2a_message = {
"kind": "message",
"role": "agent",
"parts": [{"kind": "text", "text": content}],
"messageId": uuid4().hex,
@ -119,9 +181,7 @@ class A2ACompletionBridgeTransformation:
a2a_response = {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"message": a2a_message,
},
"result": a2a_message,
}
verbose_logger.debug(f"OpenAI -> A2A transform: content_length={len(content)}")
@ -272,11 +332,10 @@ class A2ACompletionBridgeTransformation:
"jsonrpc": "2.0",
"id": request_id,
"result": {
"message": {
"role": "agent",
"parts": [{"kind": "text", "text": content}],
"messageId": uuid4().hex,
},
"kind": "message",
"role": "agent",
"parts": [{"kind": "text", "text": content}],
"messageId": uuid4().hex,
"final": is_final,
},
}

View file

@ -96,6 +96,11 @@ class A2ACompletionBridgeHandler:
"api_base": api_base,
"stream": False,
}
A2ACompletionBridgeTransformation.apply_forward_metadata_to_completion_params(
completion_params=completion_params,
a2a_message=message,
params=params,
)
# Add litellm_params (contains api_key, client_id, client_secret, tenant_id, etc.)
litellm_params_to_add = {
k: v
@ -206,6 +211,11 @@ class A2ACompletionBridgeHandler:
"api_base": api_base,
"stream": True,
}
A2ACompletionBridgeTransformation.apply_forward_metadata_to_completion_params(
completion_params=completion_params,
a2a_message=message,
params=params,
)
# Add litellm_params (contains api_key, client_id, client_secret, tenant_id, etc.)
litellm_params_to_add = {
k: v

View file

@ -110,6 +110,7 @@ class A2ACompletionBridgeTransformation:
# Build A2A message
a2a_message = {
"kind": "message",
"role": "agent",
"parts": [{"kind": "text", "text": content}],
"messageId": uuid4().hex,
@ -119,9 +120,7 @@ class A2ACompletionBridgeTransformation:
a2a_response = {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"message": a2a_message,
},
"result": a2a_message,
}
verbose_logger.debug(f"OpenAI -> A2A transform: content_length={len(content)}")
@ -272,11 +271,10 @@ class A2ACompletionBridgeTransformation:
"jsonrpc": "2.0",
"id": request_id,
"result": {
"message": {
"role": "agent",
"parts": [{"kind": "text", "text": content}],
"messageId": uuid4().hex,
},
"kind": "message",
"role": "agent",
"parts": [{"kind": "text", "text": content}],
"messageId": uuid4().hex,
"final": is_final,
},
}

View file

@ -326,7 +326,8 @@ class PydanticAITransformation:
"jsonrpc": "2.0",
"id": request_id,
"result": {
"message": a2a_message,
"kind": "message",
**a2a_message,
},
}

View file

@ -60,6 +60,10 @@ class A2ARequestUtils:
if not isinstance(result, dict):
return ""
# Direct message format (A2A spec)
if result.get("kind") == "message" or "parts" in result:
return A2ARequestUtils.extract_text_from_message(result)
message = result.get("message", {})
return A2ARequestUtils.extract_text_from_message(message)

View file

@ -139,14 +139,16 @@ class LangGraphConfig(BaseConfig):
def _convert_messages_to_langgraph_format(
self, messages: List[AllMessageValues]
) -> List[Dict[str, str]]:
) -> List[Dict[str, Any]]:
"""
Convert OpenAI-format messages to LangGraph format.
OpenAI format: {"role": "user", "content": "..."}
LangGraph format: {"role": "human", "content": "..."}
Preserves per-message ``metadata`` when present (e.g. A2A ``skillId``).
"""
langgraph_messages: List[Dict[str, str]] = []
langgraph_messages: List[Dict[str, Any]] = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
@ -169,7 +171,15 @@ class LangGraphConfig(BaseConfig):
if not isinstance(content, str):
content = str(content)
langgraph_messages.append({"role": langgraph_role, "content": content})
langgraph_message: Dict[str, Any] = {
"role": langgraph_role,
"content": content,
}
message_metadata = msg.get("metadata")
if isinstance(message_metadata, dict) and message_metadata:
langgraph_message["metadata"] = message_metadata
langgraph_messages.append(langgraph_message)
return langgraph_messages

View file

@ -94,6 +94,11 @@ LAZY_FEATURES: Tuple[LazyFeature, ...] = (
module_path="litellm.proxy.agent_endpoints.a2a_endpoints",
path_prefixes=("/a2a", "/v1/a2a"),
),
LazyFeature(
name="a2a_registration",
module_path="litellm.proxy.a2a.endpoints",
path_prefixes=("/v1/a2a/discover",),
),
LazyFeature(
name="vector_stores",
module_path="litellm.proxy.vector_store_endpoints.endpoints",

View file

@ -0,0 +1,29 @@
"""
A2A registration helpers for the LiteLLM proxy.
- ``discovery``: fetches the upstream agent's well-known card so the UI can
display its skills/capabilities for the user to pick from.
- ``agent_card``: pure merge logic that builds the LiteLLM-fronted agent card
from the upstream card + the values the user set in the UI.
- ``endpoints``: FastAPI routes that wire the above into the proxy.
"""
from litellm.proxy.a2a.agent_card import (
LITELLM_A2A_PROTOCOL_VERSION,
LITELLM_SECURITY_SCHEMES,
LITELLM_SECURITY_REQUIREMENTS,
merge_agent_card,
)
from litellm.proxy.a2a.discovery import (
AGENT_CARD_WELL_KNOWN_PATHS,
fetch_well_known_card,
)
__all__ = [
"AGENT_CARD_WELL_KNOWN_PATHS",
"LITELLM_A2A_PROTOCOL_VERSION",
"LITELLM_SECURITY_REQUIREMENTS",
"LITELLM_SECURITY_SCHEMES",
"fetch_well_known_card",
"merge_agent_card",
]

View file

@ -0,0 +1,160 @@
"""
Pure logic for merging an upstream A2A agent card with LiteLLM-specific overrides.
The merge produces the card that LiteLLM exposes to A2A clients at
``/a2a/{agent_id}/.well-known/agent-card.json``. The upstream card is taken as
the base; specific fields are replaced so all traffic flows through the proxy
and uses LiteLLM auth.
"""
from copy import deepcopy
from typing import Any, Dict, List, Mapping, Optional
# Protocol version LiteLLM speaks. Bump when the proxy's A2A surface changes.
LITELLM_A2A_PROTOCOL_VERSION = "1.0"
# Security scheme exposed by the LiteLLM-fronted agent card. Always replaces
# whatever upstream advertised — the client must authenticate to the proxy,
# not the upstream agent.
LITELLM_SECURITY_SCHEMES: Dict[str, Dict[str, Any]] = {
"LiteLLMKey": {
"type": "http",
"scheme": "bearer",
"description": "LiteLLM virtual key",
},
}
LITELLM_SECURITY_REQUIREMENTS: List[Dict[str, List[str]]] = [{"LiteLLMKey": []}]
# Capabilities LiteLLM can faithfully proxy today. Anything not in this set is
# dropped during merge so we don't advertise behavior the proxy can't deliver.
#
# TODO: re-enable ``streaming`` once the A2A streaming endpoint at
# ``POST /a2a/{agent_id}/message/stream`` is exercised end-to-end with
# cost tracking + guardrails. It's wired in ``a2a_endpoints.py`` but not
# yet covered by tests, so we keep it gated on the upstream advertising it.
# TODO: ``pushNotifications`` — proxy has no webhook plumbing yet.
# TODO: ``extendedAgentCard`` — no separate authenticated-extended-card
# endpoint exposed by the proxy.
# TODO: ``extensions`` — protocol extensions aren't validated/forwarded yet.
_ALLOWED_CAPABILITY_KEYS = {"streaming"}
# v1.0 AgentCard top-level fields. Anything else is stripped from the merged
# card as a defense against upstream drift. ``supportedInterfaces`` is kept
# verbatim per product spec even though it is not in the v1.0 schema — clients
# that expect it will find it; clients that don't will ignore it.
_ALLOWED_TOP_LEVEL_KEYS = {
"protocolVersion",
"name",
"description",
"version",
"capabilities",
"defaultInputModes",
"defaultOutputModes",
"skills",
"preferredTransport",
"additionalInterfaces",
"supportedInterfaces",
"iconUrl",
"provider",
"documentationUrl",
"securitySchemes",
"securityRequirements",
"security",
"supportsAuthenticatedExtendedCard",
"signatures",
}
_DEFAULT_SKILLS: List[Dict[str, Any]] = [
{
"id": "chat",
"name": "Chat",
"description": "Conversational interaction with the agent.",
"tags": ["chat"],
}
]
_DEFAULT_MODES: List[str] = ["text"]
def _filter_capabilities(upstream_capabilities: Any) -> Dict[str, Any]:
"""Return a capabilities dict containing only allowlisted, truthy keys."""
if not isinstance(upstream_capabilities, dict):
return {}
return {
key: value
for key, value in upstream_capabilities.items()
if key in _ALLOWED_CAPABILITY_KEYS and bool(value)
}
def _default_litellm_provider(proxy_base_url: str) -> Dict[str, str]:
return {"organization": "LiteLLM Proxy", "url": proxy_base_url}
def merge_agent_card(
upstream_card: Optional[Mapping[str, Any]],
*,
proxy_url: str,
proxy_base_url: str,
name: Optional[str] = None,
description: Optional[str] = None,
) -> Dict[str, Any]:
"""
Build the LiteLLM-fronted agent card.
Args:
upstream_card: Card returned by the upstream agent's well-known endpoint.
May be ``None``/empty when the upstream did not expose one.
proxy_url: Full URL clients should hit to invoke this agent through
the proxy, e.g. ``https://proxy.example.com/a2a/<agent_id>``.
proxy_base_url: Root URL of the LiteLLM proxy, used as a fallback when
we synthesize a provider record.
name: User-supplied agent name from the LiteLLM UI. Takes precedence
over the upstream card's ``name``.
description: User-supplied description from the LiteLLM UI. Takes
precedence over the upstream card's ``description``.
Returns:
A dict suitable for serving as the proxy's agent card. Only keys in
the v1.0 AgentCard schema (plus ``supportedInterfaces``) are emitted.
"""
base: Dict[str, Any] = deepcopy(dict(upstream_card)) if upstream_card else {}
# Strip the upstream URL so clients don't accidentally bypass the proxy.
base.pop("url", None)
base["protocolVersion"] = LITELLM_A2A_PROTOCOL_VERSION
if name:
base["name"] = name
if description:
base["description"] = description
base["capabilities"] = _filter_capabilities(base.get("capabilities"))
if not base.get("skills"):
base["skills"] = deepcopy(_DEFAULT_SKILLS)
if not base.get("defaultInputModes"):
base["defaultInputModes"] = list(_DEFAULT_MODES)
if not base.get("defaultOutputModes"):
base["defaultOutputModes"] = list(_DEFAULT_MODES)
if not base.get("provider"):
base["provider"] = _default_litellm_provider(proxy_base_url)
base["supportedInterfaces"] = [
{
"url": proxy_url,
"protocolBinding": "JSONRPC",
"protocolVersion": LITELLM_A2A_PROTOCOL_VERSION,
}
]
base["securitySchemes"] = deepcopy(LITELLM_SECURITY_SCHEMES)
base["securityRequirements"] = deepcopy(LITELLM_SECURITY_REQUIREMENTS)
# Drop the upstream's per-call ``security`` selector — the proxy enforces
# its own scheme regardless of what upstream required.
base.pop("security", None)
return {key: value for key, value in base.items() if key in _ALLOWED_TOP_LEVEL_KEYS}

View file

@ -0,0 +1,145 @@
"""
Fetch an A2A agent's well-known card from the upstream agent.
Different agent runtimes publish the card at different URL shapes, so the
fetcher dispatches by ``discovery_mode``:
- ``well_known_fallback`` (pure A2A): the card lives at one of the standard
well-known paths on the agent's own base URL. We try the canonical path,
then the previous-spec path, then a non-standard root fallback.
- ``langgraph_platform``: LangGraph Platform mounts a single card endpoint at
``{base}/.well-known/agent-card.json`` and disambiguates assistants via the
``assistant_id`` query parameter. There is no per-assistant subpath, so the
pure-A2A fallback strategy returns 404 for these deployments.
"""
from enum import Enum
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlencode
from litellm._logging import verbose_proxy_logger
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.types.llms.custom_http import httpxSpecialProvider
class DiscoveryMode(str, Enum):
"""How to locate the upstream agent card.
String-valued so it serializes cleanly over JSON / Pydantic.
"""
WELL_KNOWN_FALLBACK = "well_known_fallback"
LANGGRAPH_PLATFORM = "langgraph_platform"
# Paths the pure-A2A fetcher tries in order. The first two are the current and
# previous A2A spec locations; ``/agent.json`` is a non-standard root fallback
# some agents still serve.
AGENT_CARD_WELL_KNOWN_PATHS: Tuple[str, ...] = (
"/.well-known/agent-card.json",
"/.well-known/agent.json",
"/agent.json",
)
DEFAULT_DISCOVERY_TIMEOUT_SECONDS = 10.0
class AgentCardDiscoveryError(Exception):
"""Raised when none of the well-known paths returned a usable agent card."""
def _normalize_base_url(base_url: str) -> str:
return base_url.rstrip("/")
def _build_langgraph_platform_paths(
params: Optional[Dict[str, Any]],
) -> Tuple[str, ...]:
"""Build the paths to try for LangGraph Platform discovery.
LangGraph serves the card at ``/.well-known/agent-card.json`` with the
``assistant_id`` carried as a query parameter. We still try the other
A2A path variants (with the same query string appended) so we degrade
gracefully if a deployment uses an older spec name.
"""
assistant_id = (params or {}).get("assistant_id")
if not assistant_id:
raise AgentCardDiscoveryError(
"langgraph_platform discovery requires params.assistant_id"
)
query = urlencode({"assistant_id": str(assistant_id)})
return tuple(f"{path}?{query}" for path in AGENT_CARD_WELL_KNOWN_PATHS)
def _paths_for_mode(
mode: DiscoveryMode, params: Optional[Dict[str, Any]]
) -> Tuple[str, ...]:
if mode == DiscoveryMode.WELL_KNOWN_FALLBACK:
return AGENT_CARD_WELL_KNOWN_PATHS
if mode == DiscoveryMode.LANGGRAPH_PLATFORM:
return _build_langgraph_platform_paths(params)
raise AgentCardDiscoveryError(f"unsupported discovery_mode: {mode}")
async def fetch_well_known_card(
base_url: str,
*,
discovery_mode: DiscoveryMode = DiscoveryMode.WELL_KNOWN_FALLBACK,
params: Optional[Dict[str, Any]] = None,
timeout: float = DEFAULT_DISCOVERY_TIMEOUT_SECONDS,
headers: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
"""
Fetch an agent card from ``base_url`` using the strategy chosen by
``discovery_mode``. Returns the parsed JSON from the first path that
responds with a JSON body.
Raises:
AgentCardDiscoveryError: if every path fails (network error, non-2xx,
or non-JSON body), or if the chosen mode is missing required params.
"""
if not base_url:
raise AgentCardDiscoveryError("base_url is required")
normalized = _normalize_base_url(base_url)
paths = _paths_for_mode(discovery_mode, params)
client = get_async_httpx_client(
llm_provider=httpxSpecialProvider.A2A,
params={"timeout": timeout},
)
last_error: Optional[str] = None
for path in paths:
url = f"{normalized}{path}"
try:
response = await client.get(url, headers=headers)
except Exception as exc:
last_error = f"{url}: {exc!s}"
verbose_proxy_logger.debug("A2A discovery failed for %s: %s", url, exc)
continue
if response.status_code >= 400:
last_error = f"{url}: HTTP {response.status_code}"
verbose_proxy_logger.debug(
"A2A discovery HTTP %s for %s", response.status_code, url
)
continue
try:
card = response.json()
except Exception as exc:
last_error = f"{url}: invalid JSON ({exc!s})"
continue
if not isinstance(card, dict):
last_error = f"{url}: expected JSON object, got {type(card).__name__}"
continue
verbose_proxy_logger.debug("A2A discovery succeeded at %s", url)
return card
raise AgentCardDiscoveryError(
f"Could not fetch agent card from {base_url} (mode={discovery_mode.value}). "
f"Last error: {last_error}"
)

View file

@ -0,0 +1,120 @@
"""
FastAPI routes for the A2A registration flow.
Today this exposes a single endpoint, ``POST /v1/a2a/discover``, used by the
LiteLLM UI when an admin registers a new A2A agent: the UI hands us the
upstream agent's base URL, we fetch its well-known card, and we return the
raw card so the UI can render the agent's skills/capabilities and let the
admin pick which ones to expose through the proxy. The actual merge into a
LiteLLM-fronted card happens when the agent is saved via ``POST /v1/agents``.
"""
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.a2a.discovery import (
AGENT_CARD_WELL_KNOWN_PATHS,
AgentCardDiscoveryError,
DiscoveryMode,
fetch_well_known_card,
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
router = APIRouter()
class DiscoverAgentRequest(BaseModel):
url: str = Field(
...,
description=(
"Base URL of the upstream agent. Behavior depends on "
"``discovery_mode``: ``well_known_fallback`` (default) tries "
f"{', '.join(AGENT_CARD_WELL_KNOWN_PATHS)} under this URL in "
"order; ``langgraph_platform`` hits "
"``/.well-known/agent-card.json?assistant_id=<id>`` instead."
),
)
discovery_mode: DiscoveryMode = Field(
default=DiscoveryMode.WELL_KNOWN_FALLBACK,
description=(
"How to locate the upstream card. "
"``well_known_fallback`` for pure A2A agents (try standard paths); "
"``langgraph_platform`` for LangGraph Platform deployments where "
"the card is shared across assistants and disambiguated by a "
"query parameter."
),
)
params: Optional[Dict[str, Any]] = Field(
default=None,
description=(
"Mode-specific parameters. ``langgraph_platform`` requires "
"``{'assistant_id': <id>}``. ``well_known_fallback`` ignores this."
),
)
headers: Optional[Dict[str, str]] = Field(
default=None,
description="Optional headers to send with the discovery request (e.g. auth).",
)
class DiscoverAgentResponse(BaseModel):
url: str
agent_card: Dict[str, Any]
@router.post(
"/v1/a2a/discover",
tags=["[beta] A2A Agents"],
dependencies=[Depends(user_api_key_auth)],
response_model=DiscoverAgentResponse,
)
async def discover_agent_card(
request: DiscoverAgentRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
) -> JSONResponse:
"""
Fetch the upstream agent's well-known card so the UI can show the admin
which skills/capabilities the agent exposes.
Only proxy admins can call this — the UI uses it during agent registration,
and we don't want arbitrary keys probing internal URLs.
Example:
```bash
curl -X POST "http://localhost:4000/v1/a2a/discover" \\
-H "Authorization: Bearer <admin_key>" \\
-H "Content-Type: application/json" \\
-d '{"url": "https://upstream-agent.example.com"}'
```
"""
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
raise HTTPException(
status_code=403,
detail=(
"Only proxy admins can discover agent cards. "
f"Your role={user_api_key_dict.user_role}"
),
)
try:
card = await fetch_well_known_card(
request.url,
discovery_mode=request.discovery_mode,
params=request.params,
headers=request.headers,
)
except AgentCardDiscoveryError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception as exc:
verbose_proxy_logger.exception("Unexpected error during A2A discovery: %s", exc)
raise HTTPException(status_code=500, detail=f"Discovery failed: {exc!s}")
return JSONResponse(
content={"url": request.url, "agent_card": card},
media_type="application/json",
)

View file

@ -92,10 +92,19 @@ class AgentRegistry:
########### DB management helpers for agents ###########
############################################################
async def add_agent_to_db(
self, agent: AgentConfig, prisma_client: PrismaClient, created_by: str
self,
agent: AgentConfig,
prisma_client: PrismaClient,
created_by: str,
agent_id: Optional[str] = None,
) -> AgentResponse:
"""
Add an agent to the database
Add an agent to the database.
If ``agent_id`` is provided, it is used as the primary key for the new
row (otherwise the DB generates a UUID). Callers pass an explicit ID
when the agent_card_params must reference the agent's own URL before
the row exists, e.g. the A2A merge in ``create_agent``.
"""
try:
agent_name = agent.get("agent_name")
@ -145,6 +154,8 @@ class AgentRegistry:
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
}
if agent_id is not None:
create_data["agent_id"] = agent_id
if static_headers_val is not None:
create_data["static_headers"] = static_headers_val
if extra_headers_val is not None:

View file

@ -10,7 +10,8 @@ Follows the A2A Spec.
import asyncio
import os
from typing import Any, Dict, List, Optional
import uuid
from typing import Any, Dict, List, Mapping, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
@ -18,6 +19,7 @@ import litellm
from litellm._logging import verbose_proxy_logger
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.a2a.agent_card import merge_agent_card
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
from litellm.proxy.management_endpoints.common_daily_activity import get_daily_activity
@ -35,6 +37,27 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import (
SpendAnalyticsPaginatedResponse,
)
def _proxy_base_url(http_request: Request) -> str:
"""Return the proxy's base URL as seen by the caller, without trailing slash."""
return str(http_request.base_url).rstrip("/")
def _build_merged_agent_card(
upstream_card: Optional[Mapping[str, Any]],
*,
agent_id: str,
http_request: Request,
) -> Dict[str, Any]:
"""Apply the LiteLLM-fronting merge to ``upstream_card`` for ``agent_id``."""
proxy_base = _proxy_base_url(http_request)
return merge_agent_card(
upstream_card,
proxy_url=f"{proxy_base}/a2a/{agent_id}",
proxy_base_url=proxy_base,
)
router = APIRouter()
@ -281,6 +304,7 @@ from litellm.proxy.agent_endpoints.agent_registry import (
)
async def create_agent(
request: AgentConfig,
http_request: Request,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
@ -345,8 +369,23 @@ async def create_agent(
detail=f"Agent with name {request.get('agent_name')} already exists",
)
# Apply the LiteLLM-fronting merge so the stored card already advertises
# the proxy URL, LiteLLMKey auth, and the filtered capability set. We
# pre-generate the agent_id so the merged card can reference it in
# ``supportedInterfaces`` before the DB row exists.
new_agent_id = str(uuid.uuid4())
merged_card = _build_merged_agent_card(
request.get("agent_card_params"),
agent_id=new_agent_id,
http_request=http_request,
)
merged_request: AgentConfig = {**request, "agent_card_params": merged_card} # type: ignore[typeddict-item]
result = await AGENT_REGISTRY.add_agent_to_db(
agent=request, prisma_client=prisma_client, created_by=created_by
agent=merged_request,
prisma_client=prisma_client,
created_by=created_by,
agent_id=new_agent_id,
)
agent_name = result.agent_name
@ -473,6 +512,7 @@ async def get_agent_by_id(
async def update_agent(
agent_id: str,
request: AgentConfig,
http_request: Request,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
@ -533,9 +573,19 @@ async def update_agent(
# Get the user ID from the API key auth
updated_by = user_api_key_dict.user_id or "unknown"
# Re-apply the LiteLLM-fronting merge — an update is a re-registration,
# so any new upstream card the admin pasted must go through the same
# transformation as initial create.
merged_card = _build_merged_agent_card(
request.get("agent_card_params"),
agent_id=agent_id,
http_request=http_request,
)
merged_request: AgentConfig = {**request, "agent_card_params": merged_card} # type: ignore[typeddict-item]
result = await AGENT_REGISTRY.update_agent_in_db(
agent_id=agent_id,
agent=request,
agent=merged_request,
prisma_client=prisma_client,
updated_by=updated_by,
)
@ -566,6 +616,7 @@ async def update_agent(
async def patch_agent(
agent_id: str,
request: PatchAgentRequest,
http_request: Request,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
@ -626,9 +677,21 @@ async def patch_agent(
# Get the user ID from the API key auth
updated_by = user_api_key_dict.user_id or "unknown"
# Re-merge only when the patch actually touches agent_card_params; a
# patch updating just litellm_params/rate limits shouldn't rewrite the
# stored card.
patch_payload: PatchAgentRequest = request
if "agent_card_params" in request:
merged_card = _build_merged_agent_card(
request.get("agent_card_params"),
agent_id=agent_id,
http_request=http_request,
)
patch_payload = {**request, "agent_card_params": merged_card} # type: ignore[typeddict-item]
result = await AGENT_REGISTRY.patch_agent_in_db(
agent_id=agent_id,
agent=request,
agent=patch_payload,
prisma_client=prisma_client,
updated_by=updated_by,
)

View file

@ -54,9 +54,9 @@ async def test_a2a_completion_bridge_non_streaming():
assert response.jsonrpc == "2.0"
assert response.id is not None
assert response.result is not None
assert "message" in response.result
assert response.result.get("kind") == "message"
message = response.result["message"]
message = response.result
assert "role" in message
assert message["role"] == "agent"
assert "parts" in message

View file

@ -16,6 +16,54 @@ import pytest
class TestA2AStreamingTransformation:
"""Test the A2A streaming transformation creates proper events."""
def test_a2a_metadata_forwarded_to_completion_params(self):
from litellm.a2a_protocol.litellm_completion_bridge.transformation import (
A2ACompletionBridgeTransformation,
)
message = {
"role": "user",
"parts": [{"text": "Reply to ticket #4823"}],
"metadata": {"skillId": "draft_reply"},
}
openai_messages = (
A2ACompletionBridgeTransformation.a2a_message_to_openai_messages(message)
)
# Metadata is forwarded on the run payload only, not duplicated on messages.
assert "metadata" not in openai_messages[0]
completion_params: dict = {"model": "langgraph/agent", "messages": openai_messages}
A2ACompletionBridgeTransformation.apply_forward_metadata_to_completion_params(
completion_params=completion_params,
a2a_message=message,
params={"metadata": {"trace": "abc"}},
)
assert completion_params["extra_body"]["metadata"] == {
"trace": "abc",
"skillId": "draft_reply",
}
def test_langgraph_transform_preserves_message_metadata(self):
from litellm.llms.langgraph.chat.transformation import LangGraphConfig
config = LangGraphConfig()
request = config.transform_request(
model="langgraph/agent",
messages=[
{
"role": "user",
"content": "Reply to ticket #4823",
"metadata": {"skillId": "draft_reply"},
}
],
optional_params={},
litellm_params={"stream": False},
headers={},
)
assert request["input"]["messages"][-1]["metadata"] == {
"skillId": "draft_reply",
}
def test_create_task_event(self):
"""Test that create_task_event produces proper A2A task event structure."""
from litellm.a2a_protocol.litellm_completion_bridge.transformation import (

View file

View file

@ -0,0 +1,163 @@
"""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"}},
"securityRequirements": [{"upstreamKey": []}],
"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_strips_top_level_url():
merged = merge_agent_card(
_full_upstream_card(), proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE
)
assert "url" not in merged
def test_overrides_protocol_version():
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_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["securityRequirements"] == LITELLM_SECURITY_REQUIREMENTS
assert "security" 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_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

View file

@ -0,0 +1,271 @@
"""Tests for the well-known card fetcher and the discovery endpoint."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.a2a.discovery import (
AGENT_CARD_WELL_KNOWN_PATHS,
AgentCardDiscoveryError,
DiscoveryMode,
fetch_well_known_card,
)
from litellm.proxy.a2a.endpoints import router as a2a_router
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
# ---------------------------------------------------------------------------
# fetch_well_known_card
# ---------------------------------------------------------------------------
def _mock_response(status_code: int = 200, body=None, raise_json=False):
response = MagicMock()
response.status_code = status_code
if raise_json:
response.json = MagicMock(side_effect=ValueError("bad json"))
else:
response.json = MagicMock(return_value=body)
return response
@pytest.mark.asyncio
async def test_fetch_uses_first_path_that_returns_200():
body = {"name": "agent"}
fake_client = MagicMock()
fake_client.get = AsyncMock(return_value=_mock_response(200, body=body))
with patch(
"litellm.proxy.a2a.discovery.get_async_httpx_client", return_value=fake_client
):
card = await fetch_well_known_card("https://upstream.example")
assert card == body
# First call should be to the canonical path.
called_url = fake_client.get.call_args.args[0]
assert called_url == f"https://upstream.example{AGENT_CARD_WELL_KNOWN_PATHS[0]}"
@pytest.mark.asyncio
async def test_fetch_falls_back_to_later_paths_on_404():
body = {"name": "agent"}
fake_client = MagicMock()
fake_client.get = AsyncMock(
side_effect=[
_mock_response(404),
_mock_response(404),
_mock_response(200, body=body),
]
)
with patch(
"litellm.proxy.a2a.discovery.get_async_httpx_client", return_value=fake_client
):
card = await fetch_well_known_card("https://upstream.example")
assert card == body
assert fake_client.get.await_count == len(AGENT_CARD_WELL_KNOWN_PATHS)
@pytest.mark.asyncio
async def test_fetch_raises_when_all_paths_fail():
fake_client = MagicMock()
fake_client.get = AsyncMock(
side_effect=[_mock_response(404) for _ in AGENT_CARD_WELL_KNOWN_PATHS]
)
with patch(
"litellm.proxy.a2a.discovery.get_async_httpx_client", return_value=fake_client
):
with pytest.raises(AgentCardDiscoveryError):
await fetch_well_known_card("https://upstream.example")
@pytest.mark.asyncio
async def test_fetch_skips_path_that_returns_non_json_body():
body = {"name": "agent"}
fake_client = MagicMock()
fake_client.get = AsyncMock(
side_effect=[
_mock_response(200, raise_json=True),
_mock_response(200, body=body),
]
)
with patch(
"litellm.proxy.a2a.discovery.get_async_httpx_client", return_value=fake_client
):
card = await fetch_well_known_card("https://upstream.example")
assert card == body
@pytest.mark.asyncio
async def test_fetch_skips_path_that_returns_non_object_json():
fake_client = MagicMock()
fake_client.get = AsyncMock(
side_effect=[
_mock_response(200, body=["not", "an", "object"]),
_mock_response(200, body={"name": "agent"}),
_mock_response(404),
]
)
with patch(
"litellm.proxy.a2a.discovery.get_async_httpx_client", return_value=fake_client
):
card = await fetch_well_known_card("https://upstream.example")
assert card == {"name": "agent"}
@pytest.mark.asyncio
async def test_fetch_requires_base_url():
with pytest.raises(AgentCardDiscoveryError):
await fetch_well_known_card("")
# ---------------------------------------------------------------------------
# LangGraph Platform discovery mode
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_langgraph_mode_appends_assistant_id_query_param():
"""LangGraph serves one card endpoint; the assistant is selected via query string."""
body = {"name": "support-agent"}
fake_client = MagicMock()
fake_client.get = AsyncMock(return_value=_mock_response(200, body=body))
with patch(
"litellm.proxy.a2a.discovery.get_async_httpx_client", return_value=fake_client
):
card = await fetch_well_known_card(
"http://localhost:2024",
discovery_mode=DiscoveryMode.LANGGRAPH_PLATFORM,
params={"assistant_id": "agent"},
)
assert card == body
called_url = fake_client.get.call_args.args[0]
# The canonical A2A path with the LangGraph query parameter — NOT a
# per-assistant subpath like /agent/.well-known/agent-card.json.
assert called_url == (
"http://localhost:2024/.well-known/agent-card.json?assistant_id=agent"
)
@pytest.mark.asyncio
async def test_langgraph_mode_requires_assistant_id():
with pytest.raises(AgentCardDiscoveryError, match="assistant_id"):
await fetch_well_known_card(
"http://localhost:2024",
discovery_mode=DiscoveryMode.LANGGRAPH_PLATFORM,
params={},
)
@pytest.mark.asyncio
async def test_langgraph_mode_falls_back_to_older_well_known_paths():
"""If an older LangGraph deployment serves /.well-known/agent.json, accept that too."""
fake_client = MagicMock()
fake_client.get = AsyncMock(
side_effect=[
_mock_response(404),
_mock_response(200, body={"name": "support-agent"}),
]
)
with patch(
"litellm.proxy.a2a.discovery.get_async_httpx_client", return_value=fake_client
):
card = await fetch_well_known_card(
"http://localhost:2024",
discovery_mode=DiscoveryMode.LANGGRAPH_PLATFORM,
params={"assistant_id": "agent"},
)
assert card == {"name": "support-agent"}
# Both calls carry the assistant_id query param.
for call in fake_client.get.await_args_list:
assert "assistant_id=agent" in call.args[0]
# ---------------------------------------------------------------------------
# POST /v1/a2a/discover
# ---------------------------------------------------------------------------
def _client_for_role(role: LitellmUserRoles) -> TestClient:
app = FastAPI()
app.include_router(a2a_router)
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_id="u", user_role=role
)
return TestClient(app)
def test_discover_admin_returns_raw_card():
client = _client_for_role(LitellmUserRoles.PROXY_ADMIN)
with patch(
"litellm.proxy.a2a.endpoints.fetch_well_known_card",
new=AsyncMock(return_value={"name": "Upstream"}),
):
resp = client.post("/v1/a2a/discover", json={"url": "https://upstream.example"})
assert resp.status_code == 200
body = resp.json()
assert body["url"] == "https://upstream.example"
assert body["agent_card"] == {"name": "Upstream"}
def test_discover_non_admin_forbidden():
client = _client_for_role(LitellmUserRoles.INTERNAL_USER)
resp = client.post("/v1/a2a/discover", json={"url": "https://upstream.example"})
assert resp.status_code == 403
def test_discover_returns_400_when_upstream_unreachable():
client = _client_for_role(LitellmUserRoles.PROXY_ADMIN)
with patch(
"litellm.proxy.a2a.endpoints.fetch_well_known_card",
new=AsyncMock(side_effect=AgentCardDiscoveryError("no luck")),
):
resp = client.post("/v1/a2a/discover", json={"url": "https://upstream.example"})
assert resp.status_code == 400
assert "no luck" in resp.json()["detail"]
def test_discover_forwards_mode_and_params_to_fetcher():
"""The endpoint must hand discovery_mode + params to fetch_well_known_card."""
client = _client_for_role(LitellmUserRoles.PROXY_ADMIN)
fetch_stub = AsyncMock(return_value={"name": "support-agent"})
with patch("litellm.proxy.a2a.endpoints.fetch_well_known_card", new=fetch_stub):
resp = client.post(
"/v1/a2a/discover",
json={
"url": "http://localhost:2024",
"discovery_mode": "langgraph_platform",
"params": {"assistant_id": "agent"},
},
)
assert resp.status_code == 200
# Pydantic deserializes the JSON string back into the DiscoveryMode enum.
assert fetch_stub.await_args is not None
kwargs = fetch_stub.await_args.kwargs
assert kwargs["discovery_mode"] == DiscoveryMode.LANGGRAPH_PLATFORM
assert kwargs["params"] == {"assistant_id": "agent"}
def test_discover_rejects_unknown_mode():
"""Pydantic should 422 on an enum value we don't recognize."""
client = _client_for_role(LitellmUserRoles.PROXY_ADMIN)
resp = client.post(
"/v1/a2a/discover",
json={"url": "http://localhost:2024", "discovery_mode": "bogus"},
)
assert resp.status_code == 422

View file

@ -395,6 +395,34 @@ class TestAgentRBACProxyAdmin:
)
assert resp.status_code == 200
def test_create_agent_applies_litellm_merge_to_stored_card(self):
"""The card stored in the DB must reflect the LiteLLM-fronting merge."""
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()
self.admin_client.post(
"/v1/agents",
json=_sample_agent_config(),
headers={"Authorization": "Bearer k"},
)
call_kwargs = self.mock_registry.add_agent_to_db.await_args.kwargs
stored_card = call_kwargs["agent"]["agent_card_params"]
new_agent_id = call_kwargs["agent_id"]
# Top-level url is dropped; supportedInterfaces points at the proxy.
assert "url" not in stored_card
assert stored_card["supportedInterfaces"][0]["protocolBinding"] == "JSONRPC"
assert stored_card["supportedInterfaces"][0]["url"].endswith(
f"/a2a/{new_agent_id}"
)
# Security scheme is the LiteLLM scheme.
assert "LiteLLMKey" in stored_card["securitySchemes"]
def test_should_allow_admin_to_delete_agent(self):
existing = {
"agent_id": "agent-123",

View file

@ -19,6 +19,10 @@ import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_
import { Team } from "../key_team_helpers/key_list";
import TeamDropdown from "../common_components/team_dropdown";
import AgentFormFields from "./agent_form_fields";
import AgentCardDiscovery, {
DiscoveredAgentCardSelection,
DiscoveryRequestPlan,
} from "./agent_card_discovery";
import DynamicAgentFormFields, { buildDynamicAgentData } from "./dynamic_agent_form_fields";
import { getDefaultFormValues, buildAgentDataFromForm } from "./agent_config";
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
@ -75,6 +79,13 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
const [maxIterations, setMaxIterations] = useState<number | null>(null);
const [maxBudgetPerSession, setMaxBudgetPerSession] = useState<number | null>(null);
// Last discovery selection the admin clicked "Use these selections" on.
// Dynamic agent forms (LangGraph, Bedrock, Azure) don't render Form.Items
// for skills/capabilities/modes, so we can't carry these through form state
// — we keep them here and overlay them onto agent_card_params at submit.
const [appliedDiscoveredSelection, setAppliedDiscoveredSelection] =
useState<DiscoveredAgentCardSelection | null>(null);
// Fetch agent type metadata on mount
useEffect(() => {
const fetchMetadata = async () => {
@ -157,6 +168,68 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
(info) => info.agent_type === agentType
);
// Watch every form field so we can recompute the discovery plan whenever
// the user types into a relevant credential field below.
const watchedFormValues = Form.useWatch([], form);
// Build the discovery plan for the proxy. Different agent runtimes publish
// their cards at different URL shapes:
//
// - LangGraph Platform: one well-known endpoint on the base URL,
// ``?assistant_id=<id>`` selects the assistant.
// - Pure A2A (the default): card lives at one of the well-known paths
// on the agent's own base URL.
//
// Returns undefined when nothing usable is filled in yet, which causes the
// component to fall back to a manual URL input.
const discoveryRequest: DiscoveryRequestPlan | undefined = React.useMemo(() => {
const values = watchedFormValues || {};
const trim = (v: unknown) => (v ?? "").toString().trim();
const stripTrailingSlash = (s: string) => s.replace(/\/+$/, "");
// LangGraph Platform: base URL is `api_base`, assistant is in
// `assistant_id`. We hit ``{base}/.well-known/agent-card.json?assistant_id=…``.
if (agentType === "langgraph") {
const base = stripTrailingSlash(trim(values.api_base));
const assistantId = trim(values.assistant_id);
if (!base || !assistantId) return undefined;
const query = `?assistant_id=${encodeURIComponent(assistantId)}`;
return {
url: base,
discovery_mode: "langgraph_platform",
params: { assistant_id: assistantId },
display_url: `${base}/.well-known/agent-card.json${query}`,
};
}
// Pure A2A / use_a2a_form_fields: base URL is in the ``url`` form field.
if (agentType === "a2a" || selectedAgentTypeInfo?.use_a2a_form_fields) {
const base = stripTrailingSlash(trim(values.url));
if (!base) return undefined;
return {
url: base,
discovery_mode: "well_known_fallback",
display_url: `${base}/.well-known/agent-card.json`,
};
}
// Other dynamic types — try to derive a base URL from a credential field
// matching our URL-shaped regex; let the proxy walk the well-known paths.
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`,
};
}, [watchedFormValues, selectedAgentTypeInfo, agentType]);
const handleNext = async () => {
try {
if (currentStep === 0) {
@ -210,7 +283,37 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
}
return agentData;
} else if (selectedAgentTypeInfo) {
return buildDynamicAgentData(values, selectedAgentTypeInfo);
const agentData = buildDynamicAgentData(values, selectedAgentTypeInfo);
// The dynamic form has no UI for skills, capabilities, or input/output
// modes — buildDynamicAgentData fills those with a generic stub. If the
// admin ran discovery, their selections are the only real source of
// truth for these fields, so we overlay them here. Form-driven fields
// (name, description, version, url) stay as-is.
if (appliedDiscoveredSelection) {
const discovered = appliedDiscoveredSelection.selected_card;
agentData.agent_card_params = {
...agentData.agent_card_params,
...(Array.isArray(discovered.skills) &&
discovered.skills.length > 0 && { skills: discovered.skills }),
...(discovered.capabilities && {
capabilities: discovered.capabilities,
}),
...(Array.isArray(discovered.defaultInputModes) &&
discovered.defaultInputModes.length > 0 && {
defaultInputModes: discovered.defaultInputModes,
}),
...(Array.isArray(discovered.defaultOutputModes) &&
discovered.defaultOutputModes.length > 0 && {
defaultOutputModes: discovered.defaultOutputModes,
}),
...(discovered.provider && { provider: discovered.provider }),
...(discovered.iconUrl && { iconUrl: discovered.iconUrl }),
...(discovered.documentationUrl && {
documentationUrl: discovered.documentationUrl,
}),
};
}
return agentData;
}
return null;
};
@ -340,6 +443,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
setRequireTraceIdOutbound(false);
setMaxIterations(null);
setMaxBudgetPerSession(null);
setAppliedDiscoveredSelection(null);
onClose();
};
@ -568,6 +672,63 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
const handleAgentTypeChange = (value: string) => {
setAgentType(value);
form.resetFields();
// Discovery selections are tied to a specific agent type's URL shape;
// switching types invalidates them.
setAppliedDiscoveredSelection(null);
};
// Apply a discovered agent card to the form so the rest of Step 1 (skills,
// capabilities, name, description, URL) reflects what the user picked. The
// proxy re-applies its own merge at registration; we only seed defaults here.
//
// AntD's `setFieldsValue` silently ignores keys whose Form.Item isn't
// registered, so this is safe across all agent types — A2A forms pick up
// every field below; LangGraph and other dynamic forms only pick up the
// shared ones (`agent_name`, `description`, plus any credential field whose
// key looks URL-ish).
const handleApplyDiscoveredCard = (selection: DiscoveredAgentCardSelection) => {
setAppliedDiscoveredSelection(selection);
const { selected_card, upstream_url } = selection;
const skills = (selected_card.skills ?? []).map((s) => ({
id: s.id ?? "",
name: s.name ?? "",
description: s.description ?? "",
tags: s.tags ?? [],
examples: s.examples ?? [],
}));
const currentAgentName = form.getFieldValue("agent_name");
const seededAgentName =
currentAgentName || selected_card.name || selected_card.provider?.organization || "";
const fieldsToSet: Record<string, any> = {
agent_name: seededAgentName,
name: selected_card.name,
description: selected_card.description,
url: upstream_url,
version: selected_card.version,
protocolVersion: selected_card.protocolVersion ?? "1.0",
streaming: Boolean(selected_card.capabilities?.streaming),
skills,
iconUrl: selected_card.iconUrl,
documentationUrl: selected_card.documentationUrl,
};
// For dynamic agent types (e.g. LangGraph), the URL lives in a
// type-specific credential field. Match on common naming variants so the
// user doesn't have to re-paste the URL they already typed above.
const urlCredentialKeys = (selectedAgentTypeInfo?.credential_fields ?? [])
.map((f) => f.key)
.filter((key) => /(^|_)(url|api_base|endpoint)$/i.test(key));
for (const key of urlCredentialKeys) {
fieldsToSet[key] = upstream_url;
}
form.setFieldsValue(fieldsToSet);
if (!newKeyName && seededAgentName) {
setNewKeyName(`${seededAgentName}-key`);
}
};
const isCustomAgent = agentType === CUSTOM_AGENT_TYPE;
@ -702,6 +863,21 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({
) : selectedAgentTypeInfo ? (
<DynamicAgentFormFields agentTypeInfo={selectedAgentTypeInfo} />
) : null}
{/* Discovery sits at the bottom so its URL can be derived from the
credential fields the user typed above. The plan (URL + mode +
params) is computed from the agent type — LangGraph hits a
different shape than pure A2A. Custom agents have no upstream to
discover, so we skip them. */}
{agentType !== CUSTOM_AGENT_TYPE && (
<div className="mt-4">
<AgentCardDiscovery
accessToken={accessToken}
onApply={handleApplyDiscoveredCard}
discoveryRequest={discoveryRequest}
/>
</div>
)}
</div>
</>

View file

@ -0,0 +1,245 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "../../../tests/test-utils";
import AgentCardDiscovery from "./agent_card_discovery";
vi.mock("../networking", async () => {
const actual = await vi.importActual<any>("../networking");
return {
...actual,
discoverAgentCardCall: vi.fn(),
};
});
import { discoverAgentCardCall } from "../networking";
const mockDiscover = discoverAgentCardCall as unknown as ReturnType<typeof vi.fn>;
const sampleCard = {
protocolVersion: "1.0",
name: "Upstream Agent",
description: "An upstream agent",
version: "1.2.3",
url: "http://internal:9000",
capabilities: { streaming: true, pushNotifications: true },
skills: [
{
id: "search",
name: "Search",
description: "Search the web",
tags: ["search"],
},
{
id: "summarize",
name: "Summarize",
description: "Summarize a document",
tags: ["llm"],
},
],
provider: { organization: "UpstreamCo", url: "https://upstream.example" },
};
describe("AgentCardDiscovery", () => {
beforeEach(() => {
mockDiscover.mockReset();
});
it("renders the URL input and a Discover button", () => {
renderWithProviders(
<AgentCardDiscovery accessToken="tok" onApply={vi.fn()} />,
);
expect(
screen.getByPlaceholderText("https://upstream-agent.example.com"),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: /discover/i })).toBeInTheDocument();
});
it("shows an error when discover is clicked without a URL", async () => {
const user = userEvent.setup();
renderWithProviders(
<AgentCardDiscovery accessToken="tok" onApply={vi.fn()} />,
);
await user.click(screen.getByRole("button", { name: /discover/i }));
expect(
await screen.findByText(/Enter the agent's base URL first/i),
).toBeInTheDocument();
expect(mockDiscover).not.toHaveBeenCalled();
});
it("renders the upstream skills and capabilities on success", async () => {
mockDiscover.mockResolvedValueOnce({
url: "https://upstream.example.com",
agent_card: sampleCard,
});
const user = userEvent.setup();
renderWithProviders(
<AgentCardDiscovery accessToken="tok" onApply={vi.fn()} />,
);
await user.type(
screen.getByPlaceholderText("https://upstream-agent.example.com"),
"https://upstream.example.com",
);
await user.click(screen.getByRole("button", { name: /discover/i }));
expect(await screen.findByText("Upstream card loaded")).toBeInTheDocument();
expect(screen.getByText("Search")).toBeInTheDocument();
expect(screen.getByText("Summarize")).toBeInTheDocument();
// Only proxy-supported capabilities surface (streaming).
expect(screen.getByText(/^streaming$/i)).toBeInTheDocument();
expect(screen.queryByText(/pushNotifications/i)).not.toBeInTheDocument();
});
it("shows an inline error when discovery fails", async () => {
mockDiscover.mockRejectedValueOnce(new Error("upstream unreachable"));
const user = userEvent.setup();
renderWithProviders(
<AgentCardDiscovery accessToken="tok" onApply={vi.fn()} />,
);
await user.type(
screen.getByPlaceholderText("https://upstream-agent.example.com"),
"https://nope.example",
);
await user.click(screen.getByRole("button", { name: /discover/i }));
expect(await screen.findByText("Discovery failed")).toBeInTheDocument();
expect(screen.getByText(/upstream unreachable/)).toBeInTheDocument();
});
it("emits the selected subset when the user applies the card", async () => {
mockDiscover.mockResolvedValueOnce({
url: "https://upstream.example.com",
agent_card: sampleCard,
});
const onApply = vi.fn();
const user = userEvent.setup();
renderWithProviders(
<AgentCardDiscovery accessToken="tok" onApply={onApply} />,
);
await user.type(
screen.getByPlaceholderText("https://upstream-agent.example.com"),
"https://upstream.example.com",
);
await user.click(screen.getByRole("button", { name: /discover/i }));
await screen.findByText("Upstream card loaded");
// Deselect the "Summarize" skill by clicking its row's checkbox.
const summarizeLabel = screen.getByText("Summarize").closest("label");
expect(summarizeLabel).toBeTruthy();
const summarizeCheckbox = summarizeLabel!.querySelector(
"input[type='checkbox']",
) as HTMLInputElement;
await user.click(summarizeCheckbox);
await user.click(screen.getByRole("button", { name: /use these selections/i }));
await waitFor(() => expect(onApply).toHaveBeenCalledTimes(1));
const selection = onApply.mock.calls[0][0];
expect(selection.upstream_url).toBe("https://upstream.example.com");
expect(selection.raw_card).toEqual(sampleCard);
expect(selection.selected_card.skills).toHaveLength(1);
expect(selection.selected_card.skills[0].id).toBe("search");
expect(selection.selected_card.name).toBe("Upstream Agent");
});
it("hides the URL input and shows the display URL when parent-driven", () => {
renderWithProviders(
<AgentCardDiscovery
accessToken="tok"
onApply={vi.fn()}
discoveryRequest={{
url: "http://localhost:2024",
discovery_mode: "langgraph_platform",
params: { assistant_id: "agent" },
display_url:
"http://localhost:2024/.well-known/agent-card.json?assistant_id=agent",
}}
/>,
);
// Free-form URL input is gone.
expect(
screen.queryByPlaceholderText("https://upstream-agent.example.com"),
).not.toBeInTheDocument();
// The exact URL the proxy will hit is visible.
expect(
screen.getByText(
"http://localhost:2024/.well-known/agent-card.json?assistant_id=agent",
),
).toBeInTheDocument();
});
it("forwards discovery_mode and params from the parent plan", async () => {
mockDiscover.mockResolvedValueOnce({
url: "http://localhost:2024",
agent_card: sampleCard,
});
const user = userEvent.setup();
renderWithProviders(
<AgentCardDiscovery
accessToken="tok"
onApply={vi.fn()}
discoveryRequest={{
url: "http://localhost:2024",
discovery_mode: "langgraph_platform",
params: { assistant_id: "agent" },
display_url:
"http://localhost:2024/.well-known/agent-card.json?assistant_id=agent",
}}
/>,
);
await user.click(screen.getByRole("button", { name: /discover/i }));
await waitFor(() => expect(mockDiscover).toHaveBeenCalledTimes(1));
expect(mockDiscover).toHaveBeenCalledWith("tok", "http://localhost:2024", {
discovery_mode: "langgraph_platform",
params: { assistant_id: "agent" },
});
});
it("disables Discover until the parent provides a usable URL", async () => {
renderWithProviders(
<AgentCardDiscovery
accessToken="tok"
onApply={vi.fn()}
discoveryRequest={{
url: "",
discovery_mode: "langgraph_platform",
params: { assistant_id: "" },
display_url: "",
}}
/>,
);
expect(
(screen.getByRole("button", {
name: /discover/i,
}) as HTMLButtonElement).disabled,
).toBe(true);
});
it("blocks discover when no access token is provided", async () => {
const user = userEvent.setup();
renderWithProviders(
<AgentCardDiscovery accessToken={null} onApply={vi.fn()} />,
);
await user.type(
screen.getByPlaceholderText("https://upstream-agent.example.com"),
"https://upstream.example.com",
);
await user.click(screen.getByRole("button", { name: /discover/i }));
expect(
await screen.findByText(/No access token available/i),
).toBeInTheDocument();
expect(mockDiscover).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,442 @@
"use client";
import React, { useState } from "react";
import {
Alert,
Button,
Checkbox,
Collapse,
Empty,
Input,
Space,
Spin,
Switch,
Tag,
Tooltip,
Typography,
} from "antd";
// Empty is used in the skills panel below.
import {
CheckCircleTwoTone,
InfoCircleOutlined,
LinkOutlined,
ReloadOutlined,
SearchOutlined,
} from "@ant-design/icons";
import {
DiscoveredAgentCard,
DiscoveryMode,
discoverAgentCardCall,
} from "../networking";
const { Text, Paragraph } = Typography;
const { Panel } = Collapse;
export interface DiscoveredAgentCardSelection {
/** Full upstream card the proxy fetched, unmodified. */
raw_card: DiscoveredAgentCard;
/** Subset of the upstream card with only the user-selected skills and
* capabilities, plus the user-edited name/description. Suitable to send as
* ``agent_card_params`` on ``POST /v1/agents``. */
selected_card: DiscoveredAgentCard;
/** The base URL the user pasted in. */
upstream_url: string;
}
/**
* What the parent wants the discovery endpoint to do. When the parent can
* derive this from form state (e.g. agent type = LangGraph + assistant_id +
* api_base), it owns the values; the component just relays them. Different
* upstreams use different URL conventions, so the mode matters.
*/
export interface DiscoveryRequestPlan {
/** Base URL to send to the proxy. */
url: string;
/** Which dispatch path the proxy should use. */
discovery_mode: DiscoveryMode;
/** Mode-specific params (e.g. ``{assistant_id}`` for LangGraph). */
params?: Record<string, any>;
/** Human-readable rendering of the URL the proxy will ultimately fetch.
* Shown in the UI so the admin can see what we'll hit. */
display_url?: string;
}
interface AgentCardDiscoveryProps {
accessToken: string | null;
onApply: (selection: DiscoveredAgentCardSelection) => void;
/**
* Parent-supplied discovery plan. When provided the component uses these
* values verbatim and hides its free-form URL input — the parent is the
* source of truth (e.g. for LangGraph it's derived from api_base +
* assistant_id). When omitted the component falls back to a manual URL
* input that defaults to ``well_known_fallback`` mode.
*/
discoveryRequest?: DiscoveryRequestPlan;
}
const ALLOWED_CAPABILITY_KEYS = ["streaming"] as const;
const skillId = (skill: any, idx: number): string =>
skill?.id ?? skill?.name ?? `skill-${idx}`;
/**
* Mirrors the proxy-side `_ALLOWED_CAPABILITY_KEYS` allowlist. Keep these in
* sync — anything we surface here that the proxy strips would look like a
* silent drop to the admin.
*/
const filterCapabilitiesForUI = (
capabilities: Record<string, any> | undefined,
): Record<string, any> => {
if (!capabilities) return {};
return ALLOWED_CAPABILITY_KEYS.reduce<Record<string, any>>((acc, key) => {
if (key in capabilities) acc[key] = Boolean(capabilities[key]);
return acc;
}, {});
};
const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
accessToken,
onApply,
discoveryRequest,
}) => {
// When the parent drives discovery, ``manualUrl`` is unused — the URL
// comes from ``discoveryRequest.url`` directly. When the parent hasn't
// supplied a plan, the admin types into this field manually.
const [manualUrl, setManualUrl] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [card, setCard] = useState<DiscoveredAgentCard | null>(null);
const isParentDriven = discoveryRequest !== undefined;
const effectiveUrl = isParentDriven ? discoveryRequest!.url : manualUrl;
const [editedName, setEditedName] = useState<string>("");
const [editedDescription, setEditedDescription] = useState<string>("");
const [selectedSkillIds, setSelectedSkillIds] = useState<Set<string>>(new Set());
const [selectedCapabilities, setSelectedCapabilities] = useState<
Record<string, boolean>
>({});
const resetSelections = (fresh: DiscoveredAgentCard) => {
setEditedName(fresh.name ?? "");
setEditedDescription(fresh.description ?? "");
const skills = fresh.skills ?? [];
setSelectedSkillIds(new Set(skills.map((s, i) => skillId(s, i))));
setSelectedCapabilities(filterCapabilitiesForUI(fresh.capabilities));
};
const handleDiscover = async () => {
if (!accessToken) {
setError("No access token available");
return;
}
const trimmed = effectiveUrl.trim();
if (!trimmed) {
setError(
isParentDriven
? "Fill in the agent's connection details above first"
: "Enter the agent's base URL first",
);
return;
}
setLoading(true);
setError(null);
try {
const response = await discoverAgentCardCall(
accessToken,
trimmed,
isParentDriven
? {
discovery_mode: discoveryRequest!.discovery_mode,
params: discoveryRequest!.params,
}
: undefined,
);
setCard(response.agent_card);
resetSelections(response.agent_card);
} catch (e: any) {
setError(e?.message ? String(e.message) : "Failed to discover agent card");
setCard(null);
} finally {
setLoading(false);
}
};
const toggleSkill = (id: string, checked: boolean) => {
setSelectedSkillIds((prev) => {
const next = new Set(prev);
if (checked) next.add(id);
else next.delete(id);
return next;
});
};
const handleApply = () => {
if (!card) return;
const skills = card.skills ?? [];
const filteredSkills = skills.filter((s, i) =>
selectedSkillIds.has(skillId(s, i)),
);
const selected_card: DiscoveredAgentCard = {
...card,
name: editedName,
description: editedDescription,
skills: filteredSkills,
capabilities: { ...selectedCapabilities },
};
onApply({
raw_card: card,
selected_card,
upstream_url: effectiveUrl.trim(),
});
};
const skillCount = card?.skills?.length ?? 0;
const selectedSkillCount = selectedSkillIds.size;
return (
<div className="border border-gray-200 rounded-lg p-4 bg-gray-50 mb-4">
<div className="flex items-center gap-2 mb-2">
<LinkOutlined className="text-indigo-600" />
<Text strong>Discover from agent URL</Text>
<Tooltip title="LiteLLM will fetch /.well-known/agent-card.json from this URL and let you pick which skills and capabilities to expose through the proxy.">
<InfoCircleOutlined className="text-gray-400" />
</Tooltip>
</div>
{isParentDriven ? (
<>
<Paragraph className="text-xs text-gray-500 mb-2">
Using the connection details you entered above. We&apos;ll fetch:
</Paragraph>
<div className="bg-white border border-gray-200 rounded px-3 py-2 mb-3 font-mono text-xs text-gray-700 break-all">
{discoveryRequest!.display_url || effectiveUrl || (
<span className="text-gray-400 italic">
Fill in the fields above first
</span>
)}
</div>
<div className="flex justify-end">
<Button
type="primary"
icon={card ? <ReloadOutlined /> : <SearchOutlined />}
loading={loading}
onClick={handleDiscover}
disabled={!effectiveUrl.trim()}
>
{card ? "Re-discover" : "Discover"}
</Button>
</div>
</>
) : (
<>
<Paragraph className="text-xs text-gray-500 mb-3">
Paste the upstream agent&apos;s base URL. We&apos;ll try{" "}
<code>/.well-known/agent-card.json</code>,{" "}
<code>/.well-known/agent.json</code>, and <code>/agent.json</code>{" "}
in order.
</Paragraph>
<Space.Compact style={{ width: "100%" }}>
<Input
placeholder="https://upstream-agent.example.com"
value={manualUrl}
onChange={(e) => setManualUrl(e.target.value)}
onPressEnter={handleDiscover}
allowClear
disabled={loading}
/>
<Button
type="primary"
icon={card ? <ReloadOutlined /> : <SearchOutlined />}
loading={loading}
onClick={handleDiscover}
>
{card ? "Re-discover" : "Discover"}
</Button>
</Space.Compact>
</>
)}
{error && (
<Alert
className="mt-3"
type="error"
message="Discovery failed"
description={error}
showIcon
closable
onClose={() => setError(null)}
/>
)}
{loading && !card && (
<div className="flex items-center justify-center py-8">
<Spin />
</div>
)}
{card && (
<div className="mt-4 bg-white border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<Space>
<CheckCircleTwoTone twoToneColor="#52c41a" />
<Text strong>Upstream card loaded</Text>
{card.version && <Tag color="blue">v{card.version}</Tag>}
{card.provider?.organization && (
<Tag color="purple">{card.provider.organization}</Tag>
)}
</Space>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
<div>
<label className="text-xs font-medium text-gray-600 block mb-1">
Name (shown to API clients)
</label>
<Input
value={editedName}
onChange={(e) => setEditedName(e.target.value)}
placeholder="Agent name"
/>
</div>
<div>
<label className="text-xs font-medium text-gray-600 block mb-1">
Description
</label>
<Input.TextArea
value={editedDescription}
onChange={(e) => setEditedDescription(e.target.value)}
rows={2}
placeholder="What this agent does"
/>
</div>
</div>
<Collapse
defaultActiveKey={["skills", "capabilities"]}
ghost
className="bg-transparent"
>
<Panel
key="skills"
header={
<Space>
<Text strong>Skills</Text>
<Tag>
{selectedSkillCount} / {skillCount} selected
</Tag>
</Space>
}
>
{skillCount === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="Upstream card has no skills"
/>
) : (
<div className="space-y-2">
{(card.skills ?? []).map((skill, idx) => {
const id = skillId(skill, idx);
const checked = selectedSkillIds.has(id);
return (
<label
key={id}
className={`flex items-start gap-3 p-3 border rounded cursor-pointer transition-colors ${
checked
? "border-indigo-300 bg-indigo-50"
: "border-gray-200 bg-white hover:border-gray-300"
}`}
>
<Checkbox
checked={checked}
onChange={(e) => toggleSkill(id, e.target.checked)}
/>
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap">
<Text strong>{skill.name || id}</Text>
{skill.id && (
<Tag style={{ marginLeft: 0 }}>{skill.id}</Tag>
)}
{(skill.tags ?? []).map((t: string) => (
<Tag key={t} color="geekblue">
{t}
</Tag>
))}
</div>
{skill.description && (
<Paragraph
className="text-xs text-gray-500 mt-1 mb-0"
ellipsis={{ rows: 2, expandable: true, symbol: "more" }}
>
{skill.description}
</Paragraph>
)}
</div>
</label>
);
})}
</div>
)}
</Panel>
<Panel
key="capabilities"
header={
<Space>
<Text strong>Capabilities</Text>
<Tooltip title="Only capabilities LiteLLM can faithfully proxy today are listed. Others (push notifications, extensions) are coming soon.">
<InfoCircleOutlined className="text-gray-400" />
</Tooltip>
</Space>
}
>
<div className="space-y-2">
{ALLOWED_CAPABILITY_KEYS.map((key) => {
const upstreamHas = Boolean(card.capabilities?.[key]);
return (
<div
key={key}
className="flex items-center justify-between p-2 border border-gray-200 rounded bg-white"
>
<div>
<Text strong className="capitalize">
{key}
</Text>
{!upstreamHas && (
<Tag className="ml-2" color="default">
not advertised upstream
</Tag>
)}
</div>
<Switch
checked={Boolean(selectedCapabilities[key])}
onChange={(checked) =>
setSelectedCapabilities((prev) => ({
...prev,
[key]: checked,
}))
}
/>
</div>
);
})}
</div>
</Panel>
</Collapse>
<div className="flex justify-end mt-4">
<Button type="primary" onClick={handleApply}>
Use these selections
</Button>
</div>
</div>
)}
</div>
);
};
export default AgentCardDiscovery;

View file

@ -6299,6 +6299,81 @@ export const createAgentCall = async (accessToken: string, agentData: any) => {
}
};
export interface DiscoveredAgentCard {
protocolVersion?: string;
name?: string;
description?: string;
version?: string;
url?: string;
iconUrl?: string;
documentationUrl?: string;
defaultInputModes?: string[];
defaultOutputModes?: string[];
capabilities?: Record<string, any>;
skills?: Array<{
id?: string;
name?: string;
description?: string;
tags?: string[];
examples?: string[];
[key: string]: any;
}>;
provider?: { organization?: string; url?: string };
[key: string]: any;
}
export interface DiscoverAgentCardResponse {
url: string;
agent_card: DiscoveredAgentCard;
}
/**
* How the backend should locate the upstream agent card.
*
* - ``well_known_fallback`` (default): pure A2A — try the three standard
* well-known paths under the base URL.
* - ``langgraph_platform``: LangGraph Platform — hits the canonical
* well-known path with an ``assistant_id`` query parameter, because
* LangGraph mounts one shared card endpoint per deployment.
*/
export type DiscoveryMode = "well_known_fallback" | "langgraph_platform";
export interface DiscoverAgentCardOptions {
discovery_mode?: DiscoveryMode;
/** Mode-specific params. ``langgraph_platform`` requires ``assistant_id``. */
params?: Record<string, any>;
headers?: Record<string, string>;
}
export const discoverAgentCardCall = async (
accessToken: string,
url: string,
options?: DiscoverAgentCardOptions,
): Promise<DiscoverAgentCardResponse> => {
const endpoint = proxyBaseUrl ? `${proxyBaseUrl}/v1/a2a/discover` : `/v1/a2a/discover`;
const body: Record<string, any> = { url };
if (options?.discovery_mode) body.discovery_mode = options.discovery_mode;
if (options?.params) body.params = options.params;
if (options?.headers) body.headers = options.headers;
const response = await fetch(endpoint, {
method: "POST",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errorData = await response.text();
handleError(errorData);
throw new Error(errorData);
}
return (await response.json()) as DiscoverAgentCardResponse;
};
export const createGuardrailCall = async (accessToken: string, guardrailData: any) => {
try {
const url = proxyBaseUrl ? `${proxyBaseUrl}/guardrails` : `/guardrails`;