Skip to content

Commit a65357f

Browse files
Fix three bugs in A2A discovery flow
1. UI: Stabilize discoveryRequest deps to avoid redundant /v1/a2a/discover API calls. The parent rebuilds the discoveryRequest object on every form keystroke, so depend on primitive proxies (discovery_mode + serialized params) rather than the object identity. Read the actual object via a ref inside handleDiscover. 2. Backend: Route the well-known card fetch through async_safe_get so the admin /v1/a2a/discover endpoint can't be used to probe private/loopback addresses or cloud metadata endpoints. SSRFError is a separate handled case so it surfaces a clear AgentCardDiscoveryError. 3. Streaming: Make openai_chunk_to_a2a_chunk emit the same flat result shape as the non-streaming response (kind/role/parts/messageId at the result level), with envelope-level 'final' added. Matches the existing create_artifact_update_event pattern and lets consumers read a uniform result shape across streaming and non-streaming. Co-authored-by: Yassin Kortam <[email protected]>
1 parent 42b4449 commit a65357f

4 files changed

Lines changed: 62 additions & 24 deletions

File tree

litellm/a2a_protocol/litellm_completion_bridge/transformation.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -335,19 +335,22 @@ def openai_chunk_to_a2a_chunk(
335335
if not content and not is_final:
336336
return None
337337

338-
# Build A2A streaming chunk (legacy format). ``final`` is an
339-
# envelope-level streaming property per the A2A spec and must live
340-
# alongside ``message`` in ``result``, not inside the message object.
338+
# Build A2A streaming chunk. Mirrors the non-streaming response
339+
# shape (``result`` is the message itself, with ``kind: "message"``
340+
# as the result-level event discriminator — matching how
341+
# ``create_artifact_update_event`` uses ``kind: "artifact-update"``
342+
# at the result level). ``final`` is an envelope-level streaming
343+
# property per the A2A spec and is appended alongside the message
344+
# fields so consumers can read a uniform ``result`` shape across
345+
# streaming and non-streaming.
341346
a2a_chunk = {
342347
"jsonrpc": "2.0",
343348
"id": request_id,
344349
"result": {
345-
"message": {
346-
"kind": "message",
347-
"role": "agent",
348-
"parts": [{"kind": "text", "text": content}],
349-
"messageId": uuid4().hex,
350-
},
350+
"kind": "message",
351+
"role": "agent",
352+
"parts": [{"kind": "text", "text": content}],
353+
"messageId": uuid4().hex,
351354
"final": is_final,
352355
},
353356
}

litellm/proxy/a2a/discovery.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from urllib.parse import urlencode
2020

2121
from litellm._logging import verbose_proxy_logger
22+
from litellm.litellm_core_utils.url_utils import SSRFError, async_safe_get
2223
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
2324
from litellm.types.llms.custom_http import httpxSpecialProvider
2425

@@ -113,7 +114,18 @@ async def fetch_well_known_card(
113114
for path in paths:
114115
url = f"{normalized}{path}"
115116
try:
116-
response = await client.get(url, headers=headers)
117+
# ``async_safe_get`` validates the URL against the SSRF blocklist
118+
# (private/loopback IPs, cloud metadata endpoints, etc.) on every
119+
# redirect hop. Even though the discovery endpoint is admin-only,
120+
# we don't want a compromised admin key to be able to probe
121+
# internal infrastructure through this fetcher.
122+
response = await async_safe_get(client, url, headers=headers)
123+
except SSRFError as exc:
124+
last_error = f"{url}: {exc!s}"
125+
verbose_proxy_logger.debug(
126+
"A2A discovery blocked by SSRF guard for %s: %s", url, exc
127+
)
128+
continue
117129
except Exception as exc:
118130
last_error = f"{url}: {exc!s}"
119131
verbose_proxy_logger.debug("A2A discovery failed for %s: %s", url, exc)

tests/test_litellm/proxy/a2a/test_discovery.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from fastapi import FastAPI
77
from fastapi.testclient import TestClient
88

9+
import litellm
910
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
1011
from litellm.proxy.a2a.discovery import (
1112
AGENT_CARD_WELL_KNOWN_PATHS,
@@ -16,6 +17,17 @@
1617
from litellm.proxy.a2a.endpoints import router as a2a_router
1718
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
1819

20+
21+
@pytest.fixture(autouse=True)
22+
def _disable_url_validation_for_mocks(monkeypatch):
23+
"""The fetch tests use placeholder hostnames (``upstream.example``,
24+
``localhost:2024``) with mocked HTTP clients. ``async_safe_get`` would
25+
otherwise resolve those hostnames and either fail DNS or block on the
26+
SSRF guard. Disabling validation here lets the unit tests focus on
27+
fallback / parsing logic; SSRF behavior is covered in its own test."""
28+
monkeypatch.setattr(litellm, "user_url_validation", False)
29+
30+
1931
# ---------------------------------------------------------------------------
2032
# fetch_well_known_card
2133
# ---------------------------------------------------------------------------

ui/litellm-dashboard/src/components/agents/agent_card_discovery.tsx

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import React, { useCallback, useEffect, useRef, useState } from "react";
3+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
44
import {
55
Alert,
66
Button,
@@ -110,6 +110,20 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
110110
onApplyRef.current = onApply;
111111
const discoverRequestIdRef = useRef(0);
112112
const lastSyncedSelectionRef = useRef<string | null>(null);
113+
// Hold the latest ``discoveryRequest`` in a ref so ``handleDiscover`` can
114+
// read its ``discovery_mode``/``params`` without depending on the object
115+
// identity itself — the parent recreates the object on every form keystroke
116+
// even when the underlying values are unchanged. We use stable primitive
117+
// keys (``discoveryMode`` + ``discoveryParamsKey``) as the actual deps so
118+
// the callback / effect only re-run when content actually changes.
119+
const discoveryRequestRef = useRef(discoveryRequest);
120+
discoveryRequestRef.current = discoveryRequest;
121+
122+
const discoveryMode = discoveryRequest?.discovery_mode;
123+
const discoveryParamsKey = useMemo(
124+
() => JSON.stringify(discoveryRequest?.params ?? null),
125+
[discoveryRequest?.params],
126+
);
113127

114128
const handleDiscover = useCallback(async () => {
115129
if (!accessToken) {
@@ -129,17 +143,18 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
129143
return;
130144
}
131145

146+
const currentDiscoveryRequest = discoveryRequestRef.current;
132147
const requestId = ++discoverRequestIdRef.current;
133148
setLoading(true);
134149
setError(null);
135150
try {
136151
const response = await discoverAgentCardCall(
137152
accessToken,
138153
trimmed,
139-
isParentDriven
154+
isParentDriven && currentDiscoveryRequest
140155
? {
141-
discovery_mode: discoveryRequest!.discovery_mode,
142-
params: discoveryRequest!.params,
156+
discovery_mode: currentDiscoveryRequest.discovery_mode,
157+
params: currentDiscoveryRequest.params,
143158
}
144159
: undefined,
145160
);
@@ -158,7 +173,11 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
158173
setLoading(false);
159174
}
160175
}
161-
}, [accessToken, discoveryRequest, effectiveUrl, isParentDriven]);
176+
// ``discoveryMode`` / ``discoveryParamsKey`` are primitive proxies for
177+
// ``discoveryRequest`` content; the actual object is read via the ref
178+
// above so identity churn from the parent doesn't recreate this callback.
179+
// eslint-disable-next-line react-hooks/exhaustive-deps
180+
}, [accessToken, effectiveUrl, isParentDriven, discoveryMode, discoveryParamsKey]);
162181

163182
// Auto-discover when the URL (or parent plan) becomes available.
164183
useEffect(() => {
@@ -177,15 +196,7 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
177196
void handleDiscover();
178197
}, debounceMs);
179198
return () => window.clearTimeout(timer);
180-
}, [
181-
accessToken,
182-
effectiveUrl,
183-
isParentDriven,
184-
discoveryRequest?.url,
185-
discoveryRequest?.discovery_mode,
186-
discoveryRequest?.params,
187-
handleDiscover,
188-
]);
199+
}, [accessToken, effectiveUrl, isParentDriven, handleDiscover]);
189200

190201
const toggleSkill = (id: string, checked: boolean) => {
191202
setSelectedSkillIds((prev) => {

0 commit comments

Comments
 (0)