From 30c4898de9cea90e777a43a5260fe77011acdb5b Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 8 Aug 2026 20:16:22 -0700 Subject: [PATCH 01/18] fix(ui): hide admin-only Logs tabs from roles that cannot call their endpoints The Logs nav entry is open to internal users so they can read their own request logs, but the page rendered all four tabs unconditionally. Audit Logs calls GET /audit and Deleted Teams calls GET /v2/team/list?status=deleted, neither of which an internal user is permitted to call, so the page fired requests that came back 401. Gate both tabs on new viewAuditLogs / viewDeletedTeams capabilities, using the same CAPABILITY_ROLES map and useCan hook introduced for Tool Policies. Hiding a tab drops its panel from the tree entirely, so the request is never issued rather than issued and rejected. Selecting a tab also mapped index 0 to "request logs" and every other index to "audit logs", which activated the audit panel whenever a user opened Deleted Keys or Deleted Teams. Derive the active tab from the visible tab list instead, so the mapping survives tabs being filtered out. --- .../view_logs/index.integration.test.tsx | 104 ++++++++++++++++++ .../src/components/view_logs/index.test.tsx | 79 ++++++++++++- .../src/components/view_logs/index.tsx | 91 +++++++++------ .../src/utils/capabilities.test.ts | 13 +++ .../src/utils/capabilities.ts | 2 + 5 files changed, 254 insertions(+), 35 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/view_logs/index.integration.test.tsx diff --git a/ui/litellm-dashboard/src/components/view_logs/index.integration.test.tsx b/ui/litellm-dashboard/src/components/view_logs/index.integration.test.tsx new file mode 100644 index 00000000000..b86ad015b91 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/index.integration.test.tsx @@ -0,0 +1,104 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import SpendLogsTable from "./index"; +import { renderWithProviders, testQueryClient } from "../../../tests/test-utils"; + +const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() })); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: useAuthorizedMock, +})); + +vi.mock("./RequestLogsPanel", () => ({ + default: function RequestLogsPanelMock() { + return
; + }, +})); + +const fetchMock = vi.fn(); + +const jsonResponse = (body: unknown) => ({ + ok: true, + status: 200, + statusText: "OK", + json: async () => body, +}); + +const requestedUrls = () => fetchMock.mock.calls.map(([url]) => String(url)); + +const emptyAuditLogs = { audit_logs: [], total: 0, page: 1, page_size: 50, total_pages: 0 }; + +const defaultProps = { + accessToken: "sk-test", + token: "jwt-test", + userRole: "Admin", + userID: "user-1", + premiumUser: true, +}; + +const renderAs = (sessionRole: string) => { + useAuthorizedMock.mockReturnValue({ accessToken: "sk-test", userRole: sessionRole, premiumUser: true }); + return renderWithProviders(); +}; + +describe("SpendLogsTable network access by role", () => { + beforeEach(() => { + testQueryClient.clear(); + vi.clearAllMocks(); + fetchMock.mockImplementation(async (url: string) => { + if (String(url).includes("/audit")) { + return jsonResponse(emptyAuditLogs); + } + if (String(url).includes("/v2/team/list")) { + return jsonResponse({ teams: [] }); + } + return jsonResponse({ keys: [], total_count: 0 }); + }); + vi.stubGlobal("fetch", fetchMock); + }); + + it("fires neither the audit nor the deleted-teams request for an internal user", async () => { + const user = userEvent.setup(); + renderAs("Internal User"); + + // Liveness gate: the sibling Deleted Keys panel does reach the network, so a + // silent absence below means the gate worked, not that nothing rendered. + await waitFor(() => expect(requestedUrls().some((url) => url.includes("/key/list"))).toBe(true)); + + await user.click(screen.getByRole("tab", { name: "Deleted Keys" })); + await user.click(screen.getByRole("tab", { name: "Request Logs" })); + + expect(requestedUrls().filter((url) => url.includes("/audit"))).toEqual([]); + expect(requestedUrls().filter((url) => url.includes("/v2/team/list"))).toEqual([]); + }); + + it("fetches deleted teams and audit logs for an admin", async () => { + const user = userEvent.setup(); + renderAs("Admin"); + + await waitFor(() => + expect(requestedUrls().some((url) => url.includes("/v2/team/list") && url.includes("status=deleted"))).toBe(true), + ); + + expect(requestedUrls().filter((url) => url.includes("/audit"))).toEqual([]); + + await user.click(screen.getByRole("tab", { name: "Audit Logs" })); + + await waitFor(() => expect(requestedUrls().some((url) => url.includes("/audit"))).toBe(true)); + }); + + it("leaves the audit request unsent when an admin selects a tab after Audit Logs", async () => { + const user = userEvent.setup(); + renderAs("Admin"); + + await user.click(screen.getByRole("tab", { name: "Deleted Teams" })); + + expect(screen.getByRole("tab", { name: "Deleted Teams" })).toHaveAttribute("aria-selected", "true"); + expect(requestedUrls().filter((url) => url.includes("/audit"))).toEqual([]); + + await user.click(screen.getByRole("tab", { name: "Audit Logs" })); + + await waitFor(() => expect(requestedUrls().some((url) => url.includes("/audit"))).toBe(true)); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/index.test.tsx b/ui/litellm-dashboard/src/components/view_logs/index.test.tsx index b2e77ec7fd5..785fa0cc6f8 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.test.tsx @@ -1,9 +1,15 @@ import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import SpendLogsTable from "./index"; import { renderWithProviders } from "../../../tests/test-utils"; +const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() })); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: useAuthorizedMock, +})); + vi.mock("./RequestLogsPanel", () => ({ default: function RequestLogsPanelMock({ isActive }: { isActive: boolean }) { return
{isActive ? "active" : "inactive"}
; @@ -36,9 +42,18 @@ const defaultProps = { premiumUser: false, }; +const renderAs = (sessionRole: string) => { + useAuthorizedMock.mockReturnValue({ userRole: sessionRole }); + return renderWithProviders(); +}; + describe("SpendLogsTable", () => { + beforeEach(() => { + useAuthorizedMock.mockReturnValue({ userRole: "Admin" }); + }); + it("renders the four log tabs", () => { - renderWithProviders(); + renderAs("Admin"); for (const label of ["Request Logs", "Audit Logs", "Deleted Keys", "Deleted Teams"]) { expect(screen.getByRole("tab", { name: label })).toBeInTheDocument(); @@ -47,7 +62,7 @@ describe("SpendLogsTable", () => { it("marks only the visible tab's panel active so background tabs do not query", async () => { const user = userEvent.setup(); - renderWithProviders(); + renderAs("Admin"); expect(screen.getByTestId("request-logs-panel")).toHaveTextContent("active"); @@ -57,8 +72,64 @@ describe("SpendLogsTable", () => { expect(screen.getByTestId("request-logs-panel")).toHaveTextContent("inactive"); }); + describe("admin-only tabs", () => { + it.each(["Internal User", "Internal Viewer"])("hides Audit Logs and Deleted Teams from %s", (role) => { + renderAs(role); + + expect(screen.getByRole("tab", { name: "Request Logs" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Deleted Keys" })).toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "Audit Logs" })).not.toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "Deleted Teams" })).not.toBeInTheDocument(); + }); + + it("never mounts the panels that call the admin-only endpoints for an internal user", () => { + renderAs("Internal User"); + + expect(screen.queryByTestId("audit-logs-panel")).not.toBeInTheDocument(); + expect(screen.queryByTestId("deleted-teams-page")).not.toBeInTheDocument(); + expect(screen.getByTestId("deleted-keys-page")).toBeInTheDocument(); + }); + }); + + describe("tab index mapping", () => { + it("activates the panel the admin selected, not the one at the old hardcoded index", async () => { + const user = userEvent.setup(); + renderAs("Admin"); + + await user.click(screen.getByRole("tab", { name: "Deleted Keys" })); + + expect(screen.getByTestId("audit-logs-panel")).toHaveTextContent("inactive"); + expect(screen.getByTestId("request-logs-panel")).toHaveTextContent("inactive"); + }); + + it("keeps the audit panel inert when an admin selects the last tab", async () => { + const user = userEvent.setup(); + renderAs("Admin"); + + await user.click(screen.getByRole("tab", { name: "Deleted Teams" })); + + expect(screen.getByTestId("audit-logs-panel")).toHaveTextContent("inactive"); + expect(screen.getByTestId("deleted-teams-page")).toBeInTheDocument(); + }); + + it("selects the last visible tab for an internal user and returns to Request Logs", async () => { + const user = userEvent.setup(); + renderAs("Internal User"); + + await user.click(screen.getByRole("tab", { name: "Deleted Keys" })); + + expect(screen.getByTestId("deleted-keys-page")).toBeInTheDocument(); + expect(screen.getByTestId("request-logs-panel")).toHaveTextContent("inactive"); + + await user.click(screen.getByRole("tab", { name: "Request Logs" })); + + expect(screen.getByTestId("request-logs-panel")).toHaveTextContent("active"); + }); + }); + describe("auth-not-ready guard", () => { it("shows a loading spinner when credentials are not yet resolved", () => { + useAuthorizedMock.mockReturnValue({ userRole: "Admin" }); renderWithProviders(); expect(document.querySelector(".ant-spin")).toBeInTheDocument(); @@ -66,7 +137,7 @@ describe("SpendLogsTable", () => { }); it("renders the tabs (no spinner) once all credentials are present", () => { - renderWithProviders(); + renderAs("Admin"); expect(document.querySelector(".ant-spin")).not.toBeInTheDocument(); expect(screen.getByRole("tab", { name: "Request Logs" })).toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 8e7423e3fae..7269564dcec 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react"; +import useCan from "@/app/(dashboard)/hooks/useCan"; import DeletedKeysPage from "../DeletedKeysPage/DeletedKeysPage"; import DeletedTeamsPage from "../DeletedTeamsPage/DeletedTeamsPage"; import AuditLogsPanel from "./AuditLogsPanel"; @@ -14,8 +15,22 @@ interface SpendLogsTableProps { premiumUser: boolean; } +type LogsTabId = "request logs" | "audit logs" | "deleted keys" | "deleted teams"; + +interface LogsTab { + id: LogsTabId; + label: string; +} + +const REQUEST_LOGS_TAB: LogsTab = { id: "request logs", label: "Request Logs" }; +const AUDIT_LOGS_TAB: LogsTab = { id: "audit logs", label: "Audit Logs" }; +const DELETED_KEYS_TAB: LogsTab = { id: "deleted keys", label: "Deleted Keys" }; +const DELETED_TEAMS_TAB: LogsTab = { id: "deleted teams", label: "Deleted Teams" }; + export default function SpendLogsTable({ accessToken, token, userRole, userID, premiumUser }: SpendLogsTableProps) { - const [activeTab, setActiveTab] = useState("request logs"); + const [activeTab, setActiveTab] = useState(REQUEST_LOGS_TAB.id); + const canViewAuditLogs = useCan("viewAuditLogs"); + const canViewDeletedTeams = useCan("viewDeletedTeams"); if (!accessToken || !token || !userRole || !userID) { return ( @@ -25,41 +40,55 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p ); } + const tabs: LogsTab[] = [ + REQUEST_LOGS_TAB, + ...(canViewAuditLogs ? [AUDIT_LOGS_TAB] : []), + DELETED_KEYS_TAB, + ...(canViewDeletedTeams ? [DELETED_TEAMS_TAB] : []), + ]; + + const renderPanel = (tabId: LogsTabId) => { + switch (tabId) { + case "request logs": + return ( + + ); + case "audit logs": + return ( + + ); + case "deleted keys": + return ; + case "deleted teams": + return ; + } + }; + return (
- setActiveTab(index === 0 ? "request logs" : "audit logs")}> + setActiveTab(tabs[index].id)}> - Request Logs - Audit Logs - Deleted Keys - Deleted Teams + {tabs.map((tab) => ( + {tab.label} + ))} - - - - - - - - - - - - + {tabs.map((tab) => ( + {renderPanel(tab.id)} + ))}
diff --git a/ui/litellm-dashboard/src/utils/capabilities.test.ts b/ui/litellm-dashboard/src/utils/capabilities.test.ts index f48609b0b9d..611c9626065 100644 --- a/ui/litellm-dashboard/src/utils/capabilities.test.ts +++ b/ui/litellm-dashboard/src/utils/capabilities.test.ts @@ -18,6 +18,19 @@ describe("hasCapability", () => { ); }); +describe.each(["viewAuditLogs", "viewDeletedTeams"] 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", "App User", "Org Admin", "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 77ead2568fb..f0847cc3400 100644 --- a/ui/litellm-dashboard/src/utils/capabilities.ts +++ b/ui/litellm-dashboard/src/utils/capabilities.ts @@ -2,6 +2,8 @@ import { all_admin_roles } from "./roles"; const CAPABILITY_ROLES = { viewToolPolicies: all_admin_roles, + viewAuditLogs: all_admin_roles, + viewDeletedTeams: all_admin_roles, } as const satisfies Record; export type Capability = keyof typeof CAPABILITY_ROLES; From 6a540a1bf848129dc16228d1b23f92120d7a7f03 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 8 Aug 2026 20:17:12 -0700 Subject: [PATCH 02/18] fix(ui): gate organization and agent usage views behind capabilities The Usage page admits internal users because their own usage view works, but the entity breakdown selector inside it also offered Organization Usage, so picking it fired /organization/daily/activity and collected a 401. Neither that route nor /agent/daily/activity appears in any non-admin route list, so both are default-deny. The team breakdown leaked the second one too: it fetches agent activity unconditionally to fill its Top Agents card, which 401s for the same roles. Adds viewOrganizationUsage and viewAgentUsage to the existing capability map and points the selector option, the page section, and the fetch's enabled flag at the same capability, so a role that cannot call the endpoint never sees the breakdown and never issues the request. The team and tag breakdowns, which internal users can read, are untouched, and the default Usage view was already one of those. --- .../EntityUsage/EntityUsage.test.tsx | 43 ++++++++++++++++++- .../components/EntityUsage/EntityUsage.tsx | 34 +++++++++++---- .../components/UsagePageView.test.tsx | 25 +++++++++++ .../_components/components/UsagePageView.tsx | 9 ++-- .../UsageViewSelect/UsageViewSelect.test.tsx | 29 +++++++++++-- .../UsageViewSelect/UsageViewSelect.tsx | 20 +++++---- .../src/utils/capabilities.test.ts | 36 ++++++++++------ .../src/utils/capabilities.ts | 2 + 8 files changed, 160 insertions(+), 38 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx index 82ca66b10c0..11528117f1e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import * as networking from "@/components/networking"; import EntityUsage from "./EntityUsage"; @@ -856,6 +856,47 @@ describe("EntityUsage", () => { expect(logo.getAttribute("src")).toContain("openai_small"); }); + describe("capability gating", () => { + it.each([ + ["organization", () => mockOrganizationDailyActivityCall, "Organization Spend Overview"], + ["agent", () => mockAgentDailyActivityCall, "Agent Spend Overview"], + ] as const)("fetches %s activity for an admin but not for an internal user", async (entityType, call, heading) => { + render(); + await waitFor(() => { + expect(call()).toHaveBeenCalled(); + }); + + cleanup(); + call().mockClear(); + + render(); + expect(await screen.findByText(heading)).toBeInTheDocument(); + expect(call()).not.toHaveBeenCalled(); + }); + + it("keeps the team breakdown but drops its agent sub-fetch for an internal user", async () => { + render(); + + await waitFor(() => { + expect(mockTeamDailyActivityCall).toHaveBeenCalled(); + }); + expect(screen.getByText("Team Spend Overview")).toBeInTheDocument(); + + expect(mockAgentDailyActivityCall).not.toHaveBeenCalled(); + expect(screen.queryByText("Agent Activity")).not.toBeInTheDocument(); + expect(screen.queryByText("Top Agents Driving Spend")).not.toBeInTheDocument(); + }); + + it("keeps the tag breakdown for an internal user", async () => { + render(); + + await waitFor(() => { + expect(mockTagDailyActivityCall).toHaveBeenCalled(); + }); + expect(screen.getByText("Tag Spend Overview")).toBeInTheDocument(); + }); + }); + it("renders a letter avatar instead of an img for an unknown provider slug", async () => { const spendDataUnknownProvider = { ...mockSpendData, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx index 4d44791d1a9..5a0f2abf15b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx @@ -2,6 +2,7 @@ import useTeams from "@/app/(dashboard)/hooks/useTeams"; import { BarChart, DonutChart } from "@/components/shared/charts"; import { MoneyCell } from "@/components/shared/table_cells"; import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { hasCapability, type Capability } from "@/utils/capabilities"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { Card, @@ -108,7 +109,19 @@ const ENTITY_FETCH_FNS: Record Promise> = { user: userDailyActivityCall, }; -const EntityUsage: React.FC = ({ accessToken, entityType, entityId, entityList, dateValue }) => { +const ENTITY_CAPABILITIES: Partial> = { + organization: "viewOrganizationUsage", + agent: "viewAgentUsage", +}; + +const EntityUsage: React.FC = ({ + accessToken, + entityType, + entityId, + entityList, + userRole, + dateValue, +}) => { const { teams } = useTeams(); const [selectedTags, setSelectedTags] = useState([]); const [modelViewType, setModelViewType] = useState("groups"); @@ -125,7 +138,11 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti }, [entityType, selectedTags]); const fetchFn = ENTITY_FETCH_FNS[entityType]; - const enabled = !!accessToken && !!startTime && !!endTime; + const entityCapability = ENTITY_CAPABILITIES[entityType]; + const canViewEntity = entityCapability === undefined || hasCapability(userRole, entityCapability); + const showAgentBreakdown = entityType === "team" && hasCapability(userRole, "viewAgentUsage"); + const hasRequestWindow = !!accessToken && !!startTime && !!endTime; + const enabled = hasRequestWindow && canViewEntity; const { data: spendDataRaw, @@ -150,7 +167,7 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti } = usePaginatedDailyActivity({ fetchFn: agentDailyActivityCall, args: [accessToken, startTime, endTime, null], - enabled: enabled && entityType === "team", + enabled: enabled && showAgentBreakdown, }); const agentSpendData = agentSpendDataRaw as unknown as EntitySpendData; @@ -158,7 +175,7 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti const modelBreakdownKey = modelViewType === "groups" ? "model_groups" : "models"; const modelMetrics = processActivityData(spendData, modelBreakdownKey, teams || []); const keyMetrics = processActivityData(spendData, "api_keys", teams || []); - const agentMetrics = entityType === "team" ? processActivityData(agentSpendData, "entities", teams || []) : {}; + const agentMetrics = showAgentBreakdown ? processActivityData(agentSpendData, "entities", teams || []) : {}; const getTopModels = () => { const modelSpend: { [key: string]: any } = {}; @@ -621,8 +638,7 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti - {/* Top Agents - only for team entity type */} - {entityType === "team" && ( + {showAgentBreakdown && ( Top Agents Driving Spend @@ -708,7 +724,7 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti ), }, - ...(entityType === "team" + ...(showAgentBreakdown ? [{ key: "agents", label: "Agent Activity", content: }] : []), { @@ -757,7 +773,7 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti } /> )} - {agentIsFetchingMore && entityType === "team" && ( + {agentIsFetchingMore && showAgentBreakdown && ( = ({ accessToken, entityType, enti } /> )} - {agentCancelled && entityType === "team" && ( + {agentCancelled && showAgentBreakdown && ( { userId: "user-123", userEmail: "test@example.com", userRole: "Internal User", + userRoleLabel: "Internal User", + isViewOnly: false, premiumUser: true, disabledPersonalKeyCreation: false, showSSOBanner: false, @@ -861,6 +863,29 @@ describe("UsagePage", () => { }); }); + // The select hides both views from a non-admin, so this drives the section + // gate directly through the mocked select, which always offers every option. + it.each(["organization", "agent"])("should not render the %s usage view for an internal user", async (usageView) => { + mockUseAuthorized.mockReturnValue(nonAdminSession); + + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + const usageSelect = screen.getByTestId("usage-view-select"); + act(() => { + fireEvent.change(usageSelect, { target: { value: "team" } }); + }); + expect(screen.getAllByText("Entity Usage").length).toBeGreaterThan(0); + + act(() => { + fireEvent.change(usageSelect, { target: { value: usageView } }); + }); + expect(screen.queryByText("Entity Usage")).not.toBeInTheDocument(); + }); + describe("admin user selector", () => { it("should render user selector for admin users in global view", async () => { renderWithProviders(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index c3645d6371e..494df313ac0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -33,6 +33,7 @@ import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; +import { hasCapability } from "@/utils/capabilities"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { all_admin_roles, internalUserRoles } from "@/utils/roles"; import { ActivityMetrics, processActivityData } from "@/components/activity_metrics"; @@ -109,6 +110,8 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const { data: currentUser } = useCurrentUser(); const isAdmin = all_admin_roles.includes(userRole || ""); const canViewTagUsage = isAdmin || internalUserRoles.includes(userRole || ""); + const canViewOrganizationUsage = hasCapability(userRole, "viewOrganizationUsage"); + const canViewAgentUsage = hasCapability(userRole, "viewAgentUsage"); // Debounced search for user selector const [userSearchInput, setUserSearchInput] = useState(""); @@ -513,7 +516,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { setUsageView(value)} - isAdmin={isAdmin} + userRole={userRole} canViewTagUsage={canViewTagUsage} /> @@ -950,7 +953,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { )} {/* Organization Usage Panel */} - {usageView === "organization" && ( + {usageView === "organization" && canViewOrganizationUsage && ( = ({ teams, organizations }) => { /> )} - {usageView === "agent" && ( + {usageView === "agent" && canViewAgentUsage && ( { }); it("should render", () => { - render(); + render(); expect(screen.getByText("Usage View")).toBeInTheDocument(); expect(screen.getByText("Select the usage data you want to view")).toBeInTheDocument(); expect(screen.getByRole("combobox")).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "Your Usage" })).toBeInTheDocument(); }); it("should call onChange when value changes", () => { - render(); + render(); const select = screen.getByRole("combobox"); act(() => { @@ -109,14 +110,34 @@ describe("UsageViewSelect", () => { }); it("should show Tag Usage for non-admin users with tag usage permission", () => { - render(); + render(); expect(screen.getByRole("option", { name: "Tag Usage" })).toBeInTheDocument(); }); it("should hide Tag Usage for non-admin users without tag usage permission", () => { - render(); + render(); expect(screen.queryByRole("option", { name: "Tag Usage" })).not.toBeInTheDocument(); }); + + it.each(["Organization Usage", "Agent Usage (A2A)"])("should show %s to an admin", (optionName) => { + render(); + + expect(screen.getByRole("option", { name: optionName })).toBeInTheDocument(); + }); + + // Neither /organization/daily/activity nor /agent/daily/activity admits an + // internal user, so the option that fires them must not be selectable. + it.each(["Organization Usage", "Agent Usage (A2A)"])("should hide %s from an internal user", (optionName) => { + render(); + + expect(screen.queryByRole("option", { name: optionName })).not.toBeInTheDocument(); + }); + + it.each(["Team Usage", "Tag Usage"])("should keep %s available to an internal user", (optionName) => { + render(); + + expect(screen.getByRole("option", { name: optionName })).toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx index 94b483cb539..54c1d5ab7cc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx @@ -11,6 +11,8 @@ import { } from "@ant-design/icons"; import { Badge, Select } from "antd"; import React from "react"; +import { hasCapability, type Capability } from "@/utils/capabilities"; +import { all_admin_roles } from "@/utils/roles"; export type UsageOption = | "global" | "my-usage" @@ -24,7 +26,7 @@ export type UsageOption = export interface UsageViewSelectProps { value: UsageOption; onChange: (value: UsageOption) => void; - isAdmin: boolean; + userRole: string | null; canViewTagUsage?: boolean; title?: string; description?: string; @@ -35,6 +37,7 @@ interface OptionConfig { label: string; description: string; icon: React.ReactNode; + capability?: Capability; adminOnly?: boolean; showForAdmin?: string; showForNonAdmin?: string; @@ -63,12 +66,9 @@ const OPTIONS: OptionConfig[] = [ { value: "organization", label: "Organization Usage", - showForAdmin: "Organization Usage", - showForNonAdmin: "Your Organization Usage", - description: "View organization-level usage", - descriptionForAdmin: "View usage across all organizations", - descriptionForNonAdmin: "View your organization's usage", + description: "View usage across all organizations", icon: , + capability: "viewOrganizationUsage", }, { value: "team", @@ -95,7 +95,7 @@ const OPTIONS: OptionConfig[] = [ label: "Agent Usage (A2A)", description: "View usage by AI agents", icon: , - adminOnly: true, + capability: "viewAgentUsage", }, { value: "user", @@ -115,14 +115,18 @@ const OPTIONS: OptionConfig[] = [ export const UsageViewSelect: React.FC = ({ value, onChange, - isAdmin, + userRole, canViewTagUsage = false, title = "Usage View", description = "Select the usage data you want to view", "data-id": dataId, }) => { + const isAdmin = all_admin_roles.includes(userRole ?? ""); const getFilteredOptions = () => { return OPTIONS.filter((option) => { + if (option.capability) { + return hasCapability(userRole, option.capability); + } if (option.value === "tag" && canViewTagUsage) { return true; } diff --git a/ui/litellm-dashboard/src/utils/capabilities.test.ts b/ui/litellm-dashboard/src/utils/capabilities.test.ts index f48609b0b9d..3f5a8ac81fb 100644 --- a/ui/litellm-dashboard/src/utils/capabilities.test.ts +++ b/ui/litellm-dashboard/src/utils/capabilities.test.ts @@ -1,21 +1,31 @@ import { describe, expect, it } from "vitest"; -import { hasCapability, rolesWithCapability } from "./capabilities"; +import { hasCapability, rolesWithCapability, type Capability } from "./capabilities"; + +const ADMIN_ROLES = ["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer"]; +const NON_ADMIN_ROLES = [ + "Internal User", + "Internal Viewer", + "App User", + "Org Admin", + "Unknown Role", + "", + null, + undefined, +]; + +const ADMIN_ONLY_CAPABILITIES: Capability[] = ["viewToolPolicies", "viewOrganizationUsage", "viewAgentUsage"]; describe("hasCapability", () => { - it.each(["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer"])( - "should grant viewToolPolicies to %s", - (role) => { - expect(hasCapability(role, "viewToolPolicies")).toBe(true); - }, - ); + describe.each(ADMIN_ONLY_CAPABILITIES)("%s", (capability) => { + it.each(ADMIN_ROLES)("should grant it to %s", (role) => { + expect(hasCapability(role, capability)).toBe(true); + }); - it.each(["Internal User", "Internal Viewer", "App User", "Org Admin", "Unknown Role", "", null, undefined])( - "should deny viewToolPolicies to %s", - (role) => { - expect(hasCapability(role, "viewToolPolicies")).toBe(false); - }, - ); + it.each(NON_ADMIN_ROLES)("should deny it to %s", (role) => { + expect(hasCapability(role, capability)).toBe(false); + }); + }); }); describe("rolesWithCapability", () => { diff --git a/ui/litellm-dashboard/src/utils/capabilities.ts b/ui/litellm-dashboard/src/utils/capabilities.ts index 77ead2568fb..c4d878b81a5 100644 --- a/ui/litellm-dashboard/src/utils/capabilities.ts +++ b/ui/litellm-dashboard/src/utils/capabilities.ts @@ -2,6 +2,8 @@ import { all_admin_roles } from "./roles"; const CAPABILITY_ROLES = { viewToolPolicies: all_admin_roles, + viewOrganizationUsage: all_admin_roles, + viewAgentUsage: all_admin_roles, } as const satisfies Record; export type Capability = keyof typeof CAPABILITY_ROLES; From 2502ee4a2ace88ac10dd8ed30e2a03fff075ce26 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 8 Aug 2026 20:34:56 -0700 Subject: [PATCH 03/18] fix(ui): gate policy and prompt lookups on an admin capability /policies/list and /prompts/list are default-deny for internal_user, but the Virtual Keys create/edit flow, the Teams forms and the Playground called them on mount, so every internal user landing on the dashboard fired two requests that 401. Add viewPolicies and viewPrompts to the capability map and use them to gate the nav entry, the form field and the fetch together, following the pattern from the Tool Policies migration. Non-admins now see no policy or prompt selector at all rather than an empty dropdown. --- .../playground/components/chat_ui/ChatUI.tsx | 56 +++---- .../components/complianceUI/ComplianceUI.tsx | 48 +++--- .../src/components/Teams.test.tsx | 55 ++++++- ui/litellm-dashboard/src/components/Teams.tsx | 68 ++++---- .../src/components/leftnav.test.tsx | 22 +++ .../src/components/leftnav.tsx | 10 +- .../organisms/create_key_button.test.tsx | 49 +++++- .../organisms/create_key_button.tsx | 145 +++++++++--------- .../policies/PolicySelector.test.tsx | 20 +++ .../components/policies/PolicySelector.tsx | 10 +- .../src/components/team/TeamInfo.test.tsx | 46 ++++++ .../src/components/team/TeamInfo.tsx | 56 +++---- .../templates/key_edit_view.test.tsx | 50 +++++- .../components/templates/key_edit_view.tsx | 87 ++++++----- .../src/utils/capabilities.test.ts | 28 ++++ .../src/utils/capabilities.ts | 2 + 16 files changed, 533 insertions(+), 219 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx index 57ff7906eda..0241ef8a77e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx @@ -25,6 +25,7 @@ import React, { useEffect, useRef, useState } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { coy } from "react-syntax-highlighter/dist/esm/styles/prism"; import { v4 as uuidv4 } from "uuid"; +import useCan from "@/app/(dashboard)/hooks/useCan"; import GuardrailSelector from "@/components/guardrails/GuardrailSelector"; import PolicySelector from "@/components/policies/PolicySelector"; import MCPToolArgumentsForm, { MCPToolArgumentsFormRef } from "@/components/mcp_tools/MCPToolArgumentsForm"; @@ -106,6 +107,7 @@ const ChatUI: React.FC = ({ simplified = false, fixedModel, }) => { + const canViewPolicies = useCan("viewPolicies"); const [mcpServers, setMCPServers] = useState([]); const [mcpToolsets, setMCPToolsets] = useState([]); const [isToolsetsInfoModalVisible, setIsToolsetsInfoModalVisible] = useState(false); @@ -1652,32 +1654,34 @@ const ChatUI: React.FC = ({ />
-
- - Policies - - Select policy/policies to apply to this LLM API call. Policies define which guardrails are - applied based on conditions. You can set up your policies{" "} - - here - - . - - } - > - - - - -
+ {canViewPolicies && ( +
+ + Policies + + Select policy/policies to apply to this LLM API call. Policies define which guardrails are + applied based on conditions. You can set up your policies{" "} + + here + + . + + } + > + + + + +
+ )} {/* Code Interpreter Toggle - Only for Responses endpoint */} {endpointType === EndpointType.RESPONSES && ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/complianceUI/ComplianceUI.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/complianceUI/ComplianceUI.tsx index 39346105f2a..c3b417987e6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/complianceUI/ComplianceUI.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/complianceUI/ComplianceUI.tsx @@ -6,6 +6,7 @@ import { type ComplianceFramework, type CompliancePrompt, } from "@/data/compliancePrompts"; +import useCan from "@/app/(dashboard)/hooks/useCan"; import { getGuardrailsList, testPoliciesAndGuardrails } from "@/components/networking"; import PolicySelector, { getPolicyOptionEntries } from "@/components/policies/PolicySelector"; import { Policy } from "@/components/policies/types"; @@ -123,6 +124,7 @@ export default function ComplianceUI({ fixedModel, proxySettings, }: ComplianceUIProps) { + const canViewPolicies = useCan("viewPolicies"); const frameworks = getFrameworks(); const [policyValueToLabel, setPolicyValueToLabel] = useState>(new Map()); @@ -701,29 +703,37 @@ export default function ComplianceUI({

Test Configuration

-

Select policies, guardrails, or both to test against.

+

+ {canViewPolicies + ? "Select policies, guardrails, or both to test against." + : "Select guardrails to test against."} +

-
- - {accessToken && ( - - )} -
+ {canViewPolicies && ( + <> +
+ + {accessToken && ( + + )} +
-
-
- or -
-
+
+
+ or +
+
+ + )}
); })()} +
Estimated Output Tokens: {info.metadata?.default_estimated_output_tokens ?? "Default"}
+
+ Estimated Output Tokens Per Model:{" "} + {info.metadata?.default_estimated_output_tokens_per_model + ? JSON.stringify(info.metadata.default_estimated_output_tokens_per_model) + : "Default"} +
Team Budget diff --git a/ui/litellm-dashboard/src/components/templates/estimatedOutputTokens.test.ts b/ui/litellm-dashboard/src/components/templates/estimatedOutputTokens.test.ts new file mode 100644 index 00000000000..f857ec3efef --- /dev/null +++ b/ui/litellm-dashboard/src/components/templates/estimatedOutputTokens.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; + +import { estimateFields, estimateRules, withNormalizedEstimates } from "./estimatedOutputTokens"; + +const expectRejects = async (value: unknown) => + expect(estimateRules.perModel.validator(null, value)).rejects.toThrow(/JSON object of positive integers/); + +describe("estimateFields", () => { + it("renders a stored per-model map as editable JSON text", () => { + expect( + estimateFields({ + default_estimated_output_tokens: 2048, + default_estimated_output_tokens_per_model: { "gpt-4": 4096 }, + }), + ).toEqual({ + default_estimated_output_tokens: 2048, + default_estimated_output_tokens_per_model: '{"gpt-4":4096}', + }); + }); + + it("leaves the controls blank when metadata carries neither setting", () => { + expect(estimateFields({ unrelated: true })).toEqual({ + default_estimated_output_tokens: undefined, + default_estimated_output_tokens_per_model: "", + }); + }); + + it("tolerates absent metadata", () => { + expect(estimateFields(null).default_estimated_output_tokens_per_model).toBe(""); + expect(estimateFields(undefined).default_estimated_output_tokens_per_model).toBe(""); + }); +}); + +describe("estimateRules.perModel", () => { + it("accepts a blank control", async () => { + await expect(estimateRules.perModel.validator(null, "")).resolves.toBeUndefined(); + await expect(estimateRules.perModel.validator(null, " ")).resolves.toBeUndefined(); + await expect(estimateRules.perModel.validator(null, undefined)).resolves.toBeUndefined(); + }); + + it("accepts a per-model object", async () => { + await expect(estimateRules.perModel.validator(null, '{"gpt-4": 4096}')).resolves.toBeUndefined(); + }); + + it("rejects text that is not JSON", async () => { + await expectRejects("gpt-4: 4096"); + }); + + it("rejects JSON that is not an object, which the API would refuse", async () => { + await expectRejects("4096"); + await expectRejects('"gpt-4"'); + await expectRejects("[4096]"); + await expectRejects("null"); + }); + + it("rejects a per-model map whose values the runtime would ignore", async () => { + await expectRejects('{"gpt-4": -5}'); + await expectRejects('{"gpt-4": 0}'); + await expectRejects('{"gpt-4": 4.5}'); + await expectRejects('{"gpt-4": "4096"}'); + await expectRejects("{}"); + }); +}); + +describe("withNormalizedEstimates", () => { + it("coerces the numeric control and parses the per-model control without mutating the input", () => { + const values = { + default_estimated_output_tokens: "2048", + default_estimated_output_tokens_per_model: '{"gpt-4": 4096}', + other: "untouched", + }; + const before = { ...values }; + + expect(withNormalizedEstimates(values)).toEqual({ + default_estimated_output_tokens: 2048, + default_estimated_output_tokens_per_model: { "gpt-4": 4096 }, + other: "untouched", + }); + expect(values).toEqual(before); + }); + + it("drops blank controls so a save never sends an empty value", () => { + expect( + withNormalizedEstimates({ + default_estimated_output_tokens: "", + default_estimated_output_tokens_per_model: " ", + }), + ).toEqual({}); + }); + + it("drops each control independently", () => { + expect( + withNormalizedEstimates({ + default_estimated_output_tokens: 900, + default_estimated_output_tokens_per_model: "", + }), + ).toEqual({ default_estimated_output_tokens: 900 }); + }); + + it("drops a per-model map the API would reject rather than sending it", () => { + expect( + withNormalizedEstimates({ + default_estimated_output_tokens_per_model: '{"gpt-4": -5}', + }), + ).toEqual({}); + }); +}); + +describe("estimateRules.positive", () => { + it("accepts a blank control and a positive integer", async () => { + await expect(estimateRules.positive.validator(null, "")).resolves.toBeUndefined(); + await expect(estimateRules.positive.validator(null, 2048)).resolves.toBeUndefined(); + }); + + it("rejects values the runtime would ignore", async () => { + await expect(estimateRules.positive.validator(null, 0)).rejects.toThrow(/positive integer/); + await expect(estimateRules.positive.validator(null, -5)).rejects.toThrow(/positive integer/); + await expect(estimateRules.positive.validator(null, 12.5)).rejects.toThrow(/positive integer/); + }); +}); diff --git a/ui/litellm-dashboard/src/components/templates/estimatedOutputTokens.ts b/ui/litellm-dashboard/src/components/templates/estimatedOutputTokens.ts new file mode 100644 index 00000000000..842d7d2bb68 --- /dev/null +++ b/ui/litellm-dashboard/src/components/templates/estimatedOutputTokens.ts @@ -0,0 +1,77 @@ +type Metadata = Record | null | undefined; + +type FormValues = Record; + +const ESTIMATE_FIELD = "default_estimated_output_tokens"; +const PER_MODEL_FIELD = "default_estimated_output_tokens_per_model"; + +const INVALID_PER_MODEL_MESSAGE = 'Enter a JSON object of positive integers, e.g. {"gpt-4": 4096}'; + +const perModelEstimateToText = (value: unknown): string => + value != null && typeof value === "object" ? JSON.stringify(value) : ""; + +const isPositiveInteger = (value: unknown): boolean => + typeof value === "number" && Number.isInteger(value) && value > 0; + +const parsePerModelEstimates = (value: string): Record | null => { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return null; + } + if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const entries = Object.entries(parsed as Record); + if (entries.length === 0 || !entries.every(([, v]) => isPositiveInteger(v))) return null; + return Object.fromEntries(entries) as Record; +}; + +export const estimateFields = (metadata: Metadata) => ({ + [ESTIMATE_FIELD]: metadata?.[ESTIMATE_FIELD], + [PER_MODEL_FIELD]: perModelEstimateToText(metadata?.[PER_MODEL_FIELD]), +}); + +const ADMIN_ONLY_TOOLTIP = + "Only a proxy admin can change this. It sets how many output tokens the rate limiter reserves for a request " + + "that omits max_tokens, which is charged against the team and organization TPM windows."; + +export const estimateTooltips = (canEdit: boolean, entity: "key" | "team" = "key") => ({ + estimate: canEdit + ? `Expected output tokens reserved for TPM limiting when a request omits max_tokens. Overrides the built-in estimate for this ${entity}.` + : ADMIN_ONLY_TOOLTIP, + perModel: canEdit + ? `Per-model expected output tokens reserved for TPM limiting when a request omits max_tokens. Takes precedence over the ${entity}-wide estimate.` + : ADMIN_ONLY_TOOLTIP, +}); + +export const estimateRules = { + perModel: { + validator: (_: unknown, value: unknown) => { + if (typeof value !== "string" || value.trim() === "") return Promise.resolve(); + return parsePerModelEstimates(value) === null + ? Promise.reject(new Error(INVALID_PER_MODEL_MESSAGE)) + : Promise.resolve(); + }, + }, + positive: { + validator: (_: unknown, value: unknown) => { + if (value === "" || value === null || value === undefined) return Promise.resolve(); + return isPositiveInteger(Number(value)) + ? Promise.resolve() + : Promise.reject(new Error("Enter a positive integer")); + }, + }, +}; + +export const withNormalizedEstimates = (values: T): FormValues => { + const { [ESTIMATE_FIELD]: estimate, [PER_MODEL_FIELD]: perModel, ...rest } = values; + + const normalizedEstimate = estimate === "" || estimate === null || estimate === undefined ? null : Number(estimate); + const normalizedPerModel = typeof perModel === "string" ? parsePerModelEstimates(perModel) : null; + + return { + ...rest, + ...(normalizedEstimate === null ? {} : { [ESTIMATE_FIELD]: normalizedEstimate }), + ...(normalizedPerModel === null ? {} : { [PER_MODEL_FIELD]: normalizedPerModel }), + }; +}; diff --git a/ui/litellm-dashboard/src/components/templates/keyEditFieldNormalizers.ts b/ui/litellm-dashboard/src/components/templates/keyEditFieldNormalizers.ts new file mode 100644 index 00000000000..ee8d9ebf685 --- /dev/null +++ b/ui/litellm-dashboard/src/components/templates/keyEditFieldNormalizers.ts @@ -0,0 +1,19 @@ +const WORD_FORM_BUDGET_DURATIONS: Record = { + hourly: "1h", + daily: "24h", + weekly: "7d", + monthly: "30d", +}; + +// Normalize any legacy word-form budget duration to the canonical value the dropdown uses +export const canonicalBudgetDuration = (duration: string | null | undefined): string | null => + duration ? WORD_FORM_BUDGET_DURATIONS[duration] ?? duration : null; + +// Determine the key_type display value from allowed_routes +export const keyTypeFromRoutes = (allowedRoutes: string[] | null | undefined): string => { + if (!allowedRoutes || allowedRoutes.length === 0) return "default"; + if (allowedRoutes.includes("llm_api_routes")) return "llm_api"; + if (allowedRoutes.includes("management_routes")) return "management"; + if (allowedRoutes.includes("info_routes")) return "read_only"; + return "default"; +}; diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx index 9030a027928..3c75982612e 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx @@ -1348,4 +1348,135 @@ describe("KeyEditView", () => { }); }); }); + + describe("estimated output tokens", () => { + const renderEditView = ( + keyData: KeyResponse, + onSubmit: (values: any) => Promise, + userRole: string = "Admin", + ) => + renderWithProviders( + {}} + onSubmit={onSubmit} + accessToken={"test-token"} + userID={"test-user"} + userRole={userRole} + premiumUser={false} + />, + ); + + it("loads the estimates from key metadata and resubmits them unchanged", async () => { + const onSubmitMock = vi.fn().mockResolvedValue(undefined); + renderEditView( + { + ...MOCK_KEY_DATA, + metadata: { + ...MOCK_KEY_DATA.metadata, + default_estimated_output_tokens: 512, + default_estimated_output_tokens_per_model: { "gpt-4": 4096 }, + }, + }, + onSubmitMock, + ); + + await waitFor(() => { + expect(screen.getByLabelText("Estimated Output Tokens")).toHaveValue(512); + }); + expect(screen.getByLabelText("Estimated Output Tokens Per Model")).toHaveValue('{"gpt-4":4096}'); + + await userEvent.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled(); + }); + const callArgs = onSubmitMock.mock.calls[0][0]; + expect(callArgs.default_estimated_output_tokens).toBe(512); + expect(callArgs.default_estimated_output_tokens_per_model).toEqual({ "gpt-4": 4096 }); + }); + + it("submits edited estimates as a number and a parsed object", async () => { + const onSubmitMock = vi.fn().mockResolvedValue(undefined); + renderEditView(MOCK_KEY_DATA, onSubmitMock); + + await waitFor(() => { + expect(screen.getByLabelText("Estimated Output Tokens")).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByLabelText("Estimated Output Tokens"), { target: { value: "2048" } }); + fireEvent.change(screen.getByLabelText("Estimated Output Tokens Per Model"), { + target: { value: '{"gpt-5": 8192}' }, + }); + + await userEvent.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled(); + }); + const callArgs = onSubmitMock.mock.calls[0][0]; + expect(callArgs.default_estimated_output_tokens).toBe(2048); + expect(callArgs.default_estimated_output_tokens_per_model).toEqual({ "gpt-5": 8192 }); + }); + + it("omits both estimates from the payload when the controls are blank", async () => { + const onSubmitMock = vi.fn().mockResolvedValue(undefined); + renderEditView(MOCK_KEY_DATA, onSubmitMock); + + await waitFor(() => { + expect(screen.getByLabelText("Estimated Output Tokens Per Model")).toHaveValue(""); + }); + + await userEvent.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled(); + }); + const callArgs = onSubmitMock.mock.calls[0][0]; + expect(callArgs).not.toHaveProperty("default_estimated_output_tokens"); + expect(callArgs).not.toHaveProperty("default_estimated_output_tokens_per_model"); + }); + + it.each(["Internal User", "Admin Viewer", "org_admin"])( + "leaves both controls read-only for %s and still resubmits the stored values", + async (userRole) => { + const onSubmitMock = vi.fn().mockResolvedValue(undefined); + renderEditView( + { + ...MOCK_KEY_DATA, + metadata: { + ...MOCK_KEY_DATA.metadata, + default_estimated_output_tokens: 512, + default_estimated_output_tokens_per_model: { "gpt-4": 4096 }, + }, + }, + onSubmitMock, + userRole, + ); + + await waitFor(() => { + expect(screen.getByLabelText("Estimated Output Tokens")).toBeDisabled(); + }); + expect(screen.getByLabelText("Estimated Output Tokens Per Model")).toBeDisabled(); + + await userEvent.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled(); + }); + const callArgs = onSubmitMock.mock.calls[0][0]; + expect(callArgs.default_estimated_output_tokens).toBe(512); + expect(callArgs.default_estimated_output_tokens_per_model).toEqual({ "gpt-4": 4096 }); + }, + ); + + it.each(["Admin", "proxy_admin"])("leaves both controls editable for %s", async (userRole) => { + renderEditView(MOCK_KEY_DATA, vi.fn().mockResolvedValue(undefined), userRole); + + await waitFor(() => { + expect(screen.getByLabelText("Estimated Output Tokens")).toBeEnabled(); + }); + expect(screen.getByLabelText("Estimated Output Tokens Per Model")).toBeEnabled(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx index 2a02cf3edd0..b5538f0ca59 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -7,7 +7,7 @@ import { InfoCircleOutlined } from "@ant-design/icons"; import { TextInput, Button as TremorButton } from "@tremor/react"; import { Form, Input, Select, Switch, Tooltip } from "antd"; import { useEffect, useState } from "react"; -import { rolesWithWriteAccess } from "../../utils/roles"; +import { isProxyAdminRole, rolesWithWriteAccess } from "../../utils/roles"; import AgentSelector from "../agent_management/AgentSelector"; import AccessGroupSelector from "../common_components/AccessGroupSelector"; import BudgetDurationDropdown from "../common_components/budget_duration_dropdown"; @@ -17,6 +17,8 @@ import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSel import RateLimitTypeFormItem from "../common_components/RateLimitTypeFormItem"; import OrganizationDropdown from "../common_components/OrganizationDropdown"; import { extractLoggingSettings, formatMetadataForDisplay, stripTagsFromMetadata } from "../key_info_utils"; +import { estimateFields, estimateRules, estimateTooltips, withNormalizedEstimates } from "./estimatedOutputTokens"; +import { canonicalBudgetDuration, keyTypeFromRoutes } from "./keyEditFieldNormalizers"; import { BudgetFallbacksEditor } from "../key_team_helpers/BudgetFallbacksEditor"; import { BudgetWindowEntry, BudgetWindowsEditor } from "../key_team_helpers/BudgetWindowsEditor"; import { @@ -49,29 +51,6 @@ interface KeyEditViewProps { premiumUser?: boolean; } -// Add this helper function - -// Helper function to determine key_type display value from allowed_routes -const getKeyTypeFromRoutes = (allowedRoutes: string[] | null | undefined): string => { - if (!allowedRoutes || allowedRoutes.length === 0) { - return "default"; - } - - if (allowedRoutes.includes("llm_api_routes")) { - return "llm_api"; - } - - if (allowedRoutes.includes("management_routes")) { - return "management"; - } - - if (allowedRoutes.includes("info_routes")) { - return "read_only"; - } - - return "default"; -}; - export function KeyEditView({ keyData, onCancel, @@ -83,6 +62,8 @@ export function KeyEditView({ premiumUser = false, }: KeyEditViewProps) { const canEditGuardrails = premiumUser || (userRole != null && rolesWithWriteAccess.includes(userRole)); + const canEditEstimates = userRole != null && isProxyAdminRole(userRole); + const estimateTooltip = estimateTooltips(canEditEstimates); const [form] = Form.useForm(); const [promptsList, setPromptsList] = useState([]); const [tagsList, setTagsList] = useState>({}); @@ -157,27 +138,16 @@ export function KeyEditView({ form.setFieldValue("disabled_callbacks", disabledCallbacks); }, [form, disabledCallbacks]); - // Normalize any legacy word-form budget duration to the canonical value the dropdown uses - const getBudgetDuration = (duration: string | null) => { - if (!duration) return null; - const wordToCanonical: Record = { - hourly: "1h", - daily: "24h", - weekly: "7d", - monthly: "30d", - }; - return wordToCanonical[duration] ?? duration; - }; - // Set initial form values const initialValues = { ...keyData, token: keyData.token || keyData.token_id, - budget_duration: getBudgetDuration(keyData.budget_duration), + budget_duration: canonicalBudgetDuration(keyData.budget_duration), metadata: formatMetadataForDisplay(stripTagsFromMetadata(keyData.metadata)), guardrails: keyData.metadata?.guardrails, disable_global_guardrails: keyData.metadata?.disable_global_guardrails || false, throttle_on_budget_exceeded: keyData.metadata?.throttle_on_budget_exceeded || false, + ...estimateFields(keyData.metadata), prompts: keyData.metadata?.prompts, tags: keyData.metadata?.tags, vector_stores: keyData.object_permission?.vector_stores || [], @@ -208,7 +178,7 @@ export function KeyEditView({ form.setFieldsValue({ ...keyData, token: keyData.token || keyData.token_id, - budget_duration: getBudgetDuration(keyData.budget_duration), + budget_duration: canonicalBudgetDuration(keyData.budget_duration), metadata: formatMetadataForDisplay(stripTagsFromMetadata(keyData.metadata)), guardrails: keyData.metadata?.guardrails, disable_global_guardrails: keyData.metadata?.disable_global_guardrails || false, @@ -222,6 +192,7 @@ export function KeyEditView({ }, mcp_tool_permissions: keyData.object_permission?.mcp_tool_permissions || {}, throttle_on_budget_exceeded: keyData.metadata?.throttle_on_budget_exceeded || false, + ...estimateFields(keyData.metadata), logging_settings: extractLoggingSettings(keyData.metadata), disabled_callbacks: Array.isArray(keyData.metadata?.litellm_disabled_callbacks) ? mapInternalToDisplayNames(keyData.metadata.litellm_disabled_callbacks) @@ -339,7 +310,7 @@ export function KeyEditView({ values.budget_fallbacks = {}; } - await onSubmit(values); + await onSubmit(withNormalizedEstimates(values)); } finally { setIsKeySaving(false); } @@ -418,7 +389,7 @@ export function KeyEditView({ > {({ getFieldValue, setFieldValue }) => { const allowedRoutesValue = getFieldValue("allowed_routes") || ""; - // Convert string to array for getKeyTypeFromRoutes + // Convert string to array for keyTypeFromRoutes const allowedRoutes = typeof allowedRoutesValue === "string" && allowedRoutesValue.trim() !== "" ? allowedRoutesValue @@ -426,7 +397,7 @@ export function KeyEditView({ .map((r: string) => r.trim()) .filter((r: string) => r.length > 0) : []; - const keyTypeValue = getKeyTypeFromRoutes(allowedRoutes); + const keyTypeValue = keyTypeFromRoutes(allowedRoutes); return (