This commit is contained in:
shrey 2026-11-04 17:24:39 +00:00 • committed by GitHub
commit dafa44117e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 626 additions and 158 deletions

View file

@ -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<AgentFormValues>();
const { fields, append, remove } = useFieldArray({ control, name: "static_headers" });
return (
<>
{fields.map((item, index) => (
<div key={item.id} className="flex items-start gap-2">
<AgentFormField
name={`static_headers.${index}.header`}
rules={{ required: t("agents.formFields.staticHeaders.errors.nameRequired") }}
>
{({ value, onChange, ref, ...control }) => (
<Input
{...control}
ref={ref}
className="w-55"
placeholder={t("agents.formFields.staticHeaders.namePlaceholder")}
value={typeof value === "string" ? value : ""}
onChange={onChange}
/>
)}
</AgentFormField>
<AgentFormField
name={`static_headers.${index}.value`}
rules={{ required: t("agents.formFields.staticHeaders.errors.valueRequired") }}
>
{({ value, onChange, ref, ...control }) => (
<Input
{...control}
ref={ref}
className="w-65"
placeholder={t("agents.formFields.staticHeaders.valuePlaceholder")}
value={typeof value === "string" ? value : ""}
onChange={onChange}
/>
)}
</AgentFormField>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("agents.formFields.staticHeaders.remove")}
className="text-destructive hover:text-destructive/80"
onClick={() => remove(index)}
>
<Trash2 />
</Button>
</div>
))}
<Button type="button" variant="outline" className="w-full border-dashed" onClick={() => append({})}>
<Plus />
{t("agents.formFields.staticHeaders.add")}
</Button>
</>
);
};
const AgentAuthSettings = ({ support }: { support: AgentAuthSupport }) => {
const { t } = useTranslation();
const { control } = useFormContext<AgentFormValues>();
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 : "<agent-name>",
});
return (
<Collapsible defaultOpen className="mb-4 rounded-md border border-border px-4">
<CollapsibleTrigger className="group flex w-full items-center justify-between gap-4 py-3 text-left">
<span className="text-sm font-semibold text-foreground">{t("agents.formFields.authHeaders.title")}</span>
<ChevronDown className="size-4 text-muted-foreground transition-transform group-data-[panel-open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent keepMounted>
<FieldGroup className="pb-5">
{support.bearer && (
<AgentFormField
name="auth_type"
label={t("agents.formFields.authHeaders.authType")}
defaultValue={AUTH_TYPE.NONE}
description={authTypeHint}
>
{({ value, onChange, ...control }) => (
<Select
items={AGENT_AUTH_TYPE_ITEMS}
value={typeof value === "string" ? value : AUTH_TYPE.NONE}
onValueChange={(next) => next !== null && onChange(next)}
>
<SelectTrigger id={control.id} aria-invalid={control["aria-invalid"]} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AGENT_AUTH_TYPE_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</AgentFormField>
)}
{support.bearer && authType === AUTH_TYPE.BEARER_TOKEN && (
<AgentFormField
name="api_key"
label={labelWithHint(
t("agents.formFields.authHeaders.authValue"),
t("agents.formFields.authHeaders.authValueHint"),
)}
rules={{
validate: (value, values) =>
isAuthValueMissing(value, values) ? t("agents.formFields.authHeaders.authValueRequired") : true,
}}
>
{({ value, onChange, ref, ...control }) => (
<PasswordInput
{...control}
ref={ref}
value={typeof value === "string" ? value : ""}
onChange={onChange}
placeholder={t("agents.formFields.authHeaders.authValuePlaceholder")}
/>
)}
</AgentFormField>
)}
{support.headers && (
<>
<Field>
<FieldTitle>
{labelWithHint(
t("agents.formFields.authHeaders.staticHeaders"),
t("agents.formFields.authHeaders.staticHeadersHint"),
)}
</FieldTitle>
<div className="flex flex-col gap-2">
<StaticHeadersFieldArray />
</div>
</Field>
<AgentFormField
name="extra_headers"
label={labelWithHint(
t("agents.formFields.authHeaders.forwardHeaders"),
t("agents.formFields.authHeaders.forwardHeadersHint"),
)}
>
{({ id, value, onChange }) => (
<AgentTagsInput
id={id}
value={Array.isArray(value) ? (value as string[]) : []}
onValueChange={onChange}
placeholder={t("agents.formFields.authHeaders.forwardHeadersPlaceholder")}
/>
)}
</AgentFormField>
</>
)}
</FieldGroup>
</CollapsibleContent>
</Collapsible>
);
};
export default AgentAuthSettings;

View file

@ -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;

View file

@ -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);

View file

@ -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<AddAgentFormProps> = ({ 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<AddAgentFormProps> = ({ 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<AddAgentFormProps> = ({ visible, onClose, accessTok
discover, so we skip them. */}
{agentType !== CUSTOM_AGENT_TYPE && (
<div className="mt-4">
<AgentAuthSettings support={authSupport} />
<AgentCardDiscovery
accessToken={accessToken}
onApply={handleApplyDiscoveredCard}

View file

@ -0,0 +1,196 @@
import { describe, expect, it } from "vitest";
import {
REDACTED_SECRET,
agentAuthFormValues,
agentAuthSupport,
discoveryAuth,
isAuthValueMissing,
withAgentAuth,
} from "./agent_auth";
const BEARER_AND_HEADERS = { bearer: true, headers: true };
const HEADERS_ONLY = { bearer: false, headers: true };
const NO_AUTH_SECTION = { bearer: false, headers: false };
describe("agentAuthSupport", () => {
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<string, string> | 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);
});
});

View file

@ -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<string, unknown> | 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<string, unknown> | null;
static_headers?: Record<string, string> | 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<string, string> =>
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<string, string>, b: Record<string, string>): 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<string, string>;
}
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());

View file

@ -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<string, string> => {
const staticHeaders: Record<string, string> = {};
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<string, string>).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),

View file

@ -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");
});

View file

@ -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<string, any>;
display_url?: string;
api_key?: string;
static_headers?: Record<string, string>;
}
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,
};
}

View file

@ -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<Record<string, readonly string[]>> = {
@ -32,7 +30,6 @@ export const A2A_PANEL_FIELD_NAMES: Readonly<Record<string, readonly string[]>>
[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<AgentFormValues>();
const { fields, append, remove } = useFieldArray({ control, name: "static_headers" });
return (
<>
{fields.map((item, index) => (
<div key={item.id} className="flex items-start gap-2">
<AgentFormField
name={`static_headers.${index}.header`}
rules={{ required: t("agents.formFields.staticHeaders.errors.nameRequired") }}
>
{({ value, onChange, ref, ...control }) => (
<Input
{...control}
ref={ref}
className="w-55"
placeholder={t("agents.formFields.staticHeaders.namePlaceholder")}
value={typeof value === "string" ? value : ""}
onChange={onChange}
/>
)}
</AgentFormField>
<AgentFormField
name={`static_headers.${index}.value`}
rules={{ required: t("agents.formFields.staticHeaders.errors.valueRequired") }}
>
{({ value, onChange, ref, ...control }) => (
<Input
{...control}
ref={ref}
className="w-65"
placeholder={t("agents.formFields.staticHeaders.valuePlaceholder")}
value={typeof value === "string" ? value : ""}
onChange={onChange}
/>
)}
</AgentFormField>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("agents.formFields.staticHeaders.remove")}
className="text-destructive hover:text-destructive/80"
onClick={() => remove(index)}
>
<Trash2 />
</Button>
</div>
))}
<Button type="button" variant="outline" className="w-full border-dashed" onClick={() => append({})}>
<Plus />
{t("agents.formFields.staticHeaders.add")}
</Button>
</>
);
};
const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ panels, showAgentName = true, visiblePanels }) => {
const { t } = useTranslation();
const shouldShow = (key: string) => !visiblePanels || visiblePanels.includes(key);
@ -391,43 +329,6 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ panels, showAgentName
</AgentFormPanel>
)}
{shouldShow(AUTH_HEADERS_PANEL_KEY) && (
<AgentFormPanel
panelKey={AUTH_HEADERS_PANEL_KEY}
title={t("agents.formFields.authHeaders.title")}
panels={panels}
>
<Field>
<FieldTitle>
{labelWithHint(
t("agents.formFields.authHeaders.staticHeaders"),
t("agents.formFields.authHeaders.staticHeadersHint"),
)}
</FieldTitle>
<div className="flex flex-col gap-2">
<StaticHeadersFieldArray />
</div>
</Field>
<AgentFormField
name="extra_headers"
label={labelWithHint(
t("agents.formFields.authHeaders.forwardHeaders"),
t("agents.formFields.authHeaders.forwardHeadersHint"),
)}
>
{({ id, value, onChange }) => (
<AgentTagsInput
id={id}
value={Array.isArray(value) ? (value as string[]) : []}
onValueChange={onChange}
placeholder={t("agents.formFields.authHeaders.forwardHeadersPlaceholder")}
/>
)}
</AgentFormField>
</AgentFormPanel>
)}
{shouldShow(KILL_SWITCH_PANEL_KEY) && (
<AgentFormPanel panelKey={KILL_SWITCH_PANEL_KEY} title={t("agents.formFields.killSwitch")} panels={panels}>
<KillSwitchFormFields />

View file

@ -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: () => <div data-testid="agent-card-discovery" /> }));
vi.mock("./agent_card_discovery", () => ({
default: ({ discoveryRequest }: { discoveryRequest?: object }) => (
<div data-testid="agent-card-discovery" data-plan={JSON.stringify(discoveryRequest ?? null)} />
),
}));
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<string, unknown> | undefined)?.api_key).not.toBeNull();
});
it("keeps the agent's existing MCP grants in the update payload", async () => {
const existingMcpGrants = {
mcp_servers: ["srv-1"],

View file

@ -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<AgentInfoViewProps> = ({ agentId, onClose, accessT
if (typeInfo) {
form.reset({
...parseDynamicAgentForForm(data, typeInfo),
...agentAuthFormValues(data),
...parseMcpPermissionsForForm(data),
...parseAccessGroupIdsForForm(data),
});
@ -157,6 +160,7 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
if (typeInfo) {
form.reset({
...parseDynamicAgentForForm(agent, typeInfo),
...agentAuthFormValues(agent),
...parseMcpPermissionsForForm(agent),
...parseAccessGroupIdsForForm(agent),
});
@ -165,7 +169,17 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ 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<AgentInfoViewProps> = ({ 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<AgentInfoViewProps> = ({ agentId, onClose, accessT
<AgentIdentityFields accessToken={accessToken} />
<AgentAuthSettings support={authSupport} />
{discoveryRequest && (
<div className="mt-4">
<AgentCardDiscovery

View file

@ -212,7 +212,14 @@
},
"requiredSection": "{{section}} (Required)",
"authHeaders": {
"title": "Authentication Headers",
"title": "Authentication settings",
"authType": "Authentication",
"authTypeBearerHint": "Saved in LiteLLM and sent to the agent as Authorization: Bearer <token>.",
"authTypeNoneHint": "No token is saved here. Each caller can send its own in x-a2a-{{agentName}}-authorization: Bearer <token>.",
"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",