mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
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.
This commit is contained in:
parent
8a334c6709
commit
d6652f1b6d
13 changed files with 626 additions and 158 deletions
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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());
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue