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",