mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Merge d6652f1b6d into 8a334c6709
This commit is contained in:
commit
dafa44117e
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