diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityDetails.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityDetails.test.tsx
new file mode 100644
index 00000000000..ce54ab78d9c
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityDetails.test.tsx
@@ -0,0 +1,43 @@
+import { screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { renderWithProviders, testQueryClient } from "../../../../../tests/test-utils";
+import { apiClient } from "@/components/networking";
+import { AgentIdentityDetails } from "./AgentIdentityDetails";
+
+vi.mock("@/components/networking", () => ({ apiClient: { get: vi.fn() } }));
+
+const identity = {
+ provider: "microsoft_entra",
+ tenant_id: "11111111-1111-4111-8111-111111111111",
+ client_id: "22222222-2222-4222-8222-222222222222",
+};
+
+const status = {
+ enabled: true,
+ execution_mode: "autonomous",
+ last_authenticated_at: "2026-09-24T12:00:00Z",
+};
+
+describe("agent identity evidence", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ testQueryClient.clear();
+ });
+
+ it("shows persisted application identity evidence and links to the current logs route", async () => {
+ vi.mocked(apiClient.get).mockResolvedValue(status);
+ renderWithProviders( );
+ expect(await screen.findByText(/Last authenticated identity match:/)).toBeInTheDocument();
+ expect(screen.getByText(/Application \(Client\) ID:/)).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "View request logs" })).toHaveAttribute("href", "/ui/logs/");
+ expect(apiClient.get).toHaveBeenCalledWith("/v1/agents/native/identity", { accessToken: "admin" });
+ });
+
+ it("does not request or show administrator identity evidence to ordinary users", () => {
+ renderWithProviders(
+ ,
+ );
+ expect(screen.queryByRole("region", { name: "Agent Identity" })).not.toBeInTheDocument();
+ expect(apiClient.get).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityDetails.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityDetails.tsx
new file mode 100644
index 00000000000..12465c6e861
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityDetails.tsx
@@ -0,0 +1,81 @@
+import React from "react";
+import type { components } from "@/lib/http/schema";
+import { useQuery } from "@tanstack/react-query";
+import { apiClient } from "@/components/networking";
+import { Button } from "@/components/ui/button";
+import { readAgentIdentity } from "./agent_identity";
+
+const authenticationMessage = (error: boolean, lastAuthenticated?: string | null): string => {
+ if (error) return "Could not load authentication evidence";
+ if (lastAuthenticated) return `Last authenticated identity match: ${new Date(lastAuthenticated).toLocaleString()}`;
+ return "Configured, awaiting an authenticated request";
+};
+
+export const AgentIdentityDetails = ({
+ agentId,
+ identity: value,
+ accessToken,
+ isAdmin,
+}: {
+ agentId: string;
+ identity: unknown;
+ accessToken: string | null;
+ isAdmin: boolean;
+}) => {
+ const identity = readAgentIdentity(value);
+ const { data, isError, isFetching, refetch } = useQuery({
+ queryKey: ["agent-identity", agentId, identity],
+ queryFn: () =>
+ apiClient.get(
+ `/v1/agents/${encodeURIComponent(agentId)}/identity`,
+ {
+ accessToken: accessToken ?? "",
+ },
+ ),
+ enabled: Boolean(isAdmin && accessToken && identity),
+ });
+
+ if (!identity || !isAdmin) return null;
+ const executionLabel = data?.enabled ? "Enabled" : "Disabled";
+ return (
+
+ Agent Identity: Microsoft Entra ID
+
+ Tenant: {identity.tenant_id}
+
+ <>
+
+ Application (Client) ID: {identity.client_id}
+
+ Enterprise application Object ID: {identity.service_principal_id || "Not configured"}
+ >
+
+ Execution: {data ? executionLabel : "Loading"} · Mode: {data?.execution_mode ?? "Loading"}
+
+
+ {data?.identity?.active === false
+ ? "Identity unbound; execution is disabled"
+ : authenticationMessage(isError, data?.last_authenticated_at)}
+
+
+ Recent evidence comes from a validated Entra token matching this binding. It is persisted across restarts and
+ cleared when the binding changes. Tool and model permissions are checked separately.
+
+
+
+ );
+};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityFields.tsx
new file mode 100644
index 00000000000..43538784145
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityFields.tsx
@@ -0,0 +1,259 @@
+import React, { useEffect, useState } from "react";
+import { useWatch } from "react-hook-form";
+import { apiClient } from "@/components/networking";
+import { Input } from "@/components/ui/input";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { AgentFormField, type AgentFormValues } from "./AgentFormKit";
+import { entraTenantFromIssuer, IDENTITY_UUID_PATTERN } from "./agent_identity";
+
+const PROVIDER_OPTIONS = [
+ { value: "none", label: "No explicit identity binding" },
+ { value: "microsoft_entra", label: "Microsoft Entra ID" },
+];
+const EXECUTION_MODE_OPTIONS = [
+ { value: "autonomous", label: "Autonomous" },
+ { value: "delegated", label: "On behalf of a user" },
+ { value: "both", label: "Both" },
+];
+const EXECUTION_OPTIONS = [
+ { value: "enabled", label: "Enabled" },
+ { value: "disabled", label: "Disabled" },
+];
+
+export const AgentIdentityFields = ({ accessToken }: { accessToken: string | null }) => {
+ const provider = useWatch({ name: "identity_provider" });
+ const mode = useWatch({ name: "execution_mode" });
+ const showScopes = mode !== "autonomous" && mode !== undefined;
+ const [tenants, setTenants] = useState([]);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!accessToken || provider !== "microsoft_entra") return;
+ let active = true;
+ apiClient
+ .get("/v1/agents/identity/providers", { accessToken })
+ .then((issuers) => {
+ if (active)
+ setTenants(
+ issuers.flatMap((issuer) => {
+ const tenant = entraTenantFromIssuer(issuer);
+ return tenant ? [tenant] : [];
+ }),
+ );
+ })
+ .catch(() => {
+ if (active) setError("Could not load the gateway's trusted identity providers");
+ });
+ return () => {
+ active = false;
+ };
+ }, [accessToken, provider]);
+
+ return (
+ <>
+
+
+
Agent Identity
+
+ Connect an existing identity provider application to this agent. Its name and runtime address can change
+ independently.
+
+
+
+ {({ value, onChange, id }) => (
+
+
+
+
+
+ {PROVIDER_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ )}
+
+ {provider === "microsoft_entra" && (
+ <>
+
+ {({ value, onChange, id }) => (
+
+
+
+
+
+ {tenants.map((tenant) => (
+
+ {tenant}
+
+ ))}
+
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+ {!error && tenants.length === 0 && (
+
+ No trusted Entra tenant is available. Configure JWT issuer and audience validation on the gateway first.
+ Dashboard Microsoft SSO is configured separately.
+
+ )}
+
+ Find this under{" "}
+
+ Entra App registrations
+
+ , select your agent application, then Overview. No client secret is required here.
+ >
+ }
+ >
+ {({ value, onChange, ref, ...control }) => (
+
+ )}
+
+
+ {({ value, onChange, id }) => (
+
+
+
+
+
+ {EXECUTION_MODE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ )}
+
+
+ Open{" "}
+
+ Entra Enterprise applications
+
+ , select this application, and copy its Object ID. The App registrations Object ID is a different
+ value.
+ >
+ }
+ >
+ {({ value, onChange, ref, ...control }) => (
+
+ )}
+
+
+
+ {({ value, onChange, ref, ...control }) => (
+
+ )}
+
+
+ {showScopes && (
+ <>
+
+ {({ value, onChange, ref, ...control }) => (
+
+ )}
+
+
+ Users must first sign in through this gateway's Microsoft SSO. Subsequent delegated calls must
+ satisfy both user and agent permissions.
+
+ >
+ )}
+
+ {({ value, onChange, id }) => (
+ onChange(next === "enabled")}
+ >
+
+
+
+
+ {EXECUTION_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ )}
+
+
+ LiteLLM verifies the agent's Entra token before matching this identity. Saving these fields
+ configures the binding; an authenticated request provides verification. Runtime authentication headers are
+ configured separately.
+
+ >
+ )}
+
+ >
+ );
+};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsPanel.tsx
index f53b03b6a08..b392e270d33 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsPanel.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsPanel.tsx
@@ -145,10 +145,10 @@ const AgentsPanel: React.FC = ({ accessToken, userRole, teams
- Why do agents need keys?
+ How do agents authenticate?
- Keys scope access to an agent and allow it to call MCP tools. Assign a key when creating an agent or from
- the Virtual Keys page.
+ Agents can authenticate with a virtual key or a trusted identity provider using JWT. Configure an identity
+ binding when adding or editing an agent. JWT authentication does not require a virtual key.
{isAdmin && (
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.test.tsx
index bef938cd31c..95fcb516c55 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.test.tsx
@@ -62,6 +62,12 @@ describe("AgentsTable", () => {
expect(within(keylessRow).getByText("Needs Setup")).toBeInTheDocument();
});
+ it("shows JWT configured for agents without a virtual key", () => {
+ render( );
+ expect(screen.getByText("JWT configured")).toBeInTheDocument();
+ expect(screen.queryByText("Needs Setup")).not.toBeInTheDocument();
+ });
+
it("deletes an agent through the ⋯ actions menu", async () => {
const user = userEvent.setup();
const onDeleteClick = vi.fn();
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTableColumns.tsx
index a8fe3973a42..932bd5c513f 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTableColumns.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTableColumns.tsx
@@ -136,6 +136,7 @@ export const getAgentsTableColumns = ({
enableSorting: false,
cell: ({ row }) => {
const hasKeys = (row.original.keys?.length ?? 0) > 0;
+ if (row.original.jwt_auth_configured) return ;
return hasKeys ? (
) : (
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx
index 457ee656415..b7585e8f8b2 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import { screen, waitFor, within } from "@testing-library/react";
+import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import AddAgentForm from "./add_agent_form";
@@ -8,6 +8,7 @@ import type { AgentCreateInfo } from "@/components/networking";
import { chooseSelectOption, renderWithProviders as render } from "../../../../../tests/test-utils";
vi.mock("@/components/networking", () => ({
+ apiClient: { get: vi.fn() },
createAgentCall: vi.fn(),
getAgentCreateMetadata: vi.fn(),
getAgentsList: vi.fn(),
@@ -95,6 +96,51 @@ describe("AddAgentForm submit payload", () => {
.mockResolvedValue({} as never);
});
+ it("registers a readable agent with an explicit Entra identity and no virtual key", async () => {
+ const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
+ const tenant = "11111111-1111-4111-8111-111111111111";
+ const clientId = "22222222-2222-4222-8222-222222222222";
+ vi.mocked(networking.apiClient.get).mockResolvedValue([`https://login.microsoftonline.com/${tenant}/v2.0`]);
+ renderForm();
+ fireEvent.change(await screen.findByLabelText("Agent Name"), { target: { value: "Readable agent" } });
+ fireEvent.change(screen.getByLabelText("URL"), { target: { value: "https://runtime.example/a2a" } });
+ fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: "Readable agent" } });
+ fireEvent.change(screen.getByPlaceholderText("Describe what this agent does..."), {
+ target: { value: "Test agent" },
+ });
+ await user.click(screen.getByLabelText("Identity Provider"));
+ await user.click(await screen.findByRole("option", { name: "Microsoft Entra ID" }));
+ await user.click(screen.getByLabelText("Trusted Entra Tenant"));
+ await user.click(await screen.findByRole("option", { name: tenant }));
+ fireEvent.change(screen.getByLabelText("Application (Client) ID"), { target: { value: clientId } });
+ fireEvent.change(screen.getByLabelText("Enterprise Application Object ID"), {
+ target: { value: "33333333-3333-4333-8333-333333333333" },
+ });
+ await user.click(screen.getByRole("button", { name: /^Next/ }));
+ await user.click(screen.getByRole("button", { name: /^Next/ }));
+ await user.click(screen.getByRole("button", { name: /^Next/ }));
+ await user.click(screen.getByRole("button", { name: "Use Entra JWT authentication" }));
+ await user.click(screen.getByRole("button", { name: /Create Agent/ }));
+ await waitFor(() => expect(networking.createAgentCall).toHaveBeenCalledTimes(1));
+ expect(createdPayload().agent_name).toBe("Readable agent");
+ const expectedIdentity = {
+ provider: "microsoft_entra",
+ tenant_id: tenant,
+ client_id: clientId,
+ service_principal_id: "33333333-3333-4333-8333-333333333333",
+ required_roles: [],
+ required_scopes: ["user_impersonation"],
+ };
+ expect(createdPayload().identity).toEqual(expectedIdentity);
+ expect(createdPayload()).not.toHaveProperty("litellm_params.identity");
+ expect(networking.keyCreateForAgentCall).not.toHaveBeenCalled();
+ expect(
+ screen.getByText(
+ "Microsoft Entra ID is configured. Send an authenticated agent request to verify the connection.",
+ ),
+ ).toBeInTheDocument();
+ });
+
it("sends every a2a field the user filled across all collapsible panels", async () => {
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
renderForm();
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx
index fbf5cf8c1fb..5e8ba145396 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx
@@ -83,7 +83,9 @@ describe("AddAgentForm logos", () => {
expect(titleLogo).toBeInstanceOf(HTMLImageElement);
expect(titleLogo).toHaveAttribute("src", expect.stringContaining("assets/logos/a2a_agent.png"));
- const selectionLogo = within(await screen.findByRole("combobox")).getByAltText("A2A Agent logo");
+ const selectionLogo = within(await screen.findByRole("combobox", { name: "Agent Type" })).getByAltText(
+ "A2A Agent logo",
+ );
expect(selectionLogo).toBeInstanceOf(HTMLImageElement);
expect(selectionLogo).toHaveAttribute("src", expect.stringContaining("assets/logos/a2a_agent.png"));
});
@@ -93,14 +95,14 @@ describe("AddAgentForm logos", () => {
await screen.findByAltText("A2A Agent logo");
- expect(screen.getByLabelText("Agent Type")).toBe(screen.getByRole("combobox"));
+ expect(screen.getByLabelText("Agent Type")).toBe(screen.getByRole("combobox", { name: "Agent Type" }));
});
it("renders the option logo when the agent type dropdown is opened", async () => {
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
renderForm();
- const trigger = await screen.findByRole("combobox");
+ const trigger = await screen.findByRole("combobox", { name: "Agent Type" });
await within(trigger).findByAltText("A2A Agent logo");
await user.click(trigger);
@@ -123,7 +125,7 @@ describe("AddAgentForm logos", () => {
expect(screen.queryByAltText("Agent logo")).not.toBeInTheDocument();
expect(within(header).getByText("A")).toBeInTheDocument();
- const trigger = screen.getByRole("combobox");
+ const trigger = screen.getByRole("combobox", { name: "Agent Type" });
fireEvent.error(within(trigger).getByAltText("A2A Agent logo"));
expect(within(trigger).queryByAltText("A2A Agent logo")).not.toBeInTheDocument();
expect(warnSpy).toHaveBeenCalledTimes(2);
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx
index 5bd6ea9b83a..b243d9d1601 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx
@@ -1,3 +1,5 @@
+import { AgentIdentityFields } from "./AgentIdentityFields";
+import { withAgentIdentity } from "./agent_identity";
import React, { useState, useEffect } from "react";
import { FormProvider, useForm, useWatch } from "react-hook-form";
import { toast } from "@/lib/toast";
@@ -287,6 +289,7 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok
const buildAgentData = (values: AgentFormValues): AgentRequestPayload | null => {
if (agentType === CUSTOM_AGENT_TYPE) {
+ if (values.identity_provider === "microsoft_entra") return { agent_name: values.agent_name };
return {
agent_name: values.agent_name,
agent_card_params: {
@@ -353,12 +356,13 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok
return;
}
const values = form.getValues();
- const agentData = buildAgentData(values);
- if (!agentData) {
+ const built = buildAgentData(values);
+ if (!built) {
toast.error("Failed to build agent data");
setIsSubmitting(false);
return;
}
+ const agentData = withAgentIdentity(built, values);
// Build object_permission from MCP Tools step (allowed_mcp_servers_and_groups, mcp_tool_permissions)
const mcpServersAndGroups = values.allowed_mcp_servers_and_groups ?? {};
@@ -792,7 +796,7 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok
- For agents that don't follow a standard protocol, just needs a virtual key
+ For outbound agents using an identity provider or virtual key
@@ -801,6 +805,8 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok
+
+
{agentType === CUSTOM_AGENT_TYPE ? (
@@ -910,7 +916,7 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok
name="team_id"
label={labelWithHint(
"Assign to Team",
- "Optionally assign this agent to a team. The agent and its key will belong to the selected team.",
+ "Optionally select a team for the virtual key. The agent identity and its permissions are managed separately.",
)}
>
{({ value, onChange }) => (
@@ -920,6 +926,11 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok
+ {form.getValues("identity_provider") === "microsoft_entra" && (
+
+ This agent will authenticate with Microsoft Entra ID. You can skip virtual key creation.
+
+ )}
setKeyAssignOption(value as "create_new" | "existing_key" | "skip")}
@@ -1004,7 +1015,9 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok
className="text-sm text-muted-foreground underline hover:text-foreground"
onClick={() => setKeyAssignOption("skip")}
>
- Skip for now — I'll assign a key later
+ {form.getValues("identity_provider") === "microsoft_entra"
+ ? "Use Entra JWT authentication"
+ : "Skip for now, I’ll assign a key later"}
@@ -1033,7 +1046,9 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok
)}
{!createdKeyValue && !assignedKeyAlias && keyAssignOption === "skip" && (
- No key assigned. You can create one from the Virtual Keys page.
+ {form.getValues("identity_provider") === "microsoft_entra"
+ ? "Microsoft Entra ID is configured. Send an authenticated agent request to verify the connection."
+ : "No key assigned. You can create one from the Virtual Keys page."}
)}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_config.ts b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_config.ts
index 16ce6848402..6ec3c3181f7 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_config.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_config.ts
@@ -1,3 +1,4 @@
+import { parseIdentityForForm } from "./agent_identity";
/**
* Shared configuration for agent form fields
* Used across create, view, and update operations
@@ -57,7 +58,7 @@ export const AGENT_FORM_CONFIG: {
name: "description",
label: "Description",
type: "textarea",
- required: true,
+ required: false,
placeholder: "Describe what this agent does...",
rows: 3,
},
@@ -340,6 +341,7 @@ export const parseAccessGroupIdsForForm = (agent: { access_group_ids?: string[]
});
export const parseMcpPermissionsForForm = (agent: any) => ({
+ ...parseIdentityForForm(agent),
allowed_mcp_servers_and_groups: {
servers: agent.object_permission?.mcp_servers ?? [],
accessGroups: agent.object_permission?.mcp_access_groups ?? [],
@@ -363,8 +365,9 @@ export const buildMcpObjectPermission = (values: any) => ({
* Parse agent data for form fields
*/
export const parseAgentForForm = (agent: any) => {
+ const card = agent.agent_card_params ?? {};
const skills =
- agent.agent_card_params?.skills?.map((skill: any) => ({
+ card.skills?.map((skill: any) => ({
...skill,
tags: skill.tags,
examples: skill.examples || [],
@@ -372,18 +375,18 @@ export const parseAgentForForm = (agent: any) => {
return {
agent_name: agent.agent_name,
- name: agent.agent_card_params?.name,
- description: agent.agent_card_params?.description,
- url: agent.agent_card_params?.url,
- version: agent.agent_card_params?.version,
- protocolVersion: agent.agent_card_params?.protocolVersion,
- streaming: agent.agent_card_params?.capabilities?.streaming,
- pushNotifications: agent.agent_card_params?.capabilities?.pushNotifications,
- stateTransitionHistory: agent.agent_card_params?.capabilities?.stateTransitionHistory,
+ name: card.name || agent.agent_name,
+ description: card.description,
+ url: card.url,
+ version: card.version,
+ protocolVersion: card.protocolVersion,
+ streaming: card.capabilities?.streaming,
+ pushNotifications: card.capabilities?.pushNotifications,
+ stateTransitionHistory: card.capabilities?.stateTransitionHistory,
skills: skills,
- iconUrl: agent.agent_card_params?.iconUrl,
- documentationUrl: agent.agent_card_params?.documentationUrl,
- supportsAuthenticatedExtendedCard: agent.agent_card_params?.supportsAuthenticatedExtendedCard,
+ iconUrl: card.iconUrl,
+ documentationUrl: card.documentationUrl,
+ supportsAuthenticatedExtendedCard: card.supportsAuthenticatedExtendedCard,
model: agent.litellm_params?.model,
make_public: agent.litellm_params?.make_public,
cost_per_query: agent.litellm_params?.cost_per_query,
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_identity.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_identity.test.ts
new file mode 100644
index 00000000000..0639e7a6dd4
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_identity.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildIdentityParams,
+ entraTenantFromIssuer,
+ parseIdentityForForm,
+ readAgentIdentity,
+ withAgentIdentity,
+} from "./agent_identity";
+
+const identity = {
+ provider: "microsoft_entra",
+ tenant_id: "11111111-1111-4111-8111-111111111111",
+ client_id: "22222222-2222-4222-8222-222222222222",
+ service_principal_id: "33333333-3333-4333-8333-333333333333",
+ required_roles: ["Agent.Invoke"],
+ required_scopes: ["user_impersonation"],
+} satisfies import("./agent_identity").EntraAgentIdentity;
+
+describe("agent identity configuration", () => {
+ it("round trips an existing binding independently of the agent name and runtime", () => {
+ const values = {
+ ...parseIdentityForForm({
+ identity: { ...identity, agent_id: "stable", active: true, revision: "rev", issuer: "https://issuer.example" },
+ }),
+ agent_name: "Renamed",
+ url: "https://new-runtime.example",
+ };
+ expect(buildIdentityParams(values)).toEqual({ identity });
+ });
+ it("preserves untouched bindings and explicitly clears a removed binding", () => {
+ expect(buildIdentityParams({ agent_name: "legacy" })).toEqual({});
+ expect(buildIdentityParams({ identity_provider: "none" }, identity)).toEqual({ identity: null });
+ expect(parseIdentityForForm({}).identity_provider).toBe("none");
+ });
+ it.each([
+ null,
+ {},
+ "invalid",
+ { ...identity, client_id: "bad" },
+ { ...identity, tenant_id: 3 },
+ { ...identity, provider: "other" },
+ ])("rejects malformed bindings: %j", (value) => {
+ expect(readAgentIdentity(value)).toBeNull();
+ });
+ it("rejects incomplete submissions", () => {
+ expect(() => buildIdentityParams({ identity_provider: "microsoft_entra" })).toThrow("Enter valid Entra");
+ });
+ it("submits identity as top-level settings without changing runtime parameters", () => {
+ const formValues = {
+ identity_provider: "microsoft_entra",
+ identity_tenant_id: identity.tenant_id,
+ identity_client_id: identity.client_id,
+ identity_service_principal_id: identity.service_principal_id,
+ execution_mode: "both",
+ enabled: false,
+ };
+ const payload = withAgentIdentity({ litellm_params: { model: "runtime" } }, formValues);
+ expect(payload.litellm_params).toEqual({ model: "runtime" });
+ expect(payload.identity).toMatchObject({
+ client_id: identity.client_id,
+ service_principal_id: identity.service_principal_id,
+ });
+ expect(payload.execution_mode).toBe("both");
+ expect(payload.enabled).toBe(false);
+ });
+ it("requires a service principal for autonomous execution", () => {
+ const values = {
+ identity_provider: "microsoft_entra",
+ identity_tenant_id: identity.tenant_id,
+ identity_client_id: identity.client_id,
+ execution_mode: "autonomous",
+ };
+ expect(() => buildIdentityParams(values)).toThrow("Enterprise application Object ID");
+ });
+
+ it("only offers tenant-specific Microsoft issuers", () => {
+ expect(entraTenantFromIssuer(`https://login.microsoftonline.com/${identity.tenant_id}/v2.0`)).toBe(
+ identity.tenant_id,
+ );
+ expect(entraTenantFromIssuer("https://attacker.example/tenant/v2.0")).toBeNull();
+ expect(entraTenantFromIssuer("https://login.microsoftonline.com/common/v2.0")).toBeNull();
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_identity.ts b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_identity.ts
new file mode 100644
index 00000000000..34986f75679
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_identity.ts
@@ -0,0 +1,101 @@
+import { z } from "zod";
+import type { components } from "@/lib/http/schema";
+import type { AgentFormValues, AgentRequestPayload } from "./AgentFormKit";
+
+export type EntraAgentIdentity = components["schemas"]["EntraIdentityConfig"];
+type AgentIdentityState = Pick;
+
+export const IDENTITY_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+const stringGrants = (fallback: string[]) =>
+ z
+ .unknown()
+ .transform((value) =>
+ Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : fallback,
+ );
+
+const identityShape = {
+ provider: z.literal("microsoft_entra"),
+ tenant_id: z.string().regex(IDENTITY_UUID_PATTERN),
+ client_id: z.string().regex(IDENTITY_UUID_PATTERN),
+ service_principal_id: z.string().regex(IDENTITY_UUID_PATTERN).nullable().default(null),
+ required_roles: stringGrants([]),
+ required_scopes: stringGrants(["user_impersonation"]),
+};
+const identitySchema = z.object(identityShape);
+
+export const readAgentIdentity = (value: unknown): EntraAgentIdentity | null => {
+ const parsed = identitySchema.safeParse(value);
+ return parsed.success ? parsed.data : null;
+};
+
+const identityFormFields = (identity: EntraAgentIdentity | null): AgentFormValues => ({
+ identity_provider: identity?.provider ?? "none",
+ identity_tenant_id: identity?.tenant_id ?? "",
+ identity_client_id: identity?.client_id ?? "",
+ identity_service_principal_id: identity?.service_principal_id ?? "",
+ identity_required_roles: identity?.required_roles?.join(", ") ?? "",
+ identity_required_scopes: identity?.required_scopes?.join(", ") ?? "user_impersonation",
+});
+
+export const parseIdentityForForm = (agent?: Partial | null): AgentFormValues => {
+ const identity = agent?.identity?.active === false ? null : readAgentIdentity(agent?.identity);
+ return {
+ ...identityFormFields(identity),
+ execution_mode: agent?.execution_mode ?? "autonomous",
+ enabled: agent?.enabled ?? true,
+ };
+};
+
+const splitGrants = (value: unknown, fallback: string[]): string[] =>
+ typeof value === "string"
+ ? value
+ .split(",")
+ .map((item) => item.trim())
+ .filter(Boolean)
+ : fallback;
+
+export const buildIdentityParams = (
+ values: AgentFormValues,
+ existingIdentity?: unknown,
+): { identity?: EntraAgentIdentity | null } => {
+ if (values.identity_provider === undefined) return {};
+ if (values.identity_provider !== "microsoft_entra")
+ return readAgentIdentity(existingIdentity) ? { identity: null } : {};
+ const candidate: EntraAgentIdentity = {
+ provider: "microsoft_entra",
+ tenant_id: typeof values.identity_tenant_id === "string" ? values.identity_tenant_id.trim().toLowerCase() : "",
+ client_id: typeof values.identity_client_id === "string" ? values.identity_client_id.trim().toLowerCase() : "",
+ service_principal_id:
+ typeof values.identity_service_principal_id === "string" && values.identity_service_principal_id.trim()
+ ? values.identity_service_principal_id.trim().toLowerCase()
+ : null,
+ required_roles: splitGrants(values.identity_required_roles, []),
+ required_scopes: splitGrants(values.identity_required_scopes, ["user_impersonation"]),
+ };
+ const identity = readAgentIdentity(candidate);
+ if (!identity) throw new Error("Enter valid Entra tenant, application client and service principal IDs");
+ if (values.execution_mode !== "delegated" && !identity.service_principal_id)
+ throw new Error("Autonomous agents require the Enterprise application Object ID");
+ return { identity };
+};
+
+export const entraTenantFromIssuer = (issuer: string): string | null => {
+ const match = /^https:\/\/login\.microsoftonline\.com\/([^/]+)\/v2\.0$/.exec(issuer);
+ return match && IDENTITY_UUID_PATTERN.test(match[1]) ? match[1] : null;
+};
+
+export const withAgentIdentity = (
+ payload: AgentRequestPayload,
+ values: AgentFormValues,
+ existing?: Partial,
+): AgentRequestPayload => {
+ const identityFields = buildIdentityParams(values, existing?.identity);
+ const managed = values.identity_provider === "microsoft_entra" || Boolean(readAgentIdentity(existing?.identity));
+ return {
+ ...payload,
+ ...identityFields,
+ ...(managed && values.execution_mode !== undefined ? { execution_mode: values.execution_mode } : {}),
+ ...(managed && values.enabled !== undefined ? { enabled: values.enabled } : {}),
+ };
+};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.integration.test.tsx
index 37e00766a75..b3ae4dc26ba 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.integration.test.tsx
@@ -8,6 +8,7 @@ import * as networking from "@/components/networking";
import type { AgentCreateInfo } from "@/components/networking";
vi.mock("@/components/networking", () => ({
+ apiClient: { get: vi.fn() },
getAgentInfo: vi.fn(),
patchAgentCall: vi.fn(),
getAgentCreateMetadata: vi.fn(),
@@ -155,6 +156,50 @@ describe("AgentInfoView update payload", () => {
.mockResolvedValue({} as never);
});
+ it.each(["complete", "empty"])("preserves the Entra binding while renaming an agent with a %s card", async (card) => {
+ const user = setup();
+ const identity = {
+ provider: "microsoft_entra",
+ tenant_id: "11111111-1111-4111-8111-111111111111",
+ client_id: "22222222-2222-4222-8222-222222222222",
+ service_principal_id: "33333333-3333-4333-8333-333333333333",
+ };
+ const params = { ...A2A_AGENT.litellm_params, require_trace_id_on_calls_by_agent: true };
+ vi.mocked(networking.getAgentInfo).mockResolvedValue({
+ ...A2A_AGENT,
+ agent_card_params: card === "empty" ? {} : A2A_AGENT.agent_card_params,
+ litellm_params: params,
+ identity: { ...identity, agent_id: "agent-1", issuer: "https://issuer.example", revision: "rev", active: true },
+ identity_managed: true,
+ execution_mode: "autonomous",
+ enabled: true,
+ access_group_ids: ["ag-entra"],
+ } as never);
+ vi.mocked(networking.apiClient.get).mockImplementation(async (path) =>
+ path.endsWith("/providers")
+ ? [`https://login.microsoftonline.com/${identity.tenant_id}/v2.0`]
+ : { last_authenticated_at: null },
+ );
+ renderView();
+ expect(await screen.findByText("Configured, awaiting an authenticated request")).toBeInTheDocument();
+ await openEditor(user);
+ expect(screen.getByLabelText("Application (Client) ID")).toHaveValue(identity.client_id);
+ expect(screen.getByRole("combobox", { name: "Identity Provider" })).toHaveTextContent("Microsoft Entra ID");
+ expect(screen.getByRole("combobox", { name: "Execution Mode" })).toHaveTextContent("Autonomous");
+ expect(screen.getByRole("combobox", { name: "Execution", exact: true })).toHaveTextContent("Enabled");
+ fireEvent.change(screen.getByLabelText("Agent Name"), { target: { value: "Renamed agent" } });
+ await save(user);
+ expect(patchedPayload().agent_name).toBe("Renamed agent");
+ expect(patchedPayload()).not.toHaveProperty("litellm_params");
+ expect(patchedPayload().identity).toMatchObject(identity);
+ expect(patchedPayload().access_group_ids).toEqual(["ag-entra"]);
+ expect(networking.patchAgentCall).toHaveBeenCalledWith(
+ "tok",
+ "agent-1",
+ expect.objectContaining({ agent_name: "Renamed agent" }),
+ );
+ });
+
it("sends only the fields whose panel has been opened, dropping the rest", async () => {
const user = setup();
renderView();
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.test.tsx
index 19b1ee8ca48..fcdc70adf3b 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.test.tsx
@@ -2,6 +2,7 @@ import React from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import AgentInfoView from "./agent_info";
+import AgentFormFields from "./agent_form_fields";
import * as networking from "@/components/networking";
import type { Agent } from "@/components/agents/types";
@@ -16,12 +17,16 @@ vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({
useKeys: () => ({ data: { keys: [] }, isLoading: false, refetch: vi.fn() }),
}));
+vi.mock("./AgentIdentityDetails", () => ({
+ AgentIdentityDetails: () => null,
+}));
+
vi.mock("./agent_card_discovery", () => ({
default: () =>
,
}));
vi.mock("./agent_form_fields", () => ({
- default: () =>
,
+ default: vi.fn(() =>
),
unmountedA2AFieldNames: () => [],
}));
@@ -77,6 +82,9 @@ const agent = {
describe("AgentInfoView settings", () => {
beforeEach(() => {
vi.restoreAllMocks();
+ vi.mocked(AgentFormFields)
+ .mockReset()
+ .mockImplementation(() =>
);
vi.mocked(networking.getAgentInfo).mockReset().mockResolvedValue(agent);
vi.mocked(networking.getAgentCreateMetadata).mockReset().mockResolvedValue([]);
vi.mocked(networking.patchAgentCall).mockReset().mockResolvedValue({});
@@ -104,6 +112,23 @@ describe("AgentInfoView settings", () => {
expect(payload.access_group_ids).toEqual([]);
});
+ it("saves unrelated settings when the existing card has no description", async () => {
+ const actual = await vi.importActual("./agent_form_fields");
+ vi.mocked(AgentFormFields).mockImplementation(actual.default);
+ const { description: _description, ...card } = agent.agent_card_params;
+ vi.mocked(networking.getAgentInfo).mockResolvedValue({ ...agent, agent_card_params: card });
+ render( );
+ fireEvent.click(await screen.findByRole("tab", { name: "Settings" }));
+ fireEvent.click(screen.getByRole("button", { name: "Edit Settings" }));
+ expect(await screen.findByLabelText("Description")).toHaveValue("");
+ fireEvent.change(screen.getByLabelText("TPM Limit"), { target: { value: "42" } });
+ fireEvent.click(screen.getByRole("button", { name: /Save Changes/ }));
+ await waitFor(() => expect(networking.patchAgentCall).toHaveBeenCalledOnce());
+ const [, , payload] = vi.mocked(networking.patchAgentCall).mock.calls[0];
+ expect(payload.tpm_limit).toBe(42);
+ expect(payload.agent_card_params.description).toBe("");
+ });
+
it("sends the newly attached access group in the update payload", async () => {
render( );
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx
index 6e7389a3fa1..d4c05d0ebac 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx
@@ -1,3 +1,6 @@
+import { AgentIdentityFields } from "./AgentIdentityFields";
+import { AgentIdentityDetails } from "./AgentIdentityDetails";
+import { withAgentIdentity } from "./agent_identity";
import React, { useState, useEffect, useMemo } from "react";
import { cx } from "@/lib/cva.config";
import { FormProvider, useForm, useWatch } from "react-hook-form";
@@ -237,7 +240,7 @@ const AgentInfoView: React.FC = ({ agentId, onClose, accessT
: built;
await patchAgentCall(accessToken, agentId, {
- ...updateData,
+ ...withAgentIdentity(updateData, values, agent),
object_permission: buildMcpObjectPermission(values),
access_group_ids: values.access_group_ids ?? [],
});
@@ -337,6 +340,12 @@ const AgentInfoView: React.FC = ({ agentId, onClose, accessT
{/* Overview Panel */}
+
{agent.agent_id}
{agent.agent_name}
@@ -505,6 +514,8 @@ const AgentInfoView: React.FC = ({ agentId, onClose, accessT
)}
+
+
{discoveryRequest && (
{
expect(screen.getByRole("option", { name: /group-b/ })).toBeInTheDocument();
});
+ it("offers only individual agents when legacy groups are disabled", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ render( );
+ await user.click(screen.getByRole("combobox"));
+ await user.click(await screen.findByRole("option", { name: /Agent One/ }));
+ expect(screen.queryByRole("option", { name: /group-a/ })).not.toBeInTheDocument();
+ expect(onChange).toHaveBeenCalledWith({ agents: ["agent-1"], accessGroups: [] });
+ });
+
+ it("preserves a saved legacy group while adding an agent with legacy groups disabled", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ await user.click(screen.getByRole("combobox"));
+ expect(await screen.findByRole("option", { name: /retired-group/ })).toHaveTextContent(
+ "Existing legacy agent group",
+ );
+ await user.click(await screen.findByRole("option", { name: /Agent One/ }));
+ expect(onChange).toHaveBeenCalledWith({ agents: ["agent-1"], accessGroups: ["retired-group"] });
+ });
+
it("respects disabled prop", () => {
render( );
expect(screen.getByRole("combobox")).toBeDisabled();
diff --git a/ui/litellm-dashboard/src/components/agent_management/AgentSelector.tsx b/ui/litellm-dashboard/src/components/agent_management/AgentSelector.tsx
index d26772d5c9a..7215bcf25e8 100644
--- a/ui/litellm-dashboard/src/components/agent_management/AgentSelector.tsx
+++ b/ui/litellm-dashboard/src/components/agent_management/AgentSelector.tsx
@@ -19,6 +19,7 @@ interface AgentSelectorProps {
accessToken: string;
placeholder?: string;
disabled?: boolean;
+ allowAccessGroups?: boolean;
}
const AgentSelector: React.FC = ({
@@ -28,6 +29,7 @@ const AgentSelector: React.FC = ({
accessToken,
placeholder = "Select agents",
disabled = false,
+ allowAccessGroups = true,
}) => {
const [agents, setAgents] = useState([]);
const [accessGroups, setAccessGroups] = useState([]);
@@ -60,12 +62,15 @@ const AgentSelector: React.FC = ({
fetchData();
}, [accessToken]);
- // Combine options, access groups first
+ const selectableGroups = allowAccessGroups
+ ? Array.from(new Set([...accessGroups, ...(value?.accessGroups ?? [])]))
+ : value?.accessGroups ?? [];
+
const options: MultiSelectOption[] = [
- ...accessGroups.map((group) => ({
+ ...selectableGroups.map((group) => ({
label: group,
value: `group:${group}`,
- description: "Access Group",
+ description: allowAccessGroups ? "Access Group" : "Existing legacy agent group",
})),
...agents.map((agent) => ({
label: `${agent.agent_name || agent.agent_id}`,
diff --git a/ui/litellm-dashboard/src/components/agents/types.ts b/ui/litellm-dashboard/src/components/agents/types.ts
index 469ecf36f06..92d946c19e1 100644
--- a/ui/litellm-dashboard/src/components/agents/types.ts
+++ b/ui/litellm-dashboard/src/components/agents/types.ts
@@ -11,6 +11,11 @@ export type AgentKillSwitchConfig = components["schemas"]["AgentKillSwitchConfig
export type AgentKillSwitchResult = components["schemas"]["AgentKillSwitchResult"];
export interface Agent {
+ identity?: components["schemas"]["AgentIdentityBinding"] | null;
+ identity_managed?: boolean;
+ enabled?: boolean;
+ execution_mode?: components["schemas"]["AgentResponse"]["execution_mode"];
+ jwt_auth_configured?: boolean;
agent_id: string;
agent_name: string;
litellm_params: {
diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.test.tsx b/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.test.tsx
index 47fb5643b22..66748d3cae7 100644
--- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.test.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.test.tsx
@@ -170,3 +170,41 @@ describe("MCPServerSelector all-proxy-mcpservers option", () => {
expect(optionByLabel("Server One")).toHaveAttribute("aria-disabled", "true");
});
});
+
+describe("MCPServerSelector unified group flow", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setupMcpMocks();
+ mockUseMCPAccessGroups.mockReturnValue({ data: ["legacy-group"], isLoading: false } as ReturnType<
+ typeof useMCPAccessGroups
+ >);
+ });
+
+ it("offers servers without legacy groups when disabled", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ renderWithProviders( );
+ await openSelector(user);
+ expect(optionByLabel("legacy-group")).toBeUndefined();
+ await user.click(optionByLabel("Server One")!);
+ expect(onChange).toHaveBeenCalledWith({ servers: ["srv-1"], accessGroups: [], toolsets: [] });
+ });
+
+ it("preserves a saved legacy group even if discovery no longer returns it", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ renderWithProviders(
+ ,
+ );
+ await openSelector(user);
+ expect(optionByLabel("retired-group")).toHaveTextContent("Existing legacy MCP group");
+ expect(optionByLabel("legacy-group")).toBeUndefined();
+ await user.click(optionByLabel("Server One")!);
+ expect(onChange).toHaveBeenCalledWith({ servers: ["srv-1"], accessGroups: ["retired-group"], toolsets: [] });
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx b/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx
index 969db57462f..fe23fd87b76 100644
--- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx
@@ -18,11 +18,15 @@ interface MCPServerSelectorProps {
disabled?: boolean;
teamId?: string | null;
allowNoMcpServers?: boolean;
+ allowAccessGroups?: boolean;
allowAllProxyMcpServers?: boolean;
}
const TOOLSET_PREFIX = "toolset:";
+const selectableLegacyGroups = (available: string[], selected: string[] = [], allowNew: boolean): string[] =>
+ allowNew ? Array.from(new Set([...available, ...selected])) : selected;
+
const MCPServerSelector: React.FC = ({
onChange,
value,
@@ -32,22 +36,24 @@ const MCPServerSelector: React.FC = ({
disabled = false,
teamId,
allowNoMcpServers = false,
+ allowAccessGroups = true,
allowAllProxyMcpServers = false,
}) => {
const { data: mcpServers = [], isLoading: serversLoading } = useMCPServers(teamId);
const { data: accessGroups = [], isLoading: groupsLoading } = useMCPAccessGroups();
const { data: toolsets = [], isLoading: toolsetsLoading } = useMCPToolsets();
- const loading = serversLoading || groupsLoading || toolsetsLoading;
+ const loading = [serversLoading, groupsLoading, toolsetsLoading].some(Boolean);
- const accessGroupSet = new Set(accessGroups);
+ const selectableGroups = selectableLegacyGroups(accessGroups, value?.accessGroups, allowAccessGroups);
+ const accessGroupSet = new Set(selectableGroups);
// Combine options: access groups + servers + toolsets
const options = [
- ...accessGroups.map((group) => ({
+ ...selectableGroups.map((group) => ({
label: group,
value: group,
- description: "Access Group",
+ description: allowAccessGroups ? "Access Group" : "Existing legacy MCP group",
})),
...mcpServers.map((server) => ({
label: `${server.server_name || server.server_id} (${server.server_id})`,
diff --git a/ui/litellm-dashboard/src/components/permissions/AgentPermissions.tsx b/ui/litellm-dashboard/src/components/permissions/AgentPermissions.tsx
index d1ca25c975b..0d5525d7d78 100644
--- a/ui/litellm-dashboard/src/components/permissions/AgentPermissions.tsx
+++ b/ui/litellm-dashboard/src/components/permissions/AgentPermissions.tsx
@@ -67,7 +67,7 @@ export function AgentPermissions({
-
Agents
+
Allowed agents to call
{totalCount}
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
index a693ee971d4..bbabcf2458e 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
@@ -2249,9 +2249,9 @@ describe("TeamInfoView - the exact bytes the update call sends", () => {
await openEditorWithAgents(user);
await user.click(within(screen.getByLabelText("agent-1")).getByRole("button"));
- await user.click(within(screen.getByLabelText("group:group-a")).getByRole("button"));
+ await user.click(within(screen.getByLabelText("group-a")).getByRole("button"));
expect(screen.queryByLabelText("agent-1")).not.toBeInTheDocument();
- expect(screen.queryByLabelText("group:group-a")).not.toBeInTheDocument();
+ expect(screen.queryByLabelText("group-a")).not.toBeInTheDocument();
const payload = await save(user);
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
index 3845f94593d..e03a08dfabd 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
@@ -2039,13 +2039,14 @@ const TeamInfoView: React.FC
= ({
)}
-
+
{({ value, onChange }) => (
)}
@@ -2062,13 +2063,14 @@ const TeamInfoView: React.FC = ({
/>
-
+
{({ value, onChange }) => (
)}
diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.test.tsx
index 4d451a9c7b9..4d980d1b018 100644
--- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.test.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.test.tsx
@@ -375,3 +375,17 @@ describe("TTFT column", () => {
expect(screen.getByText("1.00")).toBeInTheDocument();
});
});
+
+describe("Request outcome", () => {
+ it("shows a failed agent outcome even when metadata has no status", () => {
+ renderRows([logEntry({ call_type: "asend_message", status: "failure", session_total_count: 4 })]);
+ expect(screen.getByText("Failure")).toBeInTheDocument();
+ expect(screen.queryByText("Success")).not.toBeInTheDocument();
+ });
+
+ it("prefers the recorded outcome over stale metadata", () => {
+ renderRows([logEntry({ status: "success", metadata: { status: "failure" } })]);
+ expect(screen.getByText("Success")).toBeInTheDocument();
+ expect(screen.queryByText("Failure")).not.toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx
index 5705f41f3de..6b35ceb81a2 100644
--- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx
@@ -120,7 +120,7 @@ export const getRequestLogsTableColumns = ({
enableSorting: false,
meta: { skeleton: "badge" },
cell: ({ row }) => {
- const status = readMetaString(row.original.metadata, "status") ?? "Success";
+ const status = row.original.status || readMetaString(row.original.metadata, "status") || "Success";
const isSuccess = status.toLowerCase() !== "failure";
const batchCounts = isSuccess ? getBatchRequestCounts(row.original.metadata) : undefined;
if (batchCounts && batchCounts.failed > 0) {