From 79ffe24c6fbf7fe0f912c19cae6077c60c9add54 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 26 May 2026 14:52:14 +0530 Subject: [PATCH] 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= (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. --- .../litellm_completion_bridge/handler.py | 10 + .../transformation.py | 95 +++- .../providers/litellm_completion/handler.py | 10 + .../litellm_completion/transformation.py | 14 +- .../pydantic_ai_agents/transformation.py | 3 +- litellm/a2a_protocol/utils.py | 4 + litellm/llms/langgraph/chat/transformation.py | 16 +- litellm/proxy/_lazy_features.py | 5 + litellm/proxy/a2a/__init__.py | 29 ++ litellm/proxy/a2a/agent_card.py | 160 +++++++ litellm/proxy/a2a/discovery.py | 145 ++++++ litellm/proxy/a2a/endpoints.py | 120 +++++ .../proxy/agent_endpoints/agent_registry.py | 15 +- litellm/proxy/agent_endpoints/endpoints.py | 71 ++- .../test_a2a_completion_bridge.py | 4 +- .../test_completion_bridge_streaming.py | 48 ++ tests/test_litellm/proxy/a2a/__init__.py | 0 .../test_litellm/proxy/a2a/test_agent_card.py | 163 +++++++ .../test_litellm/proxy/a2a/test_discovery.py | 271 +++++++++++ .../proxy/agent_endpoints/test_endpoints.py | 28 ++ .../src/components/agents/add_agent_form.tsx | 178 ++++++- .../agents/agent_card_discovery.test.tsx | 245 ++++++++++ .../agents/agent_card_discovery.tsx | 442 ++++++++++++++++++ .../src/components/networking.tsx | 75 +++ 24 files changed, 2112 insertions(+), 39 deletions(-) create mode 100644 litellm/proxy/a2a/__init__.py create mode 100644 litellm/proxy/a2a/agent_card.py create mode 100644 litellm/proxy/a2a/discovery.py create mode 100644 litellm/proxy/a2a/endpoints.py create mode 100644 tests/test_litellm/proxy/a2a/__init__.py create mode 100644 tests/test_litellm/proxy/a2a/test_agent_card.py create mode 100644 tests/test_litellm/proxy/a2a/test_discovery.py create mode 100644 ui/litellm-dashboard/src/components/agents/agent_card_discovery.test.tsx create mode 100644 ui/litellm-dashboard/src/components/agents/agent_card_discovery.tsx diff --git a/litellm/a2a_protocol/litellm_completion_bridge/handler.py b/litellm/a2a_protocol/litellm_completion_bridge/handler.py index 4e66fe4ba67..38ab94fe56c 100644 --- a/litellm/a2a_protocol/litellm_completion_bridge/handler.py +++ b/litellm/a2a_protocol/litellm_completion_bridge/handler.py @@ -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 diff --git a/litellm/a2a_protocol/litellm_completion_bridge/transformation.py b/litellm/a2a_protocol/litellm_completion_bridge/transformation.py index 8a03569f689..f5b8b40f95a 100644 --- a/litellm/a2a_protocol/litellm_completion_bridge/transformation.py +++ b/litellm/a2a_protocol/litellm_completion_bridge/transformation.py @@ -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, }, } diff --git a/litellm/a2a_protocol/providers/litellm_completion/handler.py b/litellm/a2a_protocol/providers/litellm_completion/handler.py index 730f8f6b36f..08d24014fdc 100644 --- a/litellm/a2a_protocol/providers/litellm_completion/handler.py +++ b/litellm/a2a_protocol/providers/litellm_completion/handler.py @@ -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 diff --git a/litellm/a2a_protocol/providers/litellm_completion/transformation.py b/litellm/a2a_protocol/providers/litellm_completion/transformation.py index 8a03569f689..a5f99d5bd7d 100644 --- a/litellm/a2a_protocol/providers/litellm_completion/transformation.py +++ b/litellm/a2a_protocol/providers/litellm_completion/transformation.py @@ -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, }, } diff --git a/litellm/a2a_protocol/providers/pydantic_ai_agents/transformation.py b/litellm/a2a_protocol/providers/pydantic_ai_agents/transformation.py index e73b17ac3c0..cf447e82393 100644 --- a/litellm/a2a_protocol/providers/pydantic_ai_agents/transformation.py +++ b/litellm/a2a_protocol/providers/pydantic_ai_agents/transformation.py @@ -326,7 +326,8 @@ class PydanticAITransformation: "jsonrpc": "2.0", "id": request_id, "result": { - "message": a2a_message, + "kind": "message", + **a2a_message, }, } diff --git a/litellm/a2a_protocol/utils.py b/litellm/a2a_protocol/utils.py index 1cdbde97755..467c4d1f9cc 100644 --- a/litellm/a2a_protocol/utils.py +++ b/litellm/a2a_protocol/utils.py @@ -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) diff --git a/litellm/llms/langgraph/chat/transformation.py b/litellm/llms/langgraph/chat/transformation.py index 00cc3a8f516..9808b665b54 100644 --- a/litellm/llms/langgraph/chat/transformation.py +++ b/litellm/llms/langgraph/chat/transformation.py @@ -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 diff --git a/litellm/proxy/_lazy_features.py b/litellm/proxy/_lazy_features.py index a70c5b3a920..4d9bf767830 100644 --- a/litellm/proxy/_lazy_features.py +++ b/litellm/proxy/_lazy_features.py @@ -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", diff --git a/litellm/proxy/a2a/__init__.py b/litellm/proxy/a2a/__init__.py new file mode 100644 index 00000000000..10fb308f9f6 --- /dev/null +++ b/litellm/proxy/a2a/__init__.py @@ -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", +] diff --git a/litellm/proxy/a2a/agent_card.py b/litellm/proxy/a2a/agent_card.py new file mode 100644 index 00000000000..f4a6862997c --- /dev/null +++ b/litellm/proxy/a2a/agent_card.py @@ -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/``. + 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} diff --git a/litellm/proxy/a2a/discovery.py b/litellm/proxy/a2a/discovery.py new file mode 100644 index 00000000000..9a7d6e711f5 --- /dev/null +++ b/litellm/proxy/a2a/discovery.py @@ -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}" + ) diff --git a/litellm/proxy/a2a/endpoints.py b/litellm/proxy/a2a/endpoints.py new file mode 100644 index 00000000000..9723dc6ee6c --- /dev/null +++ b/litellm/proxy/a2a/endpoints.py @@ -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=`` 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': }``. ``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 " \\ + -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", + ) diff --git a/litellm/proxy/agent_endpoints/agent_registry.py b/litellm/proxy/agent_endpoints/agent_registry.py index e69d16e0eac..13a2dd9f040 100644 --- a/litellm/proxy/agent_endpoints/agent_registry.py +++ b/litellm/proxy/agent_endpoints/agent_registry.py @@ -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: diff --git a/litellm/proxy/agent_endpoints/endpoints.py b/litellm/proxy/agent_endpoints/endpoints.py index 374c123c332..eb88d56f074 100644 --- a/litellm/proxy/agent_endpoints/endpoints.py +++ b/litellm/proxy/agent_endpoints/endpoints.py @@ -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, ) diff --git a/tests/agent_tests/local_only_agent_tests/test_a2a_completion_bridge.py b/tests/agent_tests/local_only_agent_tests/test_a2a_completion_bridge.py index 95d76ba5804..d3d582f29e4 100644 --- a/tests/agent_tests/local_only_agent_tests/test_a2a_completion_bridge.py +++ b/tests/agent_tests/local_only_agent_tests/test_a2a_completion_bridge.py @@ -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 diff --git a/tests/test_litellm/a2a_protocol/test_completion_bridge_streaming.py b/tests/test_litellm/a2a_protocol/test_completion_bridge_streaming.py index 39c303f275d..e83aad92039 100644 --- a/tests/test_litellm/a2a_protocol/test_completion_bridge_streaming.py +++ b/tests/test_litellm/a2a_protocol/test_completion_bridge_streaming.py @@ -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 ( diff --git a/tests/test_litellm/proxy/a2a/__init__.py b/tests/test_litellm/proxy/a2a/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/proxy/a2a/test_agent_card.py b/tests/test_litellm/proxy/a2a/test_agent_card.py new file mode 100644 index 00000000000..4c9901ccb55 --- /dev/null +++ b/tests/test_litellm/proxy/a2a/test_agent_card.py @@ -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 diff --git a/tests/test_litellm/proxy/a2a/test_discovery.py b/tests/test_litellm/proxy/a2a/test_discovery.py new file mode 100644 index 00000000000..cd27309e481 --- /dev/null +++ b/tests/test_litellm/proxy/a2a/test_discovery.py @@ -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 diff --git a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py index 75928b55a97..e27168181d1 100644 --- a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py @@ -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", diff --git a/ui/litellm-dashboard/src/components/agents/add_agent_form.tsx b/ui/litellm-dashboard/src/components/agents/add_agent_form.tsx index 046b28640c3..a9d2f6f5b0d 100644 --- a/ui/litellm-dashboard/src/components/agents/add_agent_form.tsx +++ b/ui/litellm-dashboard/src/components/agents/add_agent_form.tsx @@ -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 = ({ const [maxIterations, setMaxIterations] = useState(null); const [maxBudgetPerSession, setMaxBudgetPerSession] = useState(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(null); + // Fetch agent type metadata on mount useEffect(() => { const fetchMetadata = async () => { @@ -157,6 +168,68 @@ const AddAgentForm: React.FC = ({ (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=`` 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 = ({ } 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 = ({ setRequireTraceIdOutbound(false); setMaxIterations(null); setMaxBudgetPerSession(null); + setAppliedDiscoveredSelection(null); onClose(); }; @@ -568,6 +672,63 @@ const AddAgentForm: React.FC = ({ 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 = { + 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 = ({ ) : 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 && ( +
+ +
+ )} diff --git a/ui/litellm-dashboard/src/components/agents/agent_card_discovery.test.tsx b/ui/litellm-dashboard/src/components/agents/agent_card_discovery.test.tsx new file mode 100644 index 00000000000..811f19a20a7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/agents/agent_card_discovery.test.tsx @@ -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("../networking"); + return { + ...actual, + discoverAgentCardCall: vi.fn(), + }; +}); + +import { discoverAgentCardCall } from "../networking"; + +const mockDiscover = discoverAgentCardCall as unknown as ReturnType; + +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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + // 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/agents/agent_card_discovery.tsx b/ui/litellm-dashboard/src/components/agents/agent_card_discovery.tsx new file mode 100644 index 00000000000..b21f2999bb6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/agents/agent_card_discovery.tsx @@ -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; + /** 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 | undefined, +): Record => { + if (!capabilities) return {}; + return ALLOWED_CAPABILITY_KEYS.reduce>((acc, key) => { + if (key in capabilities) acc[key] = Boolean(capabilities[key]); + return acc; + }, {}); +}; + +const AgentCardDiscovery: React.FC = ({ + 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(null); + const [card, setCard] = useState(null); + + const isParentDriven = discoveryRequest !== undefined; + const effectiveUrl = isParentDriven ? discoveryRequest!.url : manualUrl; + + const [editedName, setEditedName] = useState(""); + const [editedDescription, setEditedDescription] = useState(""); + const [selectedSkillIds, setSelectedSkillIds] = useState>(new Set()); + const [selectedCapabilities, setSelectedCapabilities] = useState< + Record + >({}); + + 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 ( +
+
+ + Discover from agent URL + + + +
+ {isParentDriven ? ( + <> + + Using the connection details you entered above. We'll fetch: + +
+ {discoveryRequest!.display_url || effectiveUrl || ( + + Fill in the fields above first + + )} +
+
+ +
+ + ) : ( + <> + + Paste the upstream agent's base URL. We'll try{" "} + /.well-known/agent-card.json,{" "} + /.well-known/agent.json, and /agent.json{" "} + in order. + + + + setManualUrl(e.target.value)} + onPressEnter={handleDiscover} + allowClear + disabled={loading} + /> + + + + )} + + {error && ( + setError(null)} + /> + )} + + {loading && !card && ( +
+ +
+ )} + + {card && ( +
+
+ + + Upstream card loaded + {card.version && v{card.version}} + {card.provider?.organization && ( + {card.provider.organization} + )} + +
+ +
+
+ + setEditedName(e.target.value)} + placeholder="Agent name" + /> +
+
+ + setEditedDescription(e.target.value)} + rows={2} + placeholder="What this agent does" + /> +
+
+ + + + Skills + + {selectedSkillCount} / {skillCount} selected + + + } + > + {skillCount === 0 ? ( + + ) : ( +
+ {(card.skills ?? []).map((skill, idx) => { + const id = skillId(skill, idx); + const checked = selectedSkillIds.has(id); + return ( + + ); + })} +
+ )} +
+ + + Capabilities + + + + + } + > +
+ {ALLOWED_CAPABILITY_KEYS.map((key) => { + const upstreamHas = Boolean(card.capabilities?.[key]); + return ( +
+
+ + {key} + + {!upstreamHas && ( + + not advertised upstream + + )} +
+ + setSelectedCapabilities((prev) => ({ + ...prev, + [key]: checked, + })) + } + /> +
+ ); + })} +
+
+
+ +
+ +
+
+ )} +
+ ); +}; + +export default AgentCardDiscovery; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 57e7d51123e..4e22c6e20a8 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -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; + 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; + headers?: Record; +} + +export const discoverAgentCardCall = async ( + accessToken: string, + url: string, + options?: DiscoverAgentCardOptions, +): Promise => { + const endpoint = proxyBaseUrl ? `${proxyBaseUrl}/v1/a2a/discover` : `/v1/a2a/discover`; + const body: Record = { 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`;