From d6652f1b6dfa46cd36ec109822179be45cbb22dd Mon Sep 17 00:00:00 2001 From: shrey kharbanda Date: Tue, 3 Nov 2026 10:40:58 -0800 Subject: [PATCH] feat(agents): add MCP-style authentication settings to the agent form The agent form split auth across a LangGraph API Key field and an A2A Authentication Headers panel, with no auth type and no way to clear a saved key. One Authentication settings section now holds an Authentication select (None or Bearer Token), the token, static headers and forwarded client headers, matching the MCP server form. Bearer is offered only where the runtime sends the key: LangGraph, and A2A agents without a provider or Entra credentials of their own. Choosing None on edit clears the key. --- .../agents/_components/AgentAuthSettings.tsx | 187 +++++++++++++++++ .../agents/_components/AgentFormKit.tsx | 2 + .../add_agent_form.integration.test.tsx | 6 +- .../agents/_components/add_agent_form.tsx | 12 +- .../agents/_components/agent_auth.test.ts | 196 ++++++++++++++++++ .../agents/_components/agent_auth.ts | 109 ++++++++++ .../agents/_components/agent_config.ts | 33 +-- .../_components/agent_discovery_utils.test.ts | 14 +- .../_components/agent_discovery_utils.ts | 16 +- .../agents/_components/agent_form_fields.tsx | 101 +-------- .../agent_info.integration.test.tsx | 70 ++++++- .../agents/_components/agent_info.tsx | 29 ++- ui/litellm-dashboard/src/i18n/locales/en.json | 9 +- 13 files changed, 626 insertions(+), 158 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentAuthSettings.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_auth.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_auth.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentAuthSettings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentAuthSettings.tsx new file mode 100644 index 00000000000..80985087c75 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentAuthSettings.tsx @@ -0,0 +1,187 @@ +import React from "react"; +import { useFieldArray, useFormContext, useWatch } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { ChevronDown, Plus, Trash2 } from "lucide-react"; +import { AUTH_TYPE } from "@/components/mcp_tools/types"; +import { PasswordInput } from "@/components/shared/PasswordInput"; +import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Field, FieldGroup, FieldTitle } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { AgentFormField, AgentFormValues, AgentTagsInput, labelWithHint } from "./AgentFormKit"; +import { AGENT_AUTH_TYPE_ITEMS, AgentAuthSupport, isAuthValueMissing } from "./agent_auth"; + +const StaticHeadersFieldArray = () => { + const { t } = useTranslation(); + const { control } = useFormContext(); + const { fields, append, remove } = useFieldArray({ control, name: "static_headers" }); + + return ( + <> + {fields.map((item, index) => ( +
+ + {({ value, onChange, ref, ...control }) => ( + + )} + + + {({ value, onChange, ref, ...control }) => ( + + )} + + +
+ ))} + + + ); +}; + +const AgentAuthSettings = ({ support }: { support: AgentAuthSupport }) => { + const { t } = useTranslation(); + const { control } = useFormContext(); + const authType = useWatch({ control, name: "auth_type" }); + const agentName = useWatch({ control, name: "agent_name" }); + + if (!support.bearer && !support.headers) return null; + + const authTypeHint = + authType === AUTH_TYPE.BEARER_TOKEN + ? t("agents.formFields.authHeaders.authTypeBearerHint") + : t("agents.formFields.authHeaders.authTypeNoneHint", { + agentName: typeof agentName === "string" && agentName ? agentName : "", + }); + + return ( + + + {t("agents.formFields.authHeaders.title")} + + + + + {support.bearer && ( + + {({ value, onChange, ...control }) => ( + + )} + + )} + + {support.bearer && authType === AUTH_TYPE.BEARER_TOKEN && ( + + isAuthValueMissing(value, values) ? t("agents.formFields.authHeaders.authValueRequired") : true, + }} + > + {({ value, onChange, ref, ...control }) => ( + + )} + + )} + + {support.headers && ( + <> + + + {labelWithHint( + t("agents.formFields.authHeaders.staticHeaders"), + t("agents.formFields.authHeaders.staticHeadersHint"), + )} + +
+ +
+
+ + + {({ id, value, onChange }) => ( + + )} + + + )} +
+
+
+ ); +}; + +export default AgentAuthSettings; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentFormKit.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentFormKit.tsx index 70c7d2fdb92..34251765a4b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentFormKit.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentFormKit.tsx @@ -84,6 +84,8 @@ export interface AgentFormValues { cost_per_query?: string | number; input_cost_per_token?: string | number; output_cost_per_token?: string | number; + auth_type?: string; + api_key?: string; static_headers?: StaticHeaderFormValue[]; extra_headers?: string[]; kill_switch?: KillSwitchFormValue; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx index 53ce661baaf..14b521aee99 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx @@ -199,7 +199,6 @@ describe("AddAgentForm submit payload", () => { await user.type(await screen.findByLabelText("Model (Optional)"), "gpt-4o"); await user.click(screen.getByRole("switch", { name: "Make Public" })); - await user.click(await panel(/Authentication Headers/)); await user.click(await screen.findByRole("button", { name: /Add Static Header/ })); await user.type(await screen.findByPlaceholderText("Header name (e.g. Authorization)"), "X-Tenant"); await user.type(screen.getByPlaceholderText("Value (e.g. Bearer token123)"), "acme"); @@ -321,7 +320,10 @@ describe("AddAgentForm submit payload", () => { await user.type(screen.getByPlaceholderText("Describe what this agent does..."), "graph agent"); await user.type(screen.getByLabelText("API Base"), "https://lg.example.com"); await user.type(screen.getByLabelText("Assistant ID"), "asst_1"); - await user.type(screen.getByLabelText("API Key"), "secret-value"); + expect(screen.queryByLabelText("API Key")).not.toBeInTheDocument(); + await user.click(screen.getByRole("combobox", { name: "Authentication" })); + await user.click(await screen.findByRole("option", { name: "Bearer Token" })); + fireEvent.change(await screen.findByLabelText("Authentication Value"), { target: { value: "secret-value" } }); await goToLastStepAndCreate(user); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx index 715f2516e66..63d5f4a28e9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx @@ -37,6 +37,8 @@ import TeamDropdown from "@/components/common_components/team_dropdown"; import AgentFormFields from "./agent_form_fields"; import AgentCardDiscovery, { DiscoveredAgentCardSelection } from "./agent_card_discovery"; import { buildDiscoveryRequest, overlayDiscoveredCardParams } from "./agent_discovery_utils"; +import AgentAuthSettings from "./AgentAuthSettings"; +import { agentAuthSupport, withAgentAuth, withoutBearerKeyField } from "./agent_auth"; import DynamicAgentFormFields, { buildDynamicAgentData } from "./dynamic_agent_form_fields"; import { PasswordInput } from "@/components/shared/PasswordInput"; import { AGENT_FORM_CONFIG, getDefaultFormValues, buildAgentDataFromForm } from "./agent_config"; @@ -258,7 +260,12 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok }; }, [currentStep, accessToken]); - const selectedAgentTypeInfo = agentTypeMetadata.find((info) => info.agent_type === agentType); + const agentTypeInfo = agentTypeMetadata.find((info) => info.agent_type === agentType); + const authSupport = agentAuthSupport(agentType, agentTypeInfo?.use_a2a_form_fields); + const selectedAgentTypeInfo = React.useMemo( + () => withoutBearerKeyField(agentTypeInfo, authSupport.bearer), + [agentTypeInfo, authSupport.bearer], + ); // Watch every form field so we can recompute the discovery plan whenever // the user types into a relevant credential field below. @@ -372,7 +379,7 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok setIsSubmitting(false); return; } - const agentData = withAgentIdentity(built, values); + const agentData = withAgentIdentity(withAgentAuth(built, values, authSupport), values); // Build object_permission from MCP Tools step (allowed_mcp_servers_and_groups, mcp_tool_permissions) const mcpServersAndGroups = values.allowed_mcp_servers_and_groups ?? {}; @@ -947,6 +954,7 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok discover, so we skip them. */} {agentType !== CUSTOM_AGENT_TYPE && (
+ { + it.each([ + ["a2a", undefined, undefined, BEARER_AND_HEADERS], + ["langgraph", false, { custom_llm_provider: "langgraph" }, BEARER_AND_HEADERS], + ["pydantic_ai_agents", true, undefined, HEADERS_ONLY], + ["a2a", undefined, { custom_llm_provider: "watsonx" }, HEADERS_ONLY], + ["a2a", undefined, { client_secret: REDACTED_SECRET }, HEADERS_ONLY], + ["azure_ai_foundry", null, { custom_llm_provider: "azure_ai" }, NO_AUTH_SECTION], + ])("%s with saved params %j offers the auth the runtime sends", (agentType, usesA2AFormFields, saved, expected) => { + expect(agentAuthSupport(agentType, usesA2AFormFields, saved)).toEqual(expected); + }); +}); + +describe("agentAuthFormValues", () => { + it("reads a saved key as a bearer token, even when the server redacted it", () => { + const saved = { + litellm_params: { api_key: REDACTED_SECRET }, + static_headers: { "x-tenant": "t1" }, + extra_headers: ["x-request-id"], + }; + + const bearer = { + auth_type: "bearer_token", + api_key: REDACTED_SECRET, + static_headers: [{ header: "x-tenant", value: "t1" }], + extra_headers: ["x-request-id"], + }; + + expect(agentAuthFormValues(saved)).toEqual(bearer); + }); + + it.each([[{ litellm_params: { api_key: null } }], [{ litellm_params: {} }], [{ litellm_params: null }], [{}]])( + "reads an agent without a key as None: %j", + (agent) => { + const none = { auth_type: "none", api_key: "", static_headers: [], extra_headers: [] }; + + expect(agentAuthFormValues(agent)).toEqual(none); + }, + ); +}); + +describe("withAgentAuth", () => { + const SAVED_WITH_KEY = { + litellm_params: { api_key: REDACTED_SECRET, guardrails: ["pii"], require_trace_id_on_calls_by_agent: true }, + static_headers: { "x-tenant": "t1" }, + extra_headers: ["x-request-id"], + }; + const UNCHANGED_FORM = agentAuthFormValues(SAVED_WITH_KEY); + + it("adds a token typed on create next to the agent's other params", () => { + const typed = { auth_type: "bearer_token", api_key: "sk-1", static_headers: [], extra_headers: [] }; + const payload = withAgentAuth( + { agent_name: "a", litellm_params: { model: "langgraph/asst" } }, + typed, + BEARER_AND_HEADERS, + ); + + expect(payload).toEqual({ agent_name: "a", litellm_params: { model: "langgraph/asst", api_key: "sk-1" } }); + }); + + it("leaves the payload as built when the admin did not touch the auth section", () => { + const built = { agent_name: "a", tpm_limit: 5 }; + + expect(withAgentAuth(built, UNCHANGED_FORM, BEARER_AND_HEADERS, SAVED_WITH_KEY)).toEqual(built); + expect(withAgentAuth(built, agentAuthFormValues({}), BEARER_AND_HEADERS)).toEqual(built); + }); + + it("clears the saved key on None and keeps every other saved param, even with a token typed earlier", () => { + const payload = withAgentAuth( + { agent_name: "a" }, + { ...UNCHANGED_FORM, auth_type: "none", api_key: "sk-typed-then-abandoned" }, + BEARER_AND_HEADERS, + SAVED_WITH_KEY, + ); + + expect(payload).toEqual({ + agent_name: "a", + litellm_params: { api_key: null, guardrails: ["pii"], require_trace_id_on_calls_by_agent: true }, + }); + }); + + it("replaces the saved key on top of the params the form rebuilt", () => { + const payload = withAgentAuth( + { litellm_params: { model: "langgraph/asst" } }, + { ...UNCHANGED_FORM, api_key: "sk-new" }, + BEARER_AND_HEADERS, + SAVED_WITH_KEY, + ); + + expect(payload).toEqual({ litellm_params: { model: "langgraph/asst", api_key: "sk-new" } }); + }); + + it("sends changed headers by trimmed name, drops nameless rows, and sends an emptied list to clear it", () => { + const payload = withAgentAuth( + {}, + { + ...UNCHANGED_FORM, + static_headers: [ + { header: " x-tenant ", value: "t2" }, + { header: "x-empty-value" }, + { header: " ", value: "orphan" }, + ], + extra_headers: [], + }, + HEADERS_ONLY, + SAVED_WITH_KEY, + ); + + expect(payload).toEqual({ static_headers: { "x-tenant": "t2", "x-empty-value": "" }, extra_headers: [] }); + }); + + it("leaves a provider's own api_key alone when the type has no auth section", () => { + const payload = withAgentAuth( + { litellm_params: { api_key: "azure-key" } }, + { auth_type: "none", api_key: "" }, + NO_AUTH_SECTION, + { litellm_params: { api_key: REDACTED_SECRET } }, + ); + + expect(payload).toEqual({ litellm_params: { api_key: "azure-key" } }); + }); + + it.each([ + [ + { + auth_type: "bearer_token", + api_key: "sk-1", + static_headers: [{ header: "x-a", value: "1" }], + extra_headers: [], + }, + ], + [{ auth_type: "none", api_key: "", static_headers: [], extra_headers: ["x-request-id"] }], + ])("round-trips through a newly saved agent: %j", (values) => { + const saved = withAgentAuth({}, values, BEARER_AND_HEADERS); + + expect( + agentAuthFormValues({ + litellm_params: saved.litellm_params, + static_headers: saved.static_headers as Record | undefined, + extra_headers: saved.extra_headers as string[] | undefined, + }), + ).toEqual(values); + }); +}); + +describe("discoveryAuth", () => { + it("sends a typed token and the static headers when creating an agent", () => { + expect( + discoveryAuth( + { auth_type: "bearer_token", api_key: "sk-1", static_headers: [{ header: "x-tenant", value: "t1" }] }, + BEARER_AND_HEADERS, + ), + ).toEqual({ api_key: "sk-1", static_headers: { "x-tenant": "t1" } }); + }); + + it("sends no token while the edit form still holds the redacted saved one", () => { + expect(discoveryAuth({ auth_type: "bearer_token", api_key: REDACTED_SECRET }, BEARER_AND_HEADERS)).toEqual({}); + }); + + it("sends no token when Authentication is None", () => { + expect(discoveryAuth({ auth_type: "none", api_key: "sk-left-over" }, BEARER_AND_HEADERS)).toEqual({}); + }); + + it("sends only static headers for a type without bearer auth", () => { + expect( + discoveryAuth( + { auth_type: "bearer_token", api_key: "sk-1", static_headers: [{ header: "x-a", value: "1" }] }, + HEADERS_ONLY, + ), + ).toEqual({ static_headers: { "x-a": "1" } }); + }); +}); + +describe("isAuthValueMissing", () => { + it.each([ + ["none", "", false], + ["bearer_token", "sk-1", false], + ["bearer_token", " ", true], + ])("auth %s with value %j is missing: %j", (authType, value, expected) => { + expect(isAuthValueMissing(value, { auth_type: authType, api_key: value })).toBe(expected); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_auth.ts b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_auth.ts new file mode 100644 index 00000000000..d15117c91e2 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_auth.ts @@ -0,0 +1,109 @@ +import { AUTH_TYPE, AUTH_TYPE_ITEMS } from "@/components/mcp_tools/types"; +import type { AgentCreateInfo } from "@/components/networking"; +import type { AgentFormValues, AgentRequestPayload, StaticHeaderFormValue } from "./AgentFormKit"; + +export const REDACTED_SECRET = "REDACTED_BY_LITELM"; + +export const AGENT_AUTH_TYPE_ITEMS = AUTH_TYPE_ITEMS.filter( + (item) => item.value === AUTH_TYPE.NONE || item.value === AUTH_TYPE.BEARER_TOKEN, +); + +export interface AgentAuthSupport { + bearer: boolean; + headers: boolean; +} + +const ENTRA_CREDENTIAL_KEYS = ["azure_ad_token", "client_secret", "azure_password"]; + +/** Matches the runtime: an A2A agent with its own provider or Entra credentials never sends the bearer key. */ +export const agentAuthSupport = ( + agentType: string, + usesA2AFormFields?: boolean | null, + savedParams?: Record | null, +): AgentAuthSupport => ({ + bearer: + agentType === "langgraph" || + (agentType === "a2a" && + !savedParams?.custom_llm_provider && + !ENTRA_CREDENTIAL_KEYS.some((key) => savedParams?.[key])), + headers: agentType === "a2a" || agentType === "langgraph" || Boolean(usesA2AFormFields), +}); + +/** The section owns the bearer key, so the agent type's own api_key field leaves the form. */ +export const withoutBearerKeyField = ( + info: AgentCreateInfo | undefined, + bearer: boolean, +): AgentCreateInfo | undefined => + info && bearer ? { ...info, credential_fields: info.credential_fields.filter(({ key }) => key !== "api_key") } : info; + +interface SavedAgentAuth { + litellm_params?: Record | null; + static_headers?: Record | null; + extra_headers?: string[] | null; +} + +export const agentAuthFormValues = (agent: SavedAgentAuth): AgentFormValues => { + const apiKey = agent.litellm_params?.api_key; + const savedKey = typeof apiKey === "string" ? apiKey : ""; + return { + auth_type: savedKey ? AUTH_TYPE.BEARER_TOKEN : AUTH_TYPE.NONE, + api_key: savedKey, + static_headers: Object.entries(agent.static_headers ?? {}).map(([header, value]) => ({ header, value })), + extra_headers: agent.extra_headers ?? [], + }; +}; + +/** Converts the form's [{header, value}, ...] rows to {header: value, ...}, skipping rows without a name. */ +const staticHeaderMap = (rows: StaticHeaderFormValue[] | undefined): Record => + Object.fromEntries( + (rows ?? []).flatMap(({ header, value }) => { + const name = header?.trim(); + return name ? [[name, value ?? ""]] : []; + }), + ); + +const bearerKey = (values: AgentFormValues): string | undefined => + values.auth_type === AUTH_TYPE.BEARER_TOKEN && values.api_key ? values.api_key : undefined; + +const sameHeaders = (a: Record, b: Record): boolean => + JSON.stringify(a) === JSON.stringify(b); + +/** Adds only the auth fields the admin changed, since PATCH replaces litellm_params as a whole. */ +export const withAgentAuth = ( + payload: AgentRequestPayload, + values: AgentFormValues, + support: AgentAuthSupport, + saved: SavedAgentAuth = {}, +): AgentRequestPayload => { + const before = agentAuthFormValues(saved); + const apiKey = bearerKey(values) ?? null; + const staticHeaders = staticHeaderMap(values.static_headers); + const extraHeaders = values.extra_headers ?? []; + const keyChanged = support.bearer && apiKey !== (bearerKey(before) ?? null); + const staticChanged = support.headers && !sameHeaders(staticHeaders, staticHeaderMap(before.static_headers)); + const extraChanged = support.headers && JSON.stringify(extraHeaders) !== JSON.stringify(before.extra_headers); + return { + ...payload, + ...(keyChanged ? { litellm_params: { ...(payload.litellm_params ?? saved.litellm_params), api_key: apiKey } } : {}), + ...(staticChanged ? { static_headers: staticHeaders } : {}), + ...(extraChanged ? { extra_headers: extraHeaders } : {}), + }; +}; + +export interface DiscoveryAuth { + api_key?: string; + static_headers?: Record; +} + +export const discoveryAuth = (values: AgentFormValues, support: AgentAuthSupport): DiscoveryAuth => { + const apiKey = support.bearer ? bearerKey(values) : undefined; + const staticHeaders = support.headers ? staticHeaderMap(values.static_headers) : {}; + return { + // A saved key reads back redacted, so only a key typed into the form is sent. + ...(apiKey?.trim() && apiKey !== REDACTED_SECRET ? { api_key: apiKey } : {}), + ...(Object.keys(staticHeaders).length > 0 ? { static_headers: staticHeaders } : {}), + }; +}; + +export const isAuthValueMissing = (value: unknown, values: AgentFormValues): boolean => + values.auth_type === AUTH_TYPE.BEARER_TOKEN && !(typeof value === "string" && value.trim()); 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 a1280f9b25e..dfbb0b2ae63 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 @@ -1,5 +1,6 @@ import type { ParseKeys } from "i18next"; import { parseIdentityForForm } from "./agent_identity"; +import { agentAuthFormValues } from "./agent_auth"; /** * Shared configuration for agent form fields * Used across create, view, and update operations @@ -303,33 +304,11 @@ 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; - 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 - if (Array.isArray(values.extra_headers) && values.extra_headers.length > 0) { - agentData.extra_headers = values.extra_headers; - } - applyKillSwitchToPayload(agentData, values.kill_switch, existingAgent); 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, @@ -401,15 +380,7 @@ export const parseAgentForForm = (agent: any) => { rpm_limit: agent.rpm_limit, session_tpm_limit: agent.session_tpm_limit, session_rpm_limit: agent.session_rpm_limit, - // static_headers: {key: value} → [{header, value}, ...] - static_headers: agent.static_headers - ? Object.entries(agent.static_headers as Record).map(([header, value]) => ({ - header, - value, - })) - : [], - // extra_headers: already an array of strings - extra_headers: agent.extra_headers ?? [], + ...agentAuthFormValues(agent), kill_switch: parseKillSwitchForForm(agent.kill_switch), ...parseMcpPermissionsForForm(agent), ...parseAccessGroupIdsForForm(agent), 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 7223b631713..68456b7a31e 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 @@ -58,7 +58,12 @@ describe("selectionsFromUpstreamCard", () => { 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 values = { + api_base: "https://lg.example.com/", + assistant_id: "asst_1", + auth_type: "bearer_token", + api_key: "sk-1", + }; const plan = { url: "https://lg.example.com", @@ -72,7 +77,12 @@ describe("buildDiscoveryRequest", () => { }); 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" }; + const values = { + api_base: "https://lg.example.com", + assistant_id: "asst_1", + auth_type: "bearer_token", + api_key: "REDACTED_BY_LITELM", + }; expect(buildDiscoveryRequest("langgraph", values)).not.toHaveProperty("api_key"); }); 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 0c6a92c6dd9..1ed825210b3 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,17 +1,13 @@ import { AgentCreateInfo, DiscoveredAgentCard, DiscoveryMode } from "@/components/networking"; -import { staticHeadersFromForm } from "./agent_config"; +import { DiscoveryAuth, agentAuthSupport, discoveryAuth } from "./agent_auth"; -export interface DiscoveryRequestPlan { +export interface DiscoveryRequestPlan extends DiscoveryAuth { 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; @@ -136,32 +132,30 @@ export const buildDiscoveryRequest = ( ): DiscoveryRequestPlan | undefined => { const trim = (v: unknown) => (v ?? "").toString().trim(); const stripTrailingSlash = (s: string) => s.replace(/\/+$/, ""); + const auth = discoveryAuth(values, agentAuthSupport(agentType, selectedAgentTypeInfo?.use_a2a_form_fields)); 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)}`; - 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 } : {}), + ...auth, }; } 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 } : {}), + ...auth, }; } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_form_fields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_form_fields.tsx index 41e25d14054..a96e8212909 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_form_fields.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_form_fields.tsx @@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; -import { Field, FieldGroup, FieldTitle } from "@/components/ui/field"; +import { FieldGroup } from "@/components/ui/field"; import { AGENT_FORM_CONFIG, SKILL_FIELD_CONFIG } from "./agent_config"; import CostConfigFields, { COST_FIELD_NAMES } from "./cost_config_fields"; import KillSwitchFormFields from "./KillSwitchFormFields"; @@ -21,8 +21,6 @@ import { labelWithHint, } from "./AgentFormKit"; -const AUTH_HEADERS_PANEL_KEY = "auth_headers"; - const namesOf = (fields: readonly { name: string }[]): readonly string[] => fields.map((field) => field.name); export const A2A_PANEL_FIELD_NAMES: Readonly> = { @@ -32,7 +30,6 @@ export const A2A_PANEL_FIELD_NAMES: Readonly> [AGENT_FORM_CONFIG.optional.key]: namesOf(AGENT_FORM_CONFIG.optional.fields), [AGENT_FORM_CONFIG.cost.key]: COST_FIELD_NAMES, [AGENT_FORM_CONFIG.litellm.key]: namesOf(AGENT_FORM_CONFIG.litellm.fields), - [AUTH_HEADERS_PANEL_KEY]: ["static_headers", "extra_headers"], [KILL_SWITCH_PANEL_KEY]: ["kill_switch"], }; @@ -160,65 +157,6 @@ const SkillsFieldArray = () => { ); }; -const StaticHeadersFieldArray = () => { - const { t } = useTranslation(); - const { control } = useFormContext(); - const { fields, append, remove } = useFieldArray({ control, name: "static_headers" }); - - return ( - <> - {fields.map((item, index) => ( -
- - {({ value, onChange, ref, ...control }) => ( - - )} - - - {({ value, onChange, ref, ...control }) => ( - - )} - - -
- ))} - - - ); -}; - const AgentFormFields: React.FC = ({ panels, showAgentName = true, visiblePanels }) => { const { t } = useTranslation(); const shouldShow = (key: string) => !visiblePanels || visiblePanels.includes(key); @@ -391,43 +329,6 @@ const AgentFormFields: React.FC = ({ panels, showAgentName )} - {shouldShow(AUTH_HEADERS_PANEL_KEY) && ( - - - - {labelWithHint( - t("agents.formFields.authHeaders.staticHeaders"), - t("agents.formFields.authHeaders.staticHeadersHint"), - )} - -
- -
-
- - - {({ id, value, onChange }) => ( - - )} - -
- )} - {shouldShow(KILL_SWITCH_PANEL_KEY) && ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.integration.test.tsx index e08cab776c4..4c794063d76 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.integration.test.tsx @@ -24,7 +24,11 @@ vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({ useKeys: () => ({ data: { keys: [] }, isLoading: false, refetch: vi.fn() }), })); -vi.mock("./agent_card_discovery", () => ({ default: () =>
})); +vi.mock("./agent_card_discovery", () => ({ + default: ({ discoveryRequest }: { discoveryRequest?: object }) => ( +
+ ), +})); vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({ useAccessGroups: () => ({ data: [], isLoading: false, isError: false }), @@ -254,8 +258,7 @@ describe("AgentInfoView update payload", () => { await user.click(screen.getByRole("button", { name: /Optional Settings/ })); await user.click(screen.getByRole("button", { name: /Cost Configuration/ })); await user.click(screen.getByRole("button", { name: /LiteLLM Parameters/ })); - await user.click(screen.getByRole("button", { name: /Authentication Headers/ })); - await screen.findByLabelText("Forward Client Headers"); + await screen.findByLabelText("Model (Optional)"); await save(user); @@ -275,8 +278,6 @@ describe("AgentInfoView update payload", () => { documentationUrl: "https://example.com/docs", }, litellm_params: { model: "gpt-4o", make_public: true, cost_per_query: 0.5 }, - static_headers: { Authorization: "Bearer x" }, - extra_headers: ["x-api-key"], tpm_limit: 111, rpm_limit: 222, session_tpm_limit: 333, @@ -365,6 +366,65 @@ describe("AgentInfoView update payload", () => { }); }); + it("loads a saved LangGraph key as a bearer token and sends a replacement typed over it", async () => { + vi.mocked(networking.getAgentInfo).mockResolvedValue({ + ...LANGGRAPH_AGENT, + litellm_params: { ...LANGGRAPH_AGENT.litellm_params, api_key: "REDACTED_BY_LITELM" }, + } as never); + const user = setup(); + renderView(); + await openEditor(user); + expect(screen.getByRole("combobox", { name: "Authentication" })).toHaveTextContent("Bearer Token"); + expect( + JSON.parse(screen.getByTestId("agent-card-discovery").getAttribute("data-plan") ?? "null"), + ).not.toHaveProperty("api_key"); + + fireEvent.change(screen.getByLabelText("Authentication Value"), { target: { value: "sk-rotated" } }); + await save(user); + + const rotated = { + custom_llm_provider: "langgraph", + api_base: "https://lg.example.com", + model: "langgraph/asst_1", + api_key: "sk-rotated", + }; + + expect(patchedPayload().litellm_params).toEqual(rotated); + }); + + it("clears a saved A2A key on None and keeps the params no open panel rebuilt", async () => { + vi.mocked(networking.getAgentInfo).mockResolvedValue({ + ...A2A_AGENT, + litellm_params: { ...A2A_AGENT.litellm_params, api_key: "REDACTED_BY_LITELM", guardrails: ["pii"] }, + } as never); + const user = setup(); + renderView(); + await openEditor(user); + + await user.click(screen.getByRole("combobox", { name: "Authentication" })); + await user.click(await screen.findByRole("option", { name: "None" })); + await save(user); + + const cleared = { model: "gpt-4o", make_public: true, cost_per_query: 0.5, guardrails: ["pii"], api_key: null }; + + expect(patchedPayload().litellm_params).toEqual(cleared); + }); + + it("never offers Bearer to an agent whose own provider ignores it, and keeps its key on save", async () => { + vi.mocked(networking.getAgentInfo).mockResolvedValue({ + ...A2A_AGENT, + litellm_params: { ...A2A_AGENT.litellm_params, custom_llm_provider: "watsonx", api_key: "REDACTED_BY_LITELM" }, + } as never); + const user = setup(); + renderView(); + await openEditor(user); + + expect(screen.queryByRole("combobox", { name: "Authentication" })).not.toBeInTheDocument(); + await save(user); + + expect((patchedPayload().litellm_params as Record | undefined)?.api_key).not.toBeNull(); + }); + it("keeps the agent's existing MCP grants in the update payload", async () => { const existingMcpGrants = { mcp_servers: ["srv-1"], diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx index 554116716ea..b6bc983f1fd 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx @@ -51,6 +51,8 @@ import AgentCostView from "./agent_cost_view"; import { detectAgentType, parseDynamicAgentForForm } from "./agent_type_utils"; import AgentCardDiscovery, { DiscoveredAgentCardSelection } from "./agent_card_discovery"; import { buildDiscoveryRequest, overlayDiscoveredCardParams } from "./agent_discovery_utils"; +import AgentAuthSettings from "./AgentAuthSettings"; +import { agentAuthFormValues, agentAuthSupport, withAgentAuth, withoutBearerKeyField } from "./agent_auth"; interface AgentInfoViewProps { agentId: string; @@ -133,6 +135,7 @@ const AgentInfoView: React.FC = ({ agentId, onClose, accessT if (typeInfo) { form.reset({ ...parseDynamicAgentForForm(data, typeInfo), + ...agentAuthFormValues(data), ...parseMcpPermissionsForForm(data), ...parseAccessGroupIdsForForm(data), }); @@ -157,6 +160,7 @@ const AgentInfoView: React.FC = ({ agentId, onClose, accessT if (typeInfo) { form.reset({ ...parseDynamicAgentForForm(agent, typeInfo), + ...agentAuthFormValues(agent), ...parseMcpPermissionsForForm(agent), ...parseAccessGroupIdsForForm(agent), }); @@ -165,7 +169,17 @@ const AgentInfoView: React.FC = ({ agentId, onClose, accessT } }, [agentTypeMetadata, agent]); - const selectedAgentTypeInfo = agentTypeMetadata.find((info) => info.agent_type === detectedAgentType); + const agentTypeInfo = agentTypeMetadata.find((info) => info.agent_type === detectedAgentType); + const usesA2AFormFields = agentTypeInfo?.use_a2a_form_fields; + const savedParams = agent?.litellm_params; + const authSupport = useMemo( + () => agentAuthSupport(detectedAgentType, usesA2AFormFields, savedParams), + [detectedAgentType, usesA2AFormFields, savedParams], + ); + const selectedAgentTypeInfo = useMemo( + () => withoutBearerKeyField(agentTypeInfo, authSupport.bearer), + [agentTypeInfo, authSupport.bearer], + ); const watchedFormValues = useWatch({ control: form.control }); const mcpSelection = useWatch({ control: form.control, name: "allowed_mcp_servers_and_groups" }); const mcpToolPermissions = useWatch({ control: form.control, name: "mcp_tool_permissions" }); @@ -233,9 +247,14 @@ const AgentInfoView: React.FC = ({ agentId, onClose, accessT : unmountedA2AFieldNames(panels.mountedPanels), ); - const built: AgentRequestPayload = usesDynamicFields - ? { ...buildDynamicAgentData(values, selectedAgentTypeInfo, agent), agent_name: values.agent_name } - : buildAgentDataFromForm(values, agent); + const built: AgentRequestPayload = withAgentAuth( + usesDynamicFields + ? { ...buildDynamicAgentData(values, selectedAgentTypeInfo, agent), agent_name: values.agent_name } + : buildAgentDataFromForm(values, agent), + values, + authSupport, + agent, + ); const updateData = appliedDiscoveredSelection ? overlayDiscoveredCardParams(built, appliedDiscoveredSelection.selected_card) @@ -553,6 +572,8 @@ const AgentInfoView: React.FC = ({ agentId, onClose, accessT + + {discoveryRequest && (
.", + "authTypeNoneHint": "No token is saved here. Each caller can send its own in x-a2a-{{agentName}}-authorization: Bearer .", + "authValue": "Authentication Value", + "authValueHint": "The token LiteLLM sends to the agent", + "authValuePlaceholder": "Token", + "authValueRequired": "Please enter the token", "staticHeaders": "Static Headers", "staticHeadersHint": "Headers always sent to the backend agent, regardless of the client request. Admin-configured, static wins on conflict.", "forwardHeaders": "Forward Client Headers",