fa45d8aa5f
- health_checklist.json: 192.168.1.122→node122
- ocr_client.py: docstring IP→node122
- docs/market-data-requirements.md: IP→node122
- 所有API调用通过ProxyHandler({})绕过系统代理
Privoxy对node122:18003返回500,直连正常
182 lines
6.9 KiB
Python
182 lines
6.9 KiB
Python
"""
|
|
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.
|
|
#
|
|
# ``additionalInterfaces`` is deliberately excluded: it advertises alternate
|
|
# upstream URLs (HTTP/JSONRPC/gRPC backends) that, if persisted and served,
|
|
# would let authenticated agent callers reach the backend directly and bypass
|
|
# the proxy's auth/budget/logging. The proxy publishes its own entrypoint via
|
|
# ``supportedInterfaces`` instead.
|
|
_ALLOWED_TOP_LEVEL_KEYS = {
|
|
"protocolVersion",
|
|
"name",
|
|
"description",
|
|
"version",
|
|
"capabilities",
|
|
"defaultInputModes",
|
|
"defaultOutputModes",
|
|
"skills",
|
|
"preferredTransport",
|
|
"supportedInterfaces",
|
|
"iconUrl",
|
|
"provider",
|
|
"documentationUrl",
|
|
"securitySchemes",
|
|
"security",
|
|
"supportsAuthenticatedExtendedCard",
|
|
"signatures",
|
|
# ``url`` is retained on the stored card because the runtime A2A invocation
|
|
# path (``a2a_endpoints.py``) reads ``agent.agent_card_params['url']`` to
|
|
# locate the upstream backend. The public ``/.well-known/agent-card.json``
|
|
# endpoint rewrites this field to the proxy URL before serving it to
|
|
# clients, so retaining it here does not leak the upstream to A2A callers.
|
|
"url",
|
|
}
|
|
|
|
_DEFAULT_SKILLS: List[Dict[str, Any]] = [
|
|
{
|
|
"id": "chat",
|
|
"name": "Chat",
|
|
"description": "Conversational interaction with the agent.",
|
|
"tags": ["chat"],
|
|
}
|
|
]
|
|
|
|
_DEFAULT_MODES: List[str] = ["text"]
|
|
|
|
# Fallback ``version`` when the upstream card omits the field. The A2A v1.0
|
|
# schema requires ``version`` on every card, so without this default the
|
|
# merged card would fail validation on clients that ``model_validate`` it.
|
|
_DEFAULT_AGENT_VERSION = "1.0.0"
|
|
|
|
|
|
def _filter_capabilities(upstream_capabilities: Any) -> Dict[str, Any]:
|
|
"""Return a capabilities dict containing only allowlisted, truthy keys."""
|
|
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 {}
|
|
|
|
# Keep the upstream ``url`` on the stored card: the runtime A2A
|
|
# invocation path reads it from ``agent_card_params`` to know where to
|
|
# proxy requests. The public well-known endpoint rewrites this field
|
|
# to the proxy URL before exposing the card to clients.
|
|
|
|
base["protocolVersion"] = LITELLM_A2A_PROTOCOL_VERSION
|
|
|
|
if name:
|
|
base["name"] = name
|
|
if description:
|
|
base["description"] = description
|
|
|
|
if not base.get("version"):
|
|
base["version"] = _DEFAULT_AGENT_VERSION
|
|
|
|
base["capabilities"] = _filter_capabilities(base.get("capabilities"))
|
|
|
|
if not base.get("skills"):
|
|
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)
|
|
# Use the standard A2A/OpenAPI ``security`` field for requirements, not
|
|
# the non-standard ``securityRequirements`` alias. The upstream's own
|
|
# ``security`` selector is overwritten here because the proxy enforces its
|
|
# own scheme regardless of what upstream required.
|
|
base["security"] = deepcopy(LITELLM_SECURITY_REQUIREMENTS)
|
|
|
|
return {key: value for key, value in base.items() if key in _ALLOWED_TOP_LEVEL_KEYS}
|