diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_card_discovery.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_card_discovery.test.tsx index dcd6d8a978a..6823ac158cf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_card_discovery.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_card_discovery.test.tsx @@ -189,6 +189,34 @@ describe("AgentCardDiscovery", () => { }); }); + it("sends the credentials only when the admin clicks Discover", async () => { + mockDiscover.mockRejectedValueOnce(new Error("HTTP 401")).mockResolvedValue({ + url: "https://lg.example.com", + agent_card: sampleCard, + }); + const plan = { + url: "https://lg.example.com", + discovery_mode: "langgraph_platform" as const, + params: { assistant_id: "asst_1" }, + api_key: "sk-1", + static_headers: { "x-tenant": "t1" }, + }; + renderWithProviders(); + await vi.advanceTimersByTimeAsync(500); + expect(await screen.findByText(/HTTP 401/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /Discover/ })); + + await waitFor(() => expect(mockDiscover).toHaveBeenCalledTimes(2)); + const anonymous = { discovery_mode: "langgraph_platform", params: { assistant_id: "asst_1" } }; + expect(mockDiscover).toHaveBeenNthCalledWith(1, "tok", "https://lg.example.com", anonymous); + expect(mockDiscover).toHaveBeenNthCalledWith(2, "tok", "https://lg.example.com", { + ...anonymous, + api_key: "sk-1", + static_headers: { "x-tenant": "t1" }, + }); + }); + it("disables Re-discover until the parent provides a usable URL", async () => { renderWithProviders( = ({ [discoveryRequest?.params], ); - const handleDiscover = useCallback(async () => { - if (!accessToken) { - setError(i18n.t("agents.cardDiscovery.errors.noAccessToken")); - onApplyRef.current(null); - return; - } - const trimmed = effectiveUrl.trim(); - if (!trimmed) { - setError( - isParentDriven - ? i18n.t("agents.cardDiscovery.errors.fillConnectionDetails") - : i18n.t("agents.cardDiscovery.errors.enterBaseUrl"), - ); - setCard(null); - onApplyRef.current(null); - return; - } - - const currentDiscoveryRequest = discoveryRequestRef.current; - const requestId = ++discoverRequestIdRef.current; - setLoading(true); - setError(null); - try { - const response = await discoverAgentCardCall( - accessToken, - trimmed, - isParentDriven && currentDiscoveryRequest - ? { - discovery_mode: currentDiscoveryRequest.discovery_mode, - params: currentDiscoveryRequest.params, - } - : undefined, - ); - if (requestId !== discoverRequestIdRef.current) return; - lastSyncedSelectionRef.current = null; - setCard(response.agent_card); - resetSelections(response.agent_card); - } catch (e: any) { - if (requestId !== discoverRequestIdRef.current) return; - setError(e?.message ? String(e.message) : i18n.t("agents.cardDiscovery.errors.discoverFailed")); - setCard(null); - lastSyncedSelectionRef.current = null; - onApplyRef.current(null); - } finally { - if (requestId === discoverRequestIdRef.current) { - setLoading(false); + // Credentials go only with an explicit Discover, never to a URL the admin is still typing. + const handleDiscover = useCallback( + async (withCredentials?: boolean) => { + if (!accessToken) { + setError(i18n.t("agents.cardDiscovery.errors.noAccessToken")); + onApplyRef.current(null); + return; } - } - // ``discoveryMode`` / ``discoveryParamsKey`` are primitive proxies for - // ``discoveryRequest`` content; the actual object is read via the ref - // above so identity churn from the parent doesn't recreate this callback. - // ``savedAgentCard`` is intentionally NOT a dep — it's read via - // ``savedAgentCardRef`` inside ``resetSelections``. Including it here - // would recreate this callback whenever the parent hands us a new - // ``savedAgentCard`` object (e.g. a background refresh of agent data - // during editing), which would re-fire the auto-discover effect and - // wipe in-progress user selections. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [accessToken, effectiveUrl, isParentDriven, discoveryMode, discoveryParamsKey]); + const trimmed = effectiveUrl.trim(); + if (!trimmed) { + setError( + isParentDriven + ? i18n.t("agents.cardDiscovery.errors.fillConnectionDetails") + : i18n.t("agents.cardDiscovery.errors.enterBaseUrl"), + ); + setCard(null); + onApplyRef.current(null); + return; + } + + const currentDiscoveryRequest = discoveryRequestRef.current; + const requestId = ++discoverRequestIdRef.current; + setLoading(true); + setError(null); + try { + const response = await discoverAgentCardCall( + accessToken, + trimmed, + isParentDriven && currentDiscoveryRequest + ? { + discovery_mode: currentDiscoveryRequest.discovery_mode, + params: currentDiscoveryRequest.params, + ...(withCredentials && { + api_key: currentDiscoveryRequest.api_key, + static_headers: currentDiscoveryRequest.static_headers, + }), + } + : undefined, + ); + if (requestId !== discoverRequestIdRef.current) return; + lastSyncedSelectionRef.current = null; + setCard(response.agent_card); + resetSelections(response.agent_card); + } catch (e: any) { + if (requestId !== discoverRequestIdRef.current) return; + setError(e?.message ? String(e.message) : i18n.t("agents.cardDiscovery.errors.discoverFailed")); + setCard(null); + lastSyncedSelectionRef.current = null; + onApplyRef.current(null); + } finally { + if (requestId === discoverRequestIdRef.current) { + setLoading(false); + } + } + // ``discoveryMode`` / ``discoveryParamsKey`` are primitive proxies for + // ``discoveryRequest`` content; the actual object is read via the ref + // above so identity churn from the parent doesn't recreate this callback. + // ``savedAgentCard`` is intentionally NOT a dep — it's read via + // ``savedAgentCardRef`` inside ``resetSelections``. Including it here + // would recreate this callback whenever the parent hands us a new + // ``savedAgentCard`` object (e.g. a background refresh of agent data + // during editing), which would re-fire the auto-discover effect and + // wipe in-progress user selections. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, + [accessToken, effectiveUrl, isParentDriven, discoveryMode, discoveryParamsKey], + ); const debouncedDiscover = useDebouncedCallback( () => { @@ -283,7 +291,7 @@ const AgentCardDiscovery: React.FC = ({ )}
- @@ -312,7 +320,7 @@ const AgentCardDiscovery: React.FC = ({ }} disabled={loading} /> - diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_config.ts b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_config.ts index 652b2c0b480..a1280f9b25e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_config.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_config.ts @@ -303,16 +303,9 @@ export const buildAgentDataFromForm = (values: any, existingAgent?: any) => { if (values.rpm_limit != null) agentData.rpm_limit = values.rpm_limit; if (values.session_tpm_limit != null) agentData.session_tpm_limit = values.session_tpm_limit; if (values.session_rpm_limit != null) agentData.session_rpm_limit = values.session_rpm_limit; - // static_headers: convert [{header, value}, ...] → {header: value, ...} - if (Array.isArray(values.static_headers) && values.static_headers.length > 0) { - const staticHeaders: Record = {}; - values.static_headers.forEach((entry: { header?: string; value?: string }) => { - const key = entry?.header?.trim(); - if (key) staticHeaders[key] = entry?.value ?? ""; - }); - if (Object.keys(staticHeaders).length > 0) { - agentData.static_headers = staticHeaders; - } + const staticHeaders = staticHeadersFromForm(values.static_headers); + if (Object.keys(staticHeaders).length > 0) { + agentData.static_headers = staticHeaders; } // extra_headers: already an array of strings from Select tags @@ -325,6 +318,18 @@ export const buildAgentDataFromForm = (values: any, existingAgent?: any) => { return agentData; }; +/** Converts the form's [{header, value}, ...] rows to {header: value, ...}, skipping rows without a name. */ +export const staticHeadersFromForm = (rows: unknown): Record => { + const staticHeaders: Record = {}; + if (Array.isArray(rows)) { + rows.forEach((entry: { header?: string; value?: string }) => { + const key = entry?.header?.trim(); + if (key) staticHeaders[key] = entry?.value ?? ""; + }); + } + return staticHeaders; +}; + export const applyKillSwitchToPayload = ( agentData: { kill_switch?: KillSwitchConfig | null }, form: KillSwitchFormValue | undefined, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_discovery_utils.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_discovery_utils.test.ts index efd55d2bd79..7223b631713 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_discovery_utils.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_discovery_utils.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from "vitest"; -import { selectionsFromSavedAgentCard, selectionsFromUpstreamCard, skillId } from "./agent_discovery_utils"; +import { + buildDiscoveryRequest, + selectionsFromSavedAgentCard, + selectionsFromUpstreamCard, + skillId, +} from "./agent_discovery_utils"; const upstreamCard = { name: "Upstream Agent", @@ -50,3 +55,44 @@ describe("selectionsFromUpstreamCard", () => { expect(result.editedName).toBe("Upstream Agent"); }); }); + +describe("buildDiscoveryRequest", () => { + it("sends the LangGraph API key typed into the form", () => { + const values = { api_base: "https://lg.example.com/", assistant_id: "asst_1", api_key: "sk-1" }; + + const plan = { + url: "https://lg.example.com", + discovery_mode: "langgraph_platform", + params: { assistant_id: "asst_1" }, + display_url: "https://lg.example.com/.well-known/agent-card.json?assistant_id=asst_1", + api_key: "sk-1", + }; + + expect(buildDiscoveryRequest("langgraph", values)).toEqual(plan); + }); + + it("sends no key when the edit form holds the redacted saved one", () => { + const values = { api_base: "https://lg.example.com", assistant_id: "asst_1", api_key: "REDACTED_BY_LITELM" }; + + expect(buildDiscoveryRequest("langgraph", values)).not.toHaveProperty("api_key"); + }); + + it("sends an A2A agent's named static headers", () => { + const values = { + url: "https://agent.example.com", + static_headers: [ + { header: " Authorization ", value: "Basic abc" }, + { header: " ", value: "orphan" }, + ], + }; + + const plan = { + url: "https://agent.example.com", + discovery_mode: "well_known_fallback", + display_url: "https://agent.example.com/.well-known/agent-card.json", + static_headers: { Authorization: "Basic abc" }, + }; + + expect(buildDiscoveryRequest("a2a", values)).toEqual(plan); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_discovery_utils.ts b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_discovery_utils.ts index a0c44395043..0c6a92c6dd9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_discovery_utils.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_discovery_utils.ts @@ -1,12 +1,17 @@ import { AgentCreateInfo, DiscoveredAgentCard, DiscoveryMode } from "@/components/networking"; +import { staticHeadersFromForm } from "./agent_config"; export interface DiscoveryRequestPlan { url: string; discovery_mode: DiscoveryMode; params?: Record; display_url?: string; + api_key?: string; + static_headers?: Record; } +const REDACTED_SECRET = "REDACTED_BY_LITELM"; + export const skillId = (skill: any, idx: number): string => skill?.id ?? skill?.name ?? `skill-${idx}`; export const ALLOWED_CAPABILITY_KEYS = ["streaming"] as const; @@ -137,21 +142,26 @@ export const buildDiscoveryRequest = ( const assistantId = trim(values.assistant_id); if (!base || !assistantId) return undefined; const query = `?assistant_id=${encodeURIComponent(assistantId)}`; + const apiKey = typeof values.api_key === "string" ? values.api_key : ""; return { url: base, discovery_mode: "langgraph_platform", params: { assistant_id: assistantId }, display_url: `${base}/.well-known/agent-card.json${query}`, + // A saved key reads back redacted, so only a key typed into the form is sent. + ...(apiKey.trim() && apiKey !== REDACTED_SECRET ? { api_key: apiKey } : {}), }; } if (agentType === "a2a" || selectedAgentTypeInfo?.use_a2a_form_fields) { const base = stripTrailingSlash(trim(values.url)); if (!base) return undefined; + const staticHeaders = staticHeadersFromForm(values.static_headers); return { url: base, discovery_mode: "well_known_fallback", display_url: `${base}/.well-known/agent-card.json`, + ...(Object.keys(staticHeaders).length > 0 ? { static_headers: staticHeaders } : {}), }; } diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 994c2948346..ba4d83cf2e9 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -4353,7 +4353,7 @@ type DiscoverAgentCardResponse = Omit + Pick >; export const discoverAgentCardCall = async (