mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-30 01:52:18 +00:00
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:
parent
96a2e8b16d
commit
79ffe24c6f
24 changed files with 2112 additions and 39 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -326,7 +326,8 @@ class PydanticAITransformation:
|
|||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"message": a2a_message,
|
||||
"kind": "message",
|
||||
**a2a_message,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
29
litellm/proxy/a2a/__init__.py
Normal file
29
litellm/proxy/a2a/__init__.py
Normal 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",
|
||||
]
|
||||
160
litellm/proxy/a2a/agent_card.py
Normal file
160
litellm/proxy/a2a/agent_card.py
Normal 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}
|
||||
145
litellm/proxy/a2a/discovery.py
Normal file
145
litellm/proxy/a2a/discovery.py
Normal 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}"
|
||||
)
|
||||
120
litellm/proxy/a2a/endpoints.py
Normal file
120
litellm/proxy/a2a/endpoints.py
Normal 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",
|
||||
)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
0
tests/test_litellm/proxy/a2a/__init__.py
Normal file
0
tests/test_litellm/proxy/a2a/__init__.py
Normal file
163
tests/test_litellm/proxy/a2a/test_agent_card.py
Normal file
163
tests/test_litellm/proxy/a2a/test_agent_card.py
Normal 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
|
||||
271
tests/test_litellm/proxy/a2a/test_discovery.py
Normal file
271
tests/test_litellm/proxy/a2a/test_discovery.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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'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's base URL. We'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;
|
||||
|
|
@ -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`;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue