Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/a2a/agent_card.py: 93%

55 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 12:01 +0000

1""" 

2Pure logic for merging an upstream A2A agent card with LiteLLM-specific overrides. 

3 

4The merge produces the card that LiteLLM exposes to A2A clients at 

5``/a2a/{agent_id}/.well-known/agent-card.json``. The upstream card is taken as 

6the base; specific fields are replaced so all traffic flows through the proxy 

7and uses LiteLLM auth. 

8""" 

9 

10import re 

11from collections.abc import Mapping 

12from copy import deepcopy 

13from typing import Final, Literal 

14 

15SupportedA2AVersion = Literal["0.3", "1.0"] 

16 

17# Protocol versions LiteLLM can serve to A2A clients. The admin pins one per agent; 

18# responses are normalized to it regardless of the upstream agent's own version. 

19SUPPORTED_A2A_PROTOCOL_VERSIONS: Final[tuple[SupportedA2AVersion, ...]] = ("0.3", "1.0") 

20 

21# Default served version when the agent card does not pin one. 

22LITELLM_A2A_PROTOCOL_VERSION: Final = "1.0" 

23 

24 

25_PROTOCOL_VERSION_PATTERN: Final = re.compile( 

26 r"^(\d+\.\d+)(?:\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)?$" 

27) 

28 

29 

30def normalize_protocol_version(version: object) -> SupportedA2AVersion | None: 

31 """Map a raw ``protocolVersion`` value to the supported canonical major.minor version. 

32 

33 Accepts the bare major.minor convention of the 1.0 spec (``"0.3"``, ``"1.0"``) and the 

34 full semver forms older SDKs emit (``"0.3.0"``, ``"1.0.1"``, including prerelease and 

35 build suffixes like ``"0.3.0-rc1"``). Malformed strings, versions outside the 

36 supported set, and non-strings yield ``None``. 

37 """ 

38 if not isinstance(version, str): 

39 return None 

40 match: Final = _PROTOCOL_VERSION_PATTERN.match(version) 

41 if match is None: 41 ↛ 43line 41 didn't jump to line 43 because the condition on line 41 was always true

42 return None 

43 major_minor: Final = match.group(1) 

44 return next((supported for supported in SUPPORTED_A2A_PROTOCOL_VERSIONS if supported == major_minor), None) 

45 

46 

47def resolve_served_protocol_version(card: Mapping[str, object] | None) -> str: 

48 """Return the validated protocol version an agent card pins, else the default.""" 

49 normalized: Final = normalize_protocol_version(card.get("protocolVersion") if card else None) 

50 return normalized if normalized is not None else LITELLM_A2A_PROTOCOL_VERSION 

51 

52 

53# Security scheme exposed by the LiteLLM-fronted agent card. Always replaces 

54# whatever upstream advertised — the client must authenticate to the proxy, 

55# not the upstream agent. 

56LITELLM_SECURITY_SCHEMES: Final[dict[str, dict[str, str]]] = { 

57 "LiteLLMKey": { 

58 "type": "http", 

59 "scheme": "bearer", 

60 "description": "LiteLLM virtual key", 

61 }, 

62} 

63 

64LITELLM_SECURITY_REQUIREMENTS: Final[list[dict[str, list[str]]]] = [{"LiteLLMKey": []}] 

65 

66# Capabilities LiteLLM can faithfully proxy today. Anything not in this set is 

67# dropped during merge so we don't advertise behavior the proxy can't deliver. 

68# 

69# TODO: re-enable ``streaming`` once the A2A streaming endpoint at 

70# ``POST /a2a/{agent_id}/message/stream`` is exercised end-to-end with 

71# cost tracking + guardrails. It's wired in ``a2a_endpoints.py`` but not 

72# yet covered by tests, so we keep it gated on the upstream advertising it. 

73# TODO: ``pushNotifications`` — proxy has no webhook plumbing yet. 

74# TODO: ``extendedAgentCard`` — no separate authenticated-extended-card 

75# endpoint exposed by the proxy. 

76# TODO: ``extensions`` — protocol extensions aren't validated/forwarded yet. 

77_ALLOWED_CAPABILITY_KEYS: Final = {"streaming"} 

78 

79# v1.0 AgentCard top-level fields. Anything else is stripped from the merged 

80# card as a defense against upstream drift. ``supportedInterfaces`` is kept 

81# verbatim per product spec even though it is not in the v1.0 schema — clients 

82# that expect it will find it; clients that don't will ignore it. 

83# 

84# ``additionalInterfaces`` is deliberately excluded: it advertises alternate 

85# upstream URLs (HTTP/JSONRPC/gRPC backends) that, if persisted and served, 

86# would let authenticated agent callers reach the backend directly and bypass 

87# the proxy's auth/budget/logging. The proxy publishes its own entrypoint via 

88# ``supportedInterfaces`` instead. 

89_ALLOWED_TOP_LEVEL_KEYS: Final = { 

90 "protocolVersion", 

91 "name", 

92 "description", 

93 "version", 

94 "capabilities", 

95 "defaultInputModes", 

96 "defaultOutputModes", 

97 "skills", 

98 "preferredTransport", 

99 "supportedInterfaces", 

100 "iconUrl", 

101 "provider", 

102 "documentationUrl", 

103 "securitySchemes", 

104 "security", 

105 "supportsAuthenticatedExtendedCard", 

106 "signatures", 

107 # ``url`` is retained on the stored card because the runtime A2A invocation 

108 # path (``a2a_endpoints.py``) reads ``agent.agent_card_params['url']`` to 

109 # locate the upstream backend. The public ``/.well-known/agent-card.json`` 

110 # endpoint rewrites this field to the proxy URL before serving it to 

111 # clients, so retaining it here does not leak the upstream to A2A callers. 

112 "url", 

113} 

114 

115_DEFAULT_SKILLS: Final[list[dict[str, str | list[str]]]] = [ 

116 { 

117 "id": "chat", 

118 "name": "Chat", 

119 "description": "Conversational interaction with the agent.", 

120 "tags": ["chat"], 

121 } 

122] 

123 

124_DEFAULT_MODES: Final[list[str]] = ["text"] 

125 

126# Fallback ``version`` when the upstream card omits the field. The A2A v1.0 

127# schema requires ``version`` on every card, so without this default the 

128# merged card would fail validation on clients that ``model_validate`` it. 

129_DEFAULT_AGENT_VERSION: Final = "1.0.0" 

130 

131 

132def _filter_capabilities(upstream_capabilities: object) -> dict[str, object]: 

133 """Return a capabilities dict containing only allowlisted, truthy keys.""" 

134 if not isinstance(upstream_capabilities, dict): 

135 return {} 

136 return { 

137 key: value for key, value in upstream_capabilities.items() if key in _ALLOWED_CAPABILITY_KEYS and bool(value) 

138 } 

139 

140 

141def _default_litellm_provider(proxy_base_url: str) -> dict[str, str]: 

142 return {"organization": "LiteLLM Proxy", "url": proxy_base_url} 

143 

144 

145def merge_agent_card( 

146 upstream_card: Mapping[str, object] | None, 

147 *, 

148 proxy_url: str, 

149 proxy_base_url: str, 

150 name: str | None = None, 

151 description: str | None = None, 

152) -> dict[str, object]: 

153 """ 

154 Build the LiteLLM-fronted agent card. 

155 

156 Args: 

157 upstream_card: Card returned by the upstream agent's well-known endpoint. 

158 May be ``None``/empty when the upstream did not expose one. 

159 proxy_url: Full URL clients should hit to invoke this agent through 

160 the proxy, e.g. ``https://proxy.example.com/a2a/<agent_id>``. 

161 proxy_base_url: Root URL of the LiteLLM proxy, used as a fallback when 

162 we synthesize a provider record. 

163 name: User-supplied agent name from the LiteLLM UI. Takes precedence 

164 over the upstream card's ``name``. 

165 description: User-supplied description from the LiteLLM UI. Takes 

166 precedence over the upstream card's ``description``. 

167 

168 Returns: 

169 A dict suitable for serving as the proxy's agent card. Only keys in 

170 the v1.0 AgentCard schema (plus ``supportedInterfaces``) are emitted. 

171 """ 

172 base: Final[dict[str, object]] = deepcopy(dict(upstream_card)) if upstream_card else {} 

173 

174 # Keep the upstream ``url`` on the stored card: the runtime A2A 

175 # invocation path reads it from ``agent_card_params`` to know where to 

176 # proxy requests. The public well-known endpoint rewrites this field 

177 # to the proxy URL before exposing the card to clients. 

178 

179 served_version: Final = resolve_served_protocol_version(upstream_card) 

180 base["protocolVersion"] = served_version 

181 

182 if name: 

183 base["name"] = name 

184 if description: 184 ↛ 185line 184 didn't jump to line 185 because the condition on line 184 was never true

185 base["description"] = description 

186 

187 if not base.get("version"): 

188 base["version"] = _DEFAULT_AGENT_VERSION 

189 

190 base["capabilities"] = _filter_capabilities(base.get("capabilities")) 

191 

192 if not base.get("skills"): 

193 base["skills"] = deepcopy(_DEFAULT_SKILLS) 

194 if not base.get("defaultInputModes"): 

195 base["defaultInputModes"] = list(_DEFAULT_MODES) 

196 if not base.get("defaultOutputModes"): 

197 base["defaultOutputModes"] = list(_DEFAULT_MODES) 

198 

199 if not base.get("provider"): 

200 base["provider"] = _default_litellm_provider(proxy_base_url) 

201 

202 base["supportedInterfaces"] = [ 

203 { 

204 "url": proxy_url, 

205 "protocolBinding": "JSONRPC", 

206 "protocolVersion": served_version, 

207 } 

208 ] 

209 

210 base["securitySchemes"] = deepcopy(LITELLM_SECURITY_SCHEMES) 

211 # Use the standard A2A/OpenAPI ``security`` field for requirements, not 

212 # the non-standard ``securityRequirements`` alias. The upstream's own 

213 # ``security`` selector is overwritten here because the proxy enforces its 

214 # own scheme regardless of what upstream required. 

215 base["security"] = deepcopy(LITELLM_SECURITY_REQUIREMENTS) 

216 

217 return {key: value for key, value in base.items() if key in _ALLOWED_TOP_LEVEL_KEYS}