- {topTools.length === 0 ? (
-
- {toolSpendLoading ? "Loading..." : "No tool usage in this range."}
+ {canViewProxyWideCostData && (
+
+
+ Spend by tool
+
+ Spend on requests that invoked each tool (MCP and client-side tools); declaring a tool without invoking it
+ does not count. A request that invoked multiple tools counts its full spend toward each, so this
+ attributes rather than partitions spend.
- ) : (
-
-
-
Total by tool
-
+
+
+ {topTools.length === 0 ? (
+
+ {toolSpendLoading ? "Loading..." : "No tool usage in this range."}
+
+ ) : (
+
+
+
+
Daily spend by tool
+
+
+
-
-
Daily spend by tool
-
-
-
-
- )}
-
-
+ )}
+
+
+ )}
);
};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.integration.test.tsx
new file mode 100644
index 00000000000..d4c68841299
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.integration.test.tsx
@@ -0,0 +1,53 @@
+import { screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import GuardrailsMonitor from "./page";
+import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils";
+
+const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() }));
+
+vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
+ default: useAuthorizedMock,
+}));
+
+const fetchMock = vi.fn();
+
+const requestedUrls = () => fetchMock.mock.calls.map(([url]) => String(url));
+
+const renderAs = (userRole: string) => {
+ useAuthorizedMock.mockReturnValue({ accessToken: "sk-test", userId: "u1", userRole });
+ return renderWithProviders();
+};
+
+// `/guardrails/usage/*` aggregates across tenants and is listed in
+// admin_viewer_routes, so it is proxy-admin-only. Nothing on this page works
+// for a non-admin, hence the whole page is gated rather than a section of it.
+describe("Guardrails Monitor page access by role", () => {
+ beforeEach(() => {
+ testQueryClient.clear();
+ vi.clearAllMocks();
+ fetchMock.mockResolvedValue({
+ ok: true,
+ status: 200,
+ statusText: "OK",
+ json: async () => ({ rows: [], chart: [], totalRequests: 0, totalBlocked: 0, passRate: 100 }),
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ });
+
+ it("fetches the guardrails usage overview for an admin", async () => {
+ renderAs("Admin");
+
+ await waitFor(() => expect(requestedUrls().some((url) => url.includes("/guardrails/usage/overview"))).toBe(true));
+ });
+
+ it.each(["Internal User", "Internal Viewer", "Org Admin", "Unknown Role"])(
+ "renders the admin-only notice and fires no usage request for %s",
+ async (userRole) => {
+ renderAs(userRole);
+
+ expect(await screen.findByText("Guardrails Monitor is only available to admin users.")).toBeInTheDocument();
+ await waitFor(() => expect(fetchMock).not.toHaveBeenCalled());
+ expect(requestedUrls().filter((url) => url.includes("/guardrails/usage"))).toEqual([]);
+ },
+ );
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.tsx
index 0c4e69c2d80..255769182bf 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.tsx
@@ -1,9 +1,17 @@
"use client";
import GuardrailsMonitorView from "./_components/GuardrailsMonitorView";
+import { AdminOnlyNotice } from "@/components/shared/AdminOnlyNotice";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
+import useCan from "@/app/(dashboard)/hooks/useCan";
export default function GuardrailsMonitor() {
const { accessToken } = useAuthorized();
+ const canViewGuardrailUsage = useCan("viewGuardrailUsage");
+
+ if (!canViewGuardrailUsage) {
+ return ;
+ }
+
return ;
}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/page.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/page.integration.test.tsx
new file mode 100644
index 00000000000..8d15bb59187
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/page.integration.test.tsx
@@ -0,0 +1,59 @@
+import { screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import Memory from "./page";
+import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils";
+
+const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() }));
+
+vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
+ default: useAuthorizedMock,
+}));
+
+const fetchMock = vi.fn();
+
+const requestedUrls = () => fetchMock.mock.calls.map(([url]) => String(url));
+
+const renderAs = (userRole: string) => {
+ useAuthorizedMock.mockReturnValue({ accessToken: "sk-test", userId: "u1", userRole });
+ return renderWithProviders();
+};
+
+// `/v1/memory` scopes rows per caller in the handler, but the route gate keeps
+// it proxy-admin-only, so a non-admin deep-linking to /ui/memory gets a 401.
+describe("Memory page access by role", () => {
+ beforeEach(() => {
+ testQueryClient.clear();
+ vi.clearAllMocks();
+ fetchMock.mockResolvedValue({
+ ok: true,
+ status: 200,
+ statusText: "OK",
+ text: async () => "",
+ json: async () => ({ memories: [], total: 0 }),
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ });
+
+ it("lists memory entries for an admin", async () => {
+ renderAs("Admin");
+
+ await waitFor(() => expect(requestedUrls().some((url) => url.includes("/v1/memory"))).toBe(true));
+ });
+
+ it.each(["Internal User", "Internal Viewer", "Org Admin", "Unknown Role"])(
+ "renders the admin-only notice and fires no memory request for %s",
+ async (userRole) => {
+ renderAs(userRole);
+
+ expect(await screen.findByText("Memory is only available to admin users.")).toBeInTheDocument();
+ await waitFor(() => expect(fetchMock).not.toHaveBeenCalled());
+ expect(requestedUrls().filter((url) => url.includes("/v1/memory"))).toEqual([]);
+ },
+ );
+
+ it("hides the deprecation banner along with the page body for a denied role", () => {
+ renderAs("Internal User");
+
+ expect(screen.queryByText(/draft deprecation list/i)).not.toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/page.tsx
index b88996c5396..7b1b6223372 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/memory/page.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/page.tsx
@@ -2,10 +2,18 @@
import { MemoryView } from "./_components/MemoryView";
import { DeprecationBanner } from "@/components/DeprecationBanner";
+import { AdminOnlyNotice } from "@/components/shared/AdminOnlyNotice";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
+import useCan from "@/app/(dashboard)/hooks/useCan";
export default function Memory() {
const { accessToken, userRole, userId } = useAuthorized();
+ const canViewMemory = useCan("viewMemory");
+
+ if (!canViewMemory) {
+ return ;
+ }
+
return (
<>
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/workflows/page.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/workflows/page.integration.test.tsx
new file mode 100644
index 00000000000..6b332faf704
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/workflows/page.integration.test.tsx
@@ -0,0 +1,59 @@
+import { screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import Workflows from "./page";
+import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils";
+
+const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() }));
+
+vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
+ default: useAuthorizedMock,
+}));
+
+const fetchMock = vi.fn();
+
+const requestedUrls = () => fetchMock.mock.calls.map(([url]) => String(url));
+
+const renderAs = (userRole: string) => {
+ useAuthorizedMock.mockReturnValue({ accessToken: "sk-test", userId: "u1", userRole });
+ return renderWithProviders();
+};
+
+// Deep-linking to /ui/workflows bypasses the sidebar, so the page itself has to
+// refuse the render. `/v1/workflows/runs` is proxy-admin-only, so any request
+// from a non-admin is the 401 this gate exists to stop.
+describe("Workflows page access by role", () => {
+ beforeEach(() => {
+ testQueryClient.clear();
+ vi.clearAllMocks();
+ fetchMock.mockResolvedValue({
+ ok: true,
+ status: 200,
+ statusText: "OK",
+ json: async () => ({ runs: [], count: 0 }),
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ });
+
+ it("lists workflow runs for an admin", async () => {
+ renderAs("Admin");
+
+ await waitFor(() => expect(requestedUrls().some((url) => url.includes("/v1/workflows/runs"))).toBe(true));
+ });
+
+ it.each(["Internal User", "Internal Viewer", "Org Admin", "Unknown Role"])(
+ "renders the admin-only notice and fires no workflow request for %s",
+ async (userRole) => {
+ renderAs(userRole);
+
+ expect(await screen.findByText("Workflow Runs is only available to admin users.")).toBeInTheDocument();
+ await waitFor(() => expect(fetchMock).not.toHaveBeenCalled());
+ expect(requestedUrls().filter((url) => url.includes("/v1/workflows"))).toEqual([]);
+ },
+ );
+
+ it("hides the deprecation banner along with the page body for a denied role", () => {
+ renderAs("Internal User");
+
+ expect(screen.queryByText(/draft deprecation list/i)).not.toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/workflows/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/workflows/page.tsx
index 89dd30f7392..51db7579f82 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/workflows/page.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/workflows/page.tsx
@@ -2,10 +2,18 @@
import WorkflowRuns from "./WorkflowRuns";
import { DeprecationBanner } from "@/components/DeprecationBanner";
+import { AdminOnlyNotice } from "@/components/shared/AdminOnlyNotice";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
+import useCan from "@/app/(dashboard)/hooks/useCan";
export default function Workflows() {
const { accessToken } = useAuthorized();
+ const canViewWorkflowRuns = useCan("viewWorkflowRuns");
+
+ if (!canViewWorkflowRuns) {
+ return ;
+ }
+
return (
<>
diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx
index f795076ff03..e7e32718a27 100644
--- a/ui/litellm-dashboard/src/components/leftnav.test.tsx
+++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx
@@ -268,6 +268,96 @@ describe("Sidebar (leftnav)", () => {
});
});
+ // Workflow Runs, Memory and Guardrails Monitor render a shell and then 401
+ // for every non-proxy-admin role, because their page-load routes sit outside
+ // internal_user_routes / self_managed_routes. Cost Optimization does not:
+ // its primary call is /user/daily/activity, which every role may make, so
+ // the entry stays and only its proxy-wide tabs are gated inside the page.
+ describe("capability-gated pages whose data is proxy-admin-only", () => {
+ const authFor = (userRole: string) => ({
+ userId: "some-user-id",
+ accessToken: "test-access-token",
+ userRole,
+ isViewOnly: false,
+ token: "test-token",
+ userEmail: "someone@example.com",
+ premiumUser: false,
+ disabledPersonalKeyCreation: false,
+ showSSOBanner: false,
+ });
+
+ afterEach(() => {
+ mockUseAuthorized.mockReset();
+ });
+
+ it("hides Workflow Runs and Memory from an internal user under Agentic", async () => {
+ mockUseAuthorized.mockReturnValue(authFor("internal"));
+ renderWithProviders();
+
+ act(() => {
+ fireEvent.click(screen.getByText("Agentic"));
+ });
+ // Liveness gate: the sibling Agents child stays visible to this role, so
+ // the absences below mean the gate fired, not that the group never opened.
+ await waitFor(() => {
+ expect(screen.getByText("Agents")).toBeInTheDocument();
+ });
+ expect(screen.queryByText("Workflow Runs")).not.toBeInTheDocument();
+ expect(screen.queryByText("Memory")).not.toBeInTheDocument();
+ });
+
+ // An org admin's session role is "Org Admin", which no capability list
+ // carries, and the proxy denies these routes to org admins too because
+ // `_user_is_org_admin` needs an organization_id the page-load GET never sends.
+ // Agents is already out of reach for this role, so gating the other two
+ // empties the Agentic group entirely and the parent must go with it rather
+ // than degrade into a leaf link to the non-route `?page=agentic`.
+ it("drops the whole Agentic group for an org admin once its last child is gated", () => {
+ mockUseAuthorized.mockReturnValue(authFor("org_admin"));
+ renderWithProviders();
+
+ // Liveness gate: Logs carries no role list, so it proves the sidebar rendered.
+ expect(screen.getByText("Logs")).toBeInTheDocument();
+ expect(screen.queryByText("Agentic")).not.toBeInTheDocument();
+ expect(screen.queryByText("Workflow Runs")).not.toBeInTheDocument();
+ expect(screen.queryByText("Memory")).not.toBeInTheDocument();
+ });
+
+ it("keeps the Agentic group for an internal user, who can still see Agents", () => {
+ mockUseAuthorized.mockReturnValue(authFor("internal"));
+ renderWithProviders();
+
+ expect(screen.getByText("Agentic")).toBeInTheDocument();
+ });
+
+ it("shows Workflow Runs and Memory to admins", async () => {
+ renderWithProviders();
+
+ act(() => {
+ fireEvent.click(screen.getByText("Agentic"));
+ });
+ await waitFor(() => {
+ expect(screen.getByText("Workflow Runs")).toBeInTheDocument();
+ });
+ expect(screen.getByText("Memory")).toBeInTheDocument();
+ });
+
+ it("hides Guardrails Monitor from an internal user while keeping Usage and Cost Optimization", () => {
+ mockUseAuthorized.mockReturnValue(authFor("internal"));
+ renderWithProviders();
+
+ expect(screen.queryByText("Guardrails Monitor")).not.toBeInTheDocument();
+ expect(screen.getByText("Usage")).toBeInTheDocument();
+ expect(screen.getByText("Cost Optimization")).toBeInTheDocument();
+ });
+
+ it("shows Guardrails Monitor to admins", () => {
+ renderWithProviders();
+
+ expect(screen.getByText("Guardrails Monitor")).toBeInTheDocument();
+ });
+ });
+
it("should show Organizations tab for organization admins", () => {
mockUseAuthorized.mockReturnValueOnce({
userId: "org-admin-user-id",
diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx
index 12d124b059e..2b9b4859b16 100644
--- a/ui/litellm-dashboard/src/components/leftnav.tsx
+++ b/ui/litellm-dashboard/src/components/leftnav.tsx
@@ -146,8 +146,20 @@ const menuGroups: MenuGroup[] = [
icon: ,
roles: rolesAllowedToViewWriteScopedPages,
},
- { key: "workflows", page: "workflows", label: "Workflow Runs", icon: },
- { key: "memory", page: "memory", label: "Memory", icon: },
+ {
+ key: "workflows",
+ page: "workflows",
+ label: "Workflow Runs",
+ icon: ,
+ roles: rolesWithCapability("viewWorkflowRuns"),
+ },
+ {
+ key: "memory",
+ page: "memory",
+ label: "Memory",
+ icon: ,
+ roles: rolesWithCapability("viewMemory"),
+ },
],
},
{ key: "mcp-servers", page: "mcp-servers", label: "MCP Servers", icon: },
@@ -206,7 +218,7 @@ const menuGroups: MenuGroup[] = [
page: "guardrails-monitor",
label: "Guardrails Monitor",
icon: ,
- roles: [...all_admin_roles, ...internalUserRoles],
+ roles: rolesWithCapability("viewGuardrailUsage"),
},
],
},
@@ -455,6 +467,9 @@ const Sidebar_: React.FC = ({
return items
.map((item) => ({ ...item, children: item.children ? filterItemsByRole(item.children) : undefined }))
.filter((item) => {
+ // A parent whose children were all filtered out renders as a leaf link
+ // to its own page id, which is not a real route. Drop it instead.
+ if (item.children && item.children.length === 0) return false;
if (item.key === "llm-playground" && isViewOnly) return false;
if (item.key === "organizations" || item.key === "users") {
const hasRoleAccess = !item.roles || item.roles.includes(userRole) || isOrgAdmin;
diff --git a/ui/litellm-dashboard/src/components/shared/AdminOnlyNotice.tsx b/ui/litellm-dashboard/src/components/shared/AdminOnlyNotice.tsx
new file mode 100644
index 00000000000..afc855cd9dd
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/shared/AdminOnlyNotice.tsx
@@ -0,0 +1,14 @@
+"use client";
+
+import React from "react";
+
+interface AdminOnlyNoticeProps {
+ pageTitle: string;
+}
+
+export const AdminOnlyNotice: React.FC = ({ pageTitle }) => (
+
+
{pageTitle}
+
{pageTitle} is only available to admin users.
+
+);
diff --git a/ui/litellm-dashboard/src/utils/capabilities.test.ts b/ui/litellm-dashboard/src/utils/capabilities.test.ts
index 858658309ec..2910e4ff359 100644
--- a/ui/litellm-dashboard/src/utils/capabilities.test.ts
+++ b/ui/litellm-dashboard/src/utils/capabilities.test.ts
@@ -67,6 +67,36 @@ describe.each(["viewAuditLogs", "viewDeletedTeams"] as const)("hasCapability - %
);
});
+// `useAuthorized` supplies `userRole` as the formatted session role from
+// `effectiveSessionRole`, which collapses proxy_admin_viewer to "Admin" and
+// renders an org admin as "Org Admin". The four sidebar pages behind these
+// capabilities call proxy-admin-only routes: `_user_is_org_admin` needs an
+// `organization_id` in the request data, which a page-load GET never carries,
+// so an org admin is denied at the proxy exactly as it is here.
+describe.each(["viewWorkflowRuns", "viewMemory", "viewGuardrailUsage", "viewProxyWideCostData"] as const)(
+ "hasCapability - %s",
+ (capability) => {
+ it.each(["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer"])("should grant it to %s", (role) => {
+ expect(hasCapability(role, capability)).toBe(true);
+ });
+
+ it.each([
+ "Internal User",
+ "Internal Viewer",
+ "internal_user",
+ "internal_user_viewer",
+ "Org Admin",
+ "App User",
+ "Unknown Role",
+ "",
+ null,
+ undefined,
+ ])("should deny it to %s", (role) => {
+ expect(hasCapability(role, capability)).toBe(false);
+ });
+ },
+);
+
describe("rolesWithCapability", () => {
it("should return a copy so callers cannot mutate the capability map", () => {
const roles = rolesWithCapability("viewToolPolicies");
diff --git a/ui/litellm-dashboard/src/utils/capabilities.ts b/ui/litellm-dashboard/src/utils/capabilities.ts
index 8171ef9a512..e981b9d774f 100644
--- a/ui/litellm-dashboard/src/utils/capabilities.ts
+++ b/ui/litellm-dashboard/src/utils/capabilities.ts
@@ -6,6 +6,10 @@ const CAPABILITY_ROLES = {
viewDeletedTeams: all_admin_roles,
viewPolicies: all_admin_roles,
viewPrompts: all_admin_roles,
+ viewWorkflowRuns: all_admin_roles,
+ viewMemory: all_admin_roles,
+ viewGuardrailUsage: all_admin_roles,
+ viewProxyWideCostData: all_admin_roles,
} as const satisfies Record;
export type Capability = keyof typeof CAPABILITY_ROLES;