From e88364d888fc301a4a68d9838b3f3c5f83f60c42 Mon Sep 17 00:00:00 2001 From: Aryan Gupta Date: Thu, 10 Sep 2026 12:20:10 +0530 Subject: [PATCH 1/4] feat: Add project usage view to the Usage page (BerriAI/litellm/issues/40386) --- .../ProjectSpendBreakdown.test.tsx | 66 ++++++++ .../ProjectUsage/ProjectSpendBreakdown.tsx | 84 ++++++++++ .../ProjectUsage/ProjectUsage.test.tsx | 87 ++++++++++ .../components/ProjectUsage/ProjectUsage.tsx | 158 ++++++++++++++++++ .../projectUsageAggregations.test.ts | 106 ++++++++++++ .../ProjectUsage/projectUsageAggregations.ts | 86 ++++++++++ .../_components/components/UsagePageView.tsx | 19 +++ .../UsageViewSelect/UsageViewSelect.test.tsx | 18 +- .../UsageViewSelect/UsageViewSelect.tsx | 10 +- .../src/components/networking.tsx | 32 ++++ .../src/utils/capabilities.test.ts | 1 + .../src/utils/capabilities.ts | 1 + 12 files changed, 660 insertions(+), 8 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.test.tsx new file mode 100644 index 00000000000..4e67c870de0 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import ProjectSpendBreakdown from "./ProjectSpendBreakdown"; +import type { ProjectSpendRow } from "./projectUsageAggregations"; + +vi.mock("@/components/shared/chart_loader", () => ({ + ChartLoader: ({ isDateChanging }: { isDateChanging: boolean }) => ( +
{isDateChanging ? "Processing date selection..." : "Loading chart data..."}
+ ), +})); + +const mockProjectSpend: ProjectSpendRow[] = [ + { + project_id: "project-alpha", + project_alias: "Project Alpha", + spend: 150.5, + requests: 100, + successful_requests: 95, + failed_requests: 5, + tokens: 50000, + }, + { + project_id: "project-beta", + project_alias: "Project Beta", + spend: 200.75, + requests: 120, + successful_requests: 115, + failed_requests: 5, + tokens: 75000, + }, +]; + +describe("ProjectSpendBreakdown", () => { + it("displays the title", () => { + render(); + expect(screen.getByText("Spend by Project")).toBeInTheDocument(); + }); + + it("shows the loader instead of chart content while loading", () => { + render(); + expect(screen.getByTestId("chart-loader")).toBeInTheDocument(); + expect(screen.queryByText("No project usage data")).not.toBeInTheDocument(); + }); + + it("displays table headers", () => { + render(); + expect(screen.getByText("Project")).toBeInTheDocument(); + expect(screen.getByText("Spend")).toBeInTheDocument(); + expect(screen.getByText("Successful")).toBeInTheDocument(); + expect(screen.getByText("Failed")).toBeInTheDocument(); + expect(screen.getByText("Tokens")).toBeInTheDocument(); + }); + + it("displays each project's alias and formatted spend in the table", () => { + render(); + expect(screen.getAllByText("Project Alpha").length).toBeGreaterThan(0); + expect(screen.getAllByText("Project Beta").length).toBeGreaterThan(0); + expect(screen.getByText("$150.50")).toBeInTheDocument(); + expect(screen.getByText("$200.75")).toBeInTheDocument(); + }); + + it("shows the empty message when no project has usage", () => { + render(); + expect(screen.getByText("No project usage data")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.tsx new file mode 100644 index 00000000000..e7e41126861 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import type { ColumnDef } from "@tanstack/react-table"; + +import { DonutChart } from "@/components/shared/charts"; +import { DataTable } from "@/components/shared/DataTable"; +import { MoneyCell } from "@/components/shared/table_cells"; +import { ChartLoader } from "@/components/shared/chart_loader"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; + +import type { ProjectSpendRow } from "./projectUsageAggregations"; + +interface ProjectSpendBreakdownProps { + loading: boolean; + isDateChanging: boolean; + projectSpend: ProjectSpendRow[]; +} + +const columns: ColumnDef[] = [ + { + header: "Project", + accessorKey: "project_alias", + }, + { + header: "Spend", + accessorKey: "spend", + meta: { numeric: true }, + cell: ({ row }) => , + }, + { + header: "Successful", + accessorKey: "successful_requests", + meta: { numeric: true, className: "text-success" }, + cell: ({ row }) => row.original.successful_requests.toLocaleString(), + }, + { + header: "Failed", + accessorKey: "failed_requests", + meta: { numeric: true, className: "text-destructive" }, + cell: ({ row }) => row.original.failed_requests.toLocaleString(), + }, + { + header: "Tokens", + accessorKey: "tokens", + meta: { numeric: true }, + cell: ({ row }) => row.original.tokens.toLocaleString(), + }, +]; + +const ProjectSpendBreakdown: React.FC = ({ loading, isDateChanging, projectSpend }) => ( + + + Spend by Project + + + {loading ? ( + + ) : ( +
+ `$${formatNumberWithCommas(value, 2)}`} + colors={["cyan"]} + showLabel + startAngle={90} + endAngle={-270} + /> + row.project_id} + noDataMessage="No project usage data" + size="compact" + /> +
+ )} +
+
+); + +export default ProjectSpendBreakdown; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.test.tsx new file mode 100644 index 00000000000..1e9e0dffefb --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.test.tsx @@ -0,0 +1,87 @@ +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { renderWithProviders, screen, testQueryClient, waitFor } from "@/../tests/test-utils"; +import * as networking from "@/components/networking"; + +import ProjectUsage from "./ProjectUsage"; +import type { EntityList } from "../EntityUsage/EntityUsage"; + +vi.mock("@/components/networking", () => ({ + projectDailyActivityCall: vi.fn(), +})); + +const PROJECT_LIST: EntityList[] = [ + { label: "Project Alpha", value: "project-alpha" }, + { label: "Project Beta", value: "project-beta" }, +]; + +const DATE_VALUE = { from: new Date("2026-09-01"), to: new Date("2026-09-08") }; + +describe("ProjectUsage", () => { + const mockProjectDailyActivityCall = vi.mocked(networking.projectDailyActivityCall); + + beforeEach(() => { + vi.clearAllMocks(); + testQueryClient.clear(); + }); + + it("shows an enterprise upsell and never calls the API when the caller is not a premium user", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Project Usage is an Enterprise feature")).toBeInTheDocument(); + expect(mockProjectDailyActivityCall).not.toHaveBeenCalled(); + }); + + it("prompts for a project before fetching anything", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Select at least one project above to view its usage.")).toBeInTheDocument(); + expect(mockProjectDailyActivityCall).not.toHaveBeenCalled(); + }); + + it("fetches and renders the picked project's daily spend", async () => { + mockProjectDailyActivityCall.mockResolvedValue({ + start_date: "2026-09-01", + end_date: "2026-09-08", + results: [ + { + date: "2026-09-01", + project_id: "project-alpha", + project_alias: "Project Alpha", + spend: 12.5, + prompt_tokens: 100, + completion_tokens: 50, + total_tokens: 150, + api_requests: 4, + successful_requests: 3, + failed_requests: 1, + }, + ], + }); + + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("option", { name: "Project Alpha" })); + + await waitFor(() => expect(mockProjectDailyActivityCall).toHaveBeenCalledTimes(1)); + expect(mockProjectDailyActivityCall).toHaveBeenCalledWith( + "test-token", + DATE_VALUE.from, + DATE_VALUE.to, + ["project-alpha"], + ); + + await waitFor(() => expect(screen.getAllByText("$12.50").length).toBeGreaterThan(0)); + expect(screen.getAllByText("4").length).toBeGreaterThan(0); + expect(screen.getAllByText("Project Alpha").length).toBeGreaterThan(0); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.tsx new file mode 100644 index 00000000000..b4f06af67c8 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.tsx @@ -0,0 +1,158 @@ +import React, { useMemo, useState } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { Info } from "lucide-react"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; +import { BarChart } from "@/components/shared/charts"; +import { ChartLoader } from "@/components/shared/chart_loader"; +import { MultiSelect, type MultiSelectOption } from "@/components/shared/MultiSelect"; +import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; +import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { projectDailyActivityCall } from "@/components/networking"; +import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters"; + +import { buildSummaryTiles, type SummaryTile } from "../EntityUsage/entityUsageSummary"; +import type { EntityList } from "../EntityUsage/EntityUsage"; +import ProjectSpendBreakdown from "./ProjectSpendBreakdown"; +import { buildDailySpendSeries, buildProjectSpendBreakdown, summarizeProjectUsage } from "./projectUsageAggregations"; + +interface ProjectUsageProps { + accessToken: string | null; + projectList: EntityList[] | null; + dateValue: DateRangePickerValue; + premiumUser: boolean; +} + +const renderSummaryTile = ({ title, value, className }: SummaryTile) => ( + + +

{title}

+

{value}

+
+
+); + +const ProjectUsage: React.FC = ({ accessToken, projectList, dateValue, premiumUser }) => { + const [selectedProjectIds, setSelectedProjectIds] = useState([]); + + const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]); + const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]); + + const projectOptions = useMemo( + () => (projectList ?? []).map((project) => ({ label: project.label || project.value, value: project.value })), + [projectList], + ); + + const hasSelection = selectedProjectIds.length > 0; + const hasDateRange = !!startTime && !!endTime; + const hasRequestWindow = premiumUser && !!accessToken && hasDateRange; + const enabled = hasRequestWindow && hasSelection; + + const queryOptions = { + queryKey: ["project-daily-activity", selectedProjectIds, startTime?.toISOString(), endTime?.toISOString()], + queryFn: () => projectDailyActivityCall(accessToken as string, startTime as Date, endTime as Date, selectedProjectIds), + enabled, + placeholderData: keepPreviousData, + }; + const { data, isFetching, isPlaceholderData } = useQuery(queryOptions); + + if (!premiumUser) { + return ( + + Project Usage is an Enterprise feature + + Filtering usage by project requires a LiteLLM Enterprise license. Get a 7 day trial at{" "} + + litellm.ai/enterprise + + . + + + ); + } + + const rows = data?.results ?? []; + const summary = summarizeProjectUsage(rows); + const dailySpend = buildDailySpendSeries(rows); + const projectBreakdown = buildProjectSpendBreakdown(rows); + const isLoadingRows = isFetching && !isPlaceholderData; + + return ( +
+
+ + +
+

Projects

+ + } /> + Select one or more projects to compare their usage + +
+ +
+
+
+ + {!hasSelection ? ( +
+ + +

+ Select at least one project above to view its usage. +

+
+
+
+ ) : ( + <> +
+ + +

Project Spend Overview

+
+ {buildSummaryTiles(summary, false).map(renderSummaryTile)} +
+
+
+
+ +
+ + + Daily Spend + + + {isLoadingRows ? ( + + ) : ( + + )} + + +
+ +
+ +
+ + )} +
+ ); +}; + +export default ProjectUsage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts new file mode 100644 index 00000000000..090ee973dd4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; + +import type { ProjectDailySpendRow } from "@/components/networking"; + +import { buildDailySpendSeries, buildProjectSpendBreakdown, summarizeProjectUsage } from "./projectUsageAggregations"; + +const row = (overrides: Partial = {}): ProjectDailySpendRow => ({ + date: "2026-09-01", + project_id: "project-alpha", + project_alias: "Project Alpha", + spend: 1, + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15, + api_requests: 3, + successful_requests: 2, + failed_requests: 1, + ...overrides, +}); + +describe("summarizeProjectUsage", () => { + it("returns all-zero totals for no rows", () => { + expect(summarizeProjectUsage([])).toEqual({ + total_spend: 0, + total_api_requests: 0, + total_successful_requests: 0, + total_failed_requests: 0, + total_tokens: 0, + }); + }); + + it("sums spend, requests, and tokens across every row regardless of project or date", () => { + const projectBetaRow: Partial = { + project_id: "project-beta", + date: "2026-09-02", + spend: 2.25, + api_requests: 4, + successful_requests: 4, + failed_requests: 0, + total_tokens: 40, + }; + const rows = [row({ spend: 1.5 }), row(projectBetaRow)]; + + expect(summarizeProjectUsage(rows)).toEqual({ + total_spend: 3.75, + total_api_requests: 7, + total_successful_requests: 6, + total_failed_requests: 1, + total_tokens: 55, + }); + }); +}); + +describe("buildDailySpendSeries", () => { + it("sums same-day spend across projects into a single point", () => { + const rows = [row(), row({ project_id: "project-beta", spend: 2 })]; + + expect(buildDailySpendSeries(rows)).toEqual([{ date: "2026-09-01", spend: 3 }]); + }); + + it("sorts distinct dates oldest first regardless of input order", () => { + const rows = [row({ date: "2026-09-03", spend: 3 }), row(), row({ date: "2026-09-02", spend: 2 })]; + + expect(buildDailySpendSeries(rows).map((point) => point.date)).toEqual(["2026-09-01", "2026-09-02", "2026-09-03"]); + }); +}); + +describe("buildProjectSpendBreakdown", () => { + it("sums a project's spend and tokens across every day into a single row", () => { + const secondDayRow: Partial = { + date: "2026-09-02", + spend: 2, + total_tokens: 20, + successful_requests: 1, + failed_requests: 2, + }; + const rows = [row({ total_tokens: 10, api_requests: 2, failed_requests: 0 }), row(secondDayRow)]; + + expect(buildProjectSpendBreakdown(rows)).toEqual([ + { + project_id: "project-alpha", + project_alias: "Project Alpha", + spend: 3, + requests: 5, + successful_requests: 3, + failed_requests: 2, + tokens: 30, + }, + ]); + }); + + it("sorts projects by spend descending", () => { + const rows = [ + row({ project_id: "project-cheap", project_alias: "Cheap" }), + row({ project_id: "project-costly", project_alias: "Costly", spend: 9 }), + ]; + + expect(buildProjectSpendBreakdown(rows).map((r) => r.project_id)).toEqual(["project-costly", "project-cheap"]); + }); + + it("falls back to the project id when the project has no alias", () => { + const rows = [row({ project_id: "project-untitled", project_alias: null })]; + + expect(buildProjectSpendBreakdown(rows)[0].project_alias).toBe("project-untitled"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts new file mode 100644 index 00000000000..8a0422fbadf --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts @@ -0,0 +1,86 @@ +import type { ProjectDailySpendRow } from "@/components/networking"; + +/** One row per project, summed across the whole range: the shape the breakdown table and donut chart render. */ +export interface ProjectSpendRow extends Record { + project_id: string; + project_alias: string; + spend: number; + requests: number; + successful_requests: number; + failed_requests: number; + tokens: number; +} + +/** One point per day, spend summed across every selected project: the shape the daily spend chart renders. */ +export interface DailyProjectSpendPoint extends Record { + date: string; + spend: number; +} + +export interface ProjectUsageSummary { + total_spend: number; + total_api_requests: number; + total_successful_requests: number; + total_failed_requests: number; + total_tokens: number; +} + +const EMPTY_SUMMARY: ProjectUsageSummary = { + total_spend: 0, + total_api_requests: 0, + total_successful_requests: 0, + total_failed_requests: 0, + total_tokens: 0, +}; + +/** Aggregate totals across every row, for the summary tiles. */ +export const summarizeProjectUsage = (rows: ProjectDailySpendRow[]): ProjectUsageSummary => + rows.reduce( + (totals, row) => ({ + total_spend: totals.total_spend + row.spend, + total_api_requests: totals.total_api_requests + row.api_requests, + total_successful_requests: totals.total_successful_requests + row.successful_requests, + total_failed_requests: totals.total_failed_requests + row.failed_requests, + total_tokens: totals.total_tokens + row.total_tokens, + }), + EMPTY_SUMMARY, + ); + +/** One point per day, spend summed across every selected project, sorted oldest first. */ +export const buildDailySpendSeries = (rows: ProjectDailySpendRow[]): DailyProjectSpendPoint[] => { + const spendByDate = new Map(); + rows.forEach((row) => { + spendByDate.set(row.date, (spendByDate.get(row.date) ?? 0) + row.spend); + }); + return Array.from(spendByDate, ([date, spend]) => ({ date, spend })).sort((a, b) => a.date.localeCompare(b.date)); +}; + +/** + * One row per project, spend/tokens/requests summed across the whole range, sorted by + * spend descending like every other "top X" breakdown on the usage page. + */ +export const buildProjectSpendBreakdown = (rows: ProjectDailySpendRow[]): ProjectSpendRow[] => { + const byProject = new Map(); + rows.forEach((row) => { + const existing = byProject.get(row.project_id); + if (existing) { + existing.spend += row.spend; + existing.requests += row.api_requests; + existing.successful_requests += row.successful_requests; + existing.failed_requests += row.failed_requests; + existing.tokens += row.total_tokens; + return; + } + const newRow: ProjectSpendRow = { + project_id: row.project_id, + project_alias: row.project_alias || row.project_id, + spend: row.spend, + requests: row.api_requests, + successful_requests: row.successful_requests, + failed_requests: row.failed_requests, + tokens: row.total_tokens, + }; + byProject.set(row.project_id, newRow); + }); + return Array.from(byProject.values()).sort((a, b) => b.spend - a.spend); +}; 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 a92d1209567..55aae0940a0 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 @@ -20,6 +20,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents"; import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers"; +import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; @@ -59,6 +60,7 @@ import { import EndpointUsage from "./EndpointUsage/EndpointUsage"; import EntityUsage, { EntityList } from "./EntityUsage/EntityUsage"; import ModelViewToggle, { ModelViewType } from "./ModelViewToggle"; +import ProjectUsage from "./ProjectUsage/ProjectUsage"; import SpendByProvider from "./EntityUsage/SpendByProvider"; import { TOP_MODEL_LIMITS } from "./EntityUsage/TopModelView"; import TopKeyView from "@/components/UsagePage/components/EntityUsage/TopKeyView"; @@ -102,12 +104,14 @@ const UsagePage: React.FC = ({ teams, organizations }) => { // filter reads as loading rather than as a range with no customers. const { data: customers } = useCustomers(); const { data: agentsResponse } = useAgents(); + const { data: projectsResponse } = useProjects(); const { data: currentUser } = useCurrentUser(); const isAdmin = all_admin_roles.includes(userRole || ""); const canViewTagUsage = isAdmin || internalUserRoles.includes(userRole || ""); const isOrgAdmin = useIsOrgAdmin(); const canViewOrganizationUsage = hasCapability(userRole, "viewOrganizationUsage", isOrgAdmin); const canViewAgentUsage = hasCapability(userRole, "viewAgentUsage"); + const canViewProjectUsage = hasCapability(userRole, "viewProjectUsage"); // For admins: null means global view (all users), a string means filter by that user // For non-admins: always set to their own user ID @@ -935,6 +939,21 @@ const UsagePage: React.FC = ({ teams, organizations }) => { /> )} + {/* Project Usage Panel */} + {usageView === "project" && canViewProjectUsage && ( + ({ + label: project.project_alias || project.project_id, + value: project.project_id, + })) || null + } + dateValue={dateValue} + premiumUser={premiumUser} + /> + )} + {/* Customer Usage Panel */} {usageView === "customer" && ( { expect(offers(container, "Tag Usage")).toBe(false); }); - it.each(["Organization Usage", "Agent Usage (A2A)"])("should show %s to an admin", async (optionName) => { - const user = userEvent.setup(); - const { container } = render(); + it.each(["Organization Usage", "Agent Usage (A2A)", "Project Usage"])( + "should show %s to an admin", + async (optionName) => { + const user = userEvent.setup(); + const { container } = render(); - await openMenu(user); - expect(offers(container, optionName)).toBe(true); - }); + await openMenu(user); + expect(offers(container, optionName)).toBe(true); + }, + ); - it.each(["Organization Usage", "Agent Usage (A2A)"])("should hide %s from an internal user", async (optionName) => { + it.each(["Organization Usage", "Agent Usage (A2A)", "Project Usage"])("should hide %s from an internal user", async (optionName) => { const user = userEvent.setup(); const { container } = render( , @@ -86,6 +89,7 @@ describe("UsageViewSelect", () => { it.each([ ["Organization Usage", true], ["Agent Usage (A2A)", false], + ["Project Usage", false], ] as const)("should offer %s to an org admin: %s", async (optionName, expected) => { const user = userEvent.setup(); const { container } = render( 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 ea21a2155c1..a79267860ea 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 @@ -1,4 +1,4 @@ -import { BarChart3, Bot, Building2, Globe, LineChart, ShoppingCart, Tags, User, Users } from "lucide-react"; +import { BarChart3, Bot, Building2, Folder, Globe, LineChart, ShoppingCart, Tags, User, Users } from "lucide-react"; import React from "react"; import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -9,6 +9,7 @@ export type UsageOption = | "my-usage" | "organization" | "team" + | "project" | "customer" | "tag" | "agent" @@ -68,6 +69,13 @@ const OPTIONS: OptionConfig[] = [ description: "View usage by team", icon: , }, + { + value: "project", + label: "Project Usage", + description: "View usage by project", + icon: , + capability: "viewProjectUsage", + }, { value: "customer", label: "Customer Usage", diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 169b279bf79..21c71f9a774 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1623,6 +1623,38 @@ export const agentDailyActivityCall = async ( }); }; +export type ProjectDailySpendResponse = components["schemas"]["ProjectDailySpendResponse"]; +export type ProjectDailySpendRow = components["schemas"]["ProjectDailySpendRow"]; + +export const projectDailyActivityCall = async ( + accessToken: string, + startTime: Date, + endTime: Date, + projectIds: string[], +): Promise => { + /** + * Get daily spend per project from /project/daily/activity. + * + * Unlike team/user/tag/agent, there is no daily-aggregated project spend + * table, so this scans spend logs directly and returns the whole range in + * one response instead of paginating. project_ids is comma-joined because + * the endpoint takes a single string, not repeated query params. + */ + try { + return await apiClient.get(`/project/daily/activity`, { + accessToken, + query: { + project_ids: projectIds.join(","), + start_date: formatDate(startTime), + end_date: formatDate(endTime), + }, + }); + } catch (error) { + console.error("Failed to fetch project daily activity:", error); + throw error; + } +}; + export const getOnboardingCredentials = async (inviteUUID: string) => { /** * Get all models on proxy diff --git a/ui/litellm-dashboard/src/utils/capabilities.test.ts b/ui/litellm-dashboard/src/utils/capabilities.test.ts index 25314a3f46a..ad0b4eda3ce 100644 --- a/ui/litellm-dashboard/src/utils/capabilities.test.ts +++ b/ui/litellm-dashboard/src/utils/capabilities.test.ts @@ -24,6 +24,7 @@ const ADMIN_ONLY_CAPABILITIES: Capability[] = [ "viewPrompts", "viewOrganizationUsage", "viewAgentUsage", + "viewProjectUsage", ]; const PROXY_ADMIN_ONLY_PAGE_CAPABILITIES: Capability[] = [ diff --git a/ui/litellm-dashboard/src/utils/capabilities.ts b/ui/litellm-dashboard/src/utils/capabilities.ts index ec41531f2ac..5adb88d0f46 100644 --- a/ui/litellm-dashboard/src/utils/capabilities.ts +++ b/ui/litellm-dashboard/src/utils/capabilities.ts @@ -10,6 +10,7 @@ const CAPABILITY_ROLES = { viewPrompts: all_admin_roles, viewOrganizationUsage: all_admin_roles, viewAgentUsage: all_admin_roles, + viewProjectUsage: all_admin_roles, viewGlobalSpend: proxyAdminOnlyRoles, viewWorkflowRuns: proxyAdminOnlyRoles, viewMemory: proxyAdminOnlyRoles, From e3957440381733f5f03bac643e27b07cb126f802 Mon Sep 17 00:00:00 2001 From: Aryan Gupta Date: Thu, 10 Sep 2026 17:51:54 +0530 Subject: [PATCH 2/4] fix: surface load errors and fix stale loading state in Project Usage Surfaces API errors instead of rendering silent zeros, fixes the loading indicator not appearing while switching between selected projects, gates the summary tiles behind the same loading check as the chart, disambiguates donut chart entries for projects sharing an alias, and falls back to Global usage when project view access is lost mid-session. --- .../ProjectUsage/ProjectUsage.test.tsx | 67 +++++++++ .../components/ProjectUsage/ProjectUsage.tsx | 135 +++++++++++------- .../projectUsageAggregations.test.ts | 44 +++++- .../ProjectUsage/projectUsageAggregations.ts | 84 ++++++----- .../_components/components/UsagePageView.tsx | 10 +- .../src/components/networking.tsx | 8 -- 6 files changed, 243 insertions(+), 105 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.test.tsx index 1e9e0dffefb..3d5a17fe411 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.test.tsx @@ -84,4 +84,71 @@ describe("ProjectUsage", () => { expect(screen.getAllByText("4").length).toBeGreaterThan(0); expect(screen.getAllByText("Project Alpha").length).toBeGreaterThan(0); }); + + it("shows an error instead of silently rendering zeros when the request fails", async () => { + mockProjectDailyActivityCall.mockRejectedValue(new Error("Project management is an enterprise feature")); + + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("option", { name: "Project Alpha" })); + + expect(await screen.findByText("Could not load project usage")).toBeInTheDocument(); + expect(screen.getByText("Project management is an enterprise feature")).toBeInTheDocument(); + expect(screen.queryByText("No project usage data")).not.toBeInTheDocument(); + }); + + it("shows a loading indicator while fetching data for a newly-added project", async () => { + let resolveSecondCall: (value: unknown) => void = () => {}; + mockProjectDailyActivityCall + .mockResolvedValueOnce({ + start_date: "2026-09-01", + end_date: "2026-09-08", + results: [ + { + date: "2026-09-01", + project_id: "project-alpha", + project_alias: "Project Alpha", + spend: 12.5, + prompt_tokens: 100, + completion_tokens: 50, + total_tokens: 150, + api_requests: 4, + successful_requests: 3, + failed_requests: 1, + }, + ], + }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecondCall = resolve; + }), + ); + + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("option", { name: "Project Alpha" })); + await waitFor(() => expect(screen.getAllByText("$12.50").length).toBeGreaterThan(0)); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("option", { name: "Project Beta" })); + + await waitFor(() => expect(screen.getAllByText("Loading chart data...").length).toBeGreaterThan(0)); + + resolveSecondCall({ + start_date: "2026-09-01", + end_date: "2026-09-08", + results: [], + }); + + await waitFor(() => expect(screen.queryAllByText("Loading chart data...").length).toBe(0)); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.tsx index b4f06af67c8..62e3d675318 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.tsx @@ -10,12 +10,18 @@ import type { DateRangePickerValue } from "@/components/shared/date_picker_types import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { projectDailyActivityCall } from "@/components/networking"; +import { extractProxyErrorMessage } from "@/lib/http/client"; import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters"; import { buildSummaryTiles, type SummaryTile } from "../EntityUsage/entityUsageSummary"; import type { EntityList } from "../EntityUsage/EntityUsage"; import ProjectSpendBreakdown from "./ProjectSpendBreakdown"; -import { buildDailySpendSeries, buildProjectSpendBreakdown, summarizeProjectUsage } from "./projectUsageAggregations"; +import { + buildDailySpendSeries, + buildProjectSpendBreakdown, + humanizeBackendListMessage, + summarizeProjectUsage, +} from "./projectUsageAggregations"; interface ProjectUsageProps { accessToken: string | null; @@ -55,7 +61,7 @@ const ProjectUsage: React.FC = ({ accessToken, projectList, d enabled, placeholderData: keepPreviousData, }; - const { data, isFetching, isPlaceholderData } = useQuery(queryOptions); + const { data, isPending, isFetching, isPlaceholderData, isError, error } = useQuery(queryOptions); if (!premiumUser) { return ( @@ -76,7 +82,79 @@ const ProjectUsage: React.FC = ({ accessToken, projectList, d const summary = summarizeProjectUsage(rows); const dailySpend = buildDailySpendSeries(rows); const projectBreakdown = buildProjectSpendBreakdown(rows); - const isLoadingRows = isFetching && !isPlaceholderData; + const isLoadingRows = isPending || (isFetching && isPlaceholderData); + + const renderResultsPanel = () => { + if (!hasSelection) { + return ( +
+ + +

+ Select at least one project above to view its usage. +

+
+
+
+ ); + } + + if (isError) { + return ( +
+ + Could not load project usage + {humanizeBackendListMessage(extractProxyErrorMessage(error))} + +
+ ); + } + + return ( + <> +
+ + +

Project Spend Overview

+ {isLoadingRows ? ( + + ) : ( +
+ {buildSummaryTiles(summary, false).map(renderSummaryTile)} +
+ )} +
+
+
+ +
+ + + Daily Spend + + + {isLoadingRows ? ( + + ) : ( + + )} + + +
+ +
+ +
+ + ); + }; return (
@@ -101,56 +179,7 @@ const ProjectUsage: React.FC = ({ accessToken, projectList, d
- {!hasSelection ? ( -
- - -

- Select at least one project above to view its usage. -

-
-
-
- ) : ( - <> -
- - -

Project Spend Overview

-
- {buildSummaryTiles(summary, false).map(renderSummaryTile)} -
-
-
-
- -
- - - Daily Spend - - - {isLoadingRows ? ( - - ) : ( - - )} - - -
- -
- -
- - )} + {renderResultsPanel()} ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts index 090ee973dd4..c95747f7a7f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from "vitest"; import type { ProjectDailySpendRow } from "@/components/networking"; -import { buildDailySpendSeries, buildProjectSpendBreakdown, summarizeProjectUsage } from "./projectUsageAggregations"; +import { + buildDailySpendSeries, + buildProjectSpendBreakdown, + humanizeBackendListMessage, + summarizeProjectUsage, +} from "./projectUsageAggregations"; const row = (overrides: Partial = {}): ProjectDailySpendRow => ({ date: "2026-09-01", @@ -103,4 +108,41 @@ describe("buildProjectSpendBreakdown", () => { expect(buildProjectSpendBreakdown(rows)[0].project_alias).toBe("project-untitled"); }); + + it("disambiguates two projects that share the same human-set alias", () => { + const rows = [ + row({ project_id: "project-one", project_alias: "Production" }), + row({ project_id: "project-two", project_alias: "Production" }), + ]; + + const aliases = buildProjectSpendBreakdown(rows).map((r) => r.project_alias); + expect(new Set(aliases).size).toBe(2); + expect(aliases.every((alias) => alias.includes("Production"))).toBe(true); + }); + + it("leaves a unique alias untouched", () => { + const rows = [row({ project_id: "project-alpha", project_alias: "Project Alpha" })]; + + expect(buildProjectSpendBreakdown(rows)[0].project_alias).toBe("Project Alpha"); + }); +}); + +describe("humanizeBackendListMessage", () => { + it("strips a single-quoted Python list down to plain text", () => { + expect(humanizeBackendListMessage("Project(s) not found: ['proj-123']")).toBe( + "Project(s) not found: proj-123", + ); + }); + + it("comma-joins a multi-item Python list", () => { + expect(humanizeBackendListMessage("Project(s) not found: ['proj-1', 'proj-2']")).toBe( + "Project(s) not found: proj-1, proj-2", + ); + }); + + it("leaves a message with no trailing list untouched", () => { + expect(humanizeBackendListMessage("Not authorized to view this project")).toBe( + "Not authorized to view this project", + ); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts index 8a0422fbadf..a94b3aa67e4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts @@ -1,6 +1,5 @@ import type { ProjectDailySpendRow } from "@/components/networking"; -/** One row per project, summed across the whole range: the shape the breakdown table and donut chart render. */ export interface ProjectSpendRow extends Record { project_id: string; project_alias: string; @@ -11,7 +10,6 @@ export interface ProjectSpendRow extends Record { tokens: number; } -/** One point per day, spend summed across every selected project: the shape the daily spend chart renders. */ export interface DailyProjectSpendPoint extends Record { date: string; spend: number; @@ -33,7 +31,6 @@ const EMPTY_SUMMARY: ProjectUsageSummary = { total_tokens: 0, }; -/** Aggregate totals across every row, for the summary tiles. */ export const summarizeProjectUsage = (rows: ProjectDailySpendRow[]): ProjectUsageSummary => rows.reduce( (totals, row) => ({ @@ -46,41 +43,54 @@ export const summarizeProjectUsage = (rows: ProjectDailySpendRow[]): ProjectUsag EMPTY_SUMMARY, ); -/** One point per day, spend summed across every selected project, sorted oldest first. */ export const buildDailySpendSeries = (rows: ProjectDailySpendRow[]): DailyProjectSpendPoint[] => { - const spendByDate = new Map(); - rows.forEach((row) => { - spendByDate.set(row.date, (spendByDate.get(row.date) ?? 0) + row.spend); - }); - return Array.from(spendByDate, ([date, spend]) => ({ date, spend })).sort((a, b) => a.date.localeCompare(b.date)); + const spendByDate = rows.reduce>( + (totals, row) => ({ ...totals, [row.date]: (totals[row.date] ?? 0) + row.spend }), + {}, + ); + return Object.entries(spendByDate) + .map(([date, spend]) => ({ date, spend })) + .sort((a, b) => a.date.localeCompare(b.date)); }; -/** - * One row per project, spend/tokens/requests summed across the whole range, sorted by - * spend descending like every other "top X" breakdown on the usage page. - */ -export const buildProjectSpendBreakdown = (rows: ProjectDailySpendRow[]): ProjectSpendRow[] => { - const byProject = new Map(); - rows.forEach((row) => { - const existing = byProject.get(row.project_id); - if (existing) { - existing.spend += row.spend; - existing.requests += row.api_requests; - existing.successful_requests += row.successful_requests; - existing.failed_requests += row.failed_requests; - existing.tokens += row.total_tokens; - return; - } - const newRow: ProjectSpendRow = { - project_id: row.project_id, - project_alias: row.project_alias || row.project_id, - spend: row.spend, - requests: row.api_requests, - successful_requests: row.successful_requests, - failed_requests: row.failed_requests, - tokens: row.total_tokens, - }; - byProject.set(row.project_id, newRow); - }); - return Array.from(byProject.values()).sort((a, b) => b.spend - a.spend); +const groupByProjectId = (rows: ProjectDailySpendRow[]): Record => + rows.reduce>( + (groups, row) => ({ ...groups, [row.project_id]: [...(groups[row.project_id] ?? []), row] }), + {}, + ); + +const summarizeProjectGroup = (rows: ProjectDailySpendRow[]): ProjectSpendRow => { + const [{ project_id, project_alias }] = rows; + const totals = rows.reduce( + (acc, row) => ({ + spend: acc.spend + row.spend, + requests: acc.requests + row.api_requests, + successful_requests: acc.successful_requests + row.successful_requests, + failed_requests: acc.failed_requests + row.failed_requests, + tokens: acc.tokens + row.total_tokens, + }), + { spend: 0, requests: 0, successful_requests: 0, failed_requests: 0, tokens: 0 }, + ); + return { project_id, project_alias: project_alias || project_id, ...totals }; }; + +const disambiguateAliases = (rows: ProjectSpendRow[]): ProjectSpendRow[] => { + const aliasCounts = rows.reduce>( + (counts, row) => ({ ...counts, [row.project_alias]: (counts[row.project_alias] ?? 0) + 1 }), + {}, + ); + return rows.map((row) => + aliasCounts[row.project_alias] > 1 ? { ...row, project_alias: `${row.project_alias} (${row.project_id})` } : row, + ); +}; + +export const buildProjectSpendBreakdown = (rows: ProjectDailySpendRow[]): ProjectSpendRow[] => { + const summarized = Object.values(groupByProjectId(rows)).map(summarizeProjectGroup); + return disambiguateAliases(summarized).sort((a, b) => b.spend - a.spend); +}; + +export const humanizeBackendListMessage = (message: string): string => + message.replace(/\[([^\]]*)]\s*$/, (_match, listContents: string) => { + const items = [...listContents.matchAll(/'([^']*)'|"([^"]*)"/g)].map((m) => m[1] ?? m[2]); + return items.length > 0 ? items.join(", ") : listContents; + }); 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 55aae0940a0..b6ca7397677 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 @@ -121,12 +121,10 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false); const [isAiChatOpen, setIsAiChatOpen] = useState(false); const [selectedUsageView, setUsageView] = useState("global"); - // Org-admin membership is read from the server, so unlike the other usage - // views this one can be revoked while the page is open. Derive the view in - // render rather than storing it, so the fallback lands on the same paint and - // the selector never holds a value it no longer offers. - const usageView: UsageOption = - selectedUsageView === "organization" && !canViewOrganizationUsage ? "global" : selectedUsageView; + const stillHasAccessToSelectedView = + (selectedUsageView !== "organization" || canViewOrganizationUsage) && + (selectedUsageView !== "project" || canViewProjectUsage); + const usageView: UsageOption = stillHasAccessToSelectedView ? selectedUsageView : "global"; const [showCredentialBanner, setShowCredentialBanner] = useState(true); const [topKeysLimit, setTopKeysLimit] = useState(5); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 21c71f9a774..67ddb5d2dba 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1632,14 +1632,6 @@ export const projectDailyActivityCall = async ( endTime: Date, projectIds: string[], ): Promise => { - /** - * Get daily spend per project from /project/daily/activity. - * - * Unlike team/user/tag/agent, there is no daily-aggregated project spend - * table, so this scans spend logs directly and returns the whole range in - * one response instead of paginating. project_ids is comma-joined because - * the endpoint takes a single string, not repeated query params. - */ try { return await apiClient.get(`/project/daily/activity`, { accessToken, From 05ef5627ae0cf7af98046bb06477ee5f2fcdd772 Mon Sep 17 00:00:00 2001 From: Aryan Gupta Date: Thu, 10 Sep 2026 17:52:07 +0530 Subject: [PATCH 3/4] refactor: extract a shared spend-by-category panel from Project and Provider usage SpendByProvider and ProjectSpendBreakdown rendered the same donut chart, table, and loading state with only the columns and title differing. Pulls that shell into SpendByCategoryPanel and has both consumers pass in their own columns and data, keeping provider-specific filtering where it belongs. --- .../EntityUsage/SpendByProvider.tsx | 87 +++++-------- .../ProjectUsage/ProjectSpendBreakdown.tsx | 47 ++----- .../components/SpendByCategoryPanel.test.tsx | 118 ++++++++++++++++++ .../components/SpendByCategoryPanel.tsx | 63 ++++++++++ 4 files changed, 224 insertions(+), 91 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx index f5d6db67478..5ace97421e0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx @@ -1,15 +1,12 @@ -import { DonutChart } from "@/components/shared/charts"; -import { DataTable } from "@/components/shared/DataTable"; import { MoneyCell } from "@/components/shared/table_cells"; -import { formatNumberWithCommas } from "@/utils/dataUtils"; import { Info } from "lucide-react"; import type { ColumnDef } from "@tanstack/react-table"; -import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Switch } from "@/components/ui/switch"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import React, { useState } from "react"; import { ProviderLogo } from "@/components/molecules/models/ProviderLogo"; -import { ChartLoader } from "@/components/shared/chart_loader"; + +import { SpendByCategoryPanel } from "../SpendByCategoryPanel"; type ProviderSpendData = { provider: string; @@ -70,13 +67,10 @@ const SpendByProvider: React.FC = ({ loading, isDateChangi const filteredProviderSpend = providerSpend.filter((provider) => { const isUnknown = provider.provider?.toLowerCase() === "unknown"; - // If includeUnknown is true, always include unknown provider if (isUnknown) { return includeUnknown; } - // If includeZeroSpend is true, include all providers (including those with 0 spend) - // Otherwise, only include providers with spend > 0 if (includeZeroSpend) { return true; } @@ -84,54 +78,37 @@ const SpendByProvider: React.FC = ({ loading, isDateChangi return provider.spend > 0; }); + const headerAction = ( + <> +
+ + +
+
+
+ + + } /> + Requests that failed to route to a provider + +
+ +
+ + ); + return ( - - - Spend by Provider - -
- - -
-
-
- - - } /> - Requests that failed to route to a provider - -
- -
-
-
- - {loading ? ( - - ) : ( -
- `$${formatNumberWithCommas(value, 2)}`} - colors={["cyan"]} - showLabel - startAngle={90} - endAngle={-270} - /> - row.provider} - noDataMessage="No provider usage data" - size="compact" - /> -
- )} -
-
+ row.provider} + noDataMessage="No provider usage data" + /> ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.tsx index e7e41126861..d2459e65665 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.tsx @@ -1,13 +1,9 @@ import React from "react"; import type { ColumnDef } from "@tanstack/react-table"; -import { DonutChart } from "@/components/shared/charts"; -import { DataTable } from "@/components/shared/DataTable"; import { MoneyCell } from "@/components/shared/table_cells"; -import { ChartLoader } from "@/components/shared/chart_loader"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { SpendByCategoryPanel } from "../SpendByCategoryPanel"; import type { ProjectSpendRow } from "./projectUsageAggregations"; interface ProjectSpendBreakdownProps { @@ -48,37 +44,16 @@ const columns: ColumnDef[] = [ ]; const ProjectSpendBreakdown: React.FC = ({ loading, isDateChanging, projectSpend }) => ( - - - Spend by Project - - - {loading ? ( - - ) : ( -
- `$${formatNumberWithCommas(value, 2)}`} - colors={["cyan"]} - showLabel - startAngle={90} - endAngle={-270} - /> - row.project_id} - noDataMessage="No project usage data" - size="compact" - /> -
- )} -
-
+ row.project_id} + noDataMessage="No project usage data" + /> ); export default ProjectSpendBreakdown; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.test.tsx new file mode 100644 index 00000000000..33dd4d41095 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.test.tsx @@ -0,0 +1,118 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { ColumnDef } from "@tanstack/react-table"; + +import { SpendByCategoryPanel } from "./SpendByCategoryPanel"; + +vi.mock("@/components/shared/chart_loader", () => ({ + ChartLoader: ({ isDateChanging }: { isDateChanging: boolean }) => ( +
{isDateChanging ? "Processing date selection..." : "Loading chart data..."}
+ ), +})); + +interface TestRow extends Record { + key: string; + spend: number; +} + +const columns: ColumnDef[] = [{ header: "Key", accessorKey: "key" }]; + +const rows: TestRow[] = [{ key: "row-1", spend: 5 }]; + +describe("SpendByCategoryPanel", () => { + it("displays the given title", () => { + render( + row.key} + noDataMessage="No data" + />, + ); + expect(screen.getByText("Spend by Widget")).toBeInTheDocument(); + }); + + it("shows the loader instead of chart content while loading", () => { + render( + row.key} + noDataMessage="No data" + />, + ); + expect(screen.getByTestId("chart-loader")).toBeInTheDocument(); + expect(screen.queryByText("row-1")).not.toBeInTheDocument(); + }); + + it("renders the table rows once loaded", () => { + render( + row.key} + noDataMessage="No data" + />, + ); + expect(screen.getAllByText("row-1").length).toBeGreaterThan(0); + }); + + it("shows the empty message when there is no data", () => { + render( + row.key} + noDataMessage="No data for this range" + />, + ); + expect(screen.getByText("No data for this range")).toBeInTheDocument(); + }); + + it("renders a header action only when one is given", () => { + const { rerender } = render( + row.key} + noDataMessage="No data" + />, + ); + expect(screen.queryByText("Toggle")).not.toBeInTheDocument(); + + rerender( + Toggle} + loading={false} + isDateChanging={false} + data={[]} + indexKey="key" + columns={columns} + getRowId={(row) => row.key} + noDataMessage="No data" + />, + ); + expect(screen.getByText("Toggle")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.tsx new file mode 100644 index 00000000000..9dbf1550e88 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import type { ColumnDef } from "@tanstack/react-table"; + +import { DonutChart } from "@/components/shared/charts"; +import { DataTable } from "@/components/shared/DataTable"; +import { ChartLoader } from "@/components/shared/chart_loader"; +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; + +interface SpendByCategoryPanelProps> { + title: string; + headerAction?: React.ReactNode; + loading: boolean; + isDateChanging: boolean; + data: TRow[]; + indexKey: keyof TRow & string; + columns: ColumnDef[]; + getRowId: (row: TRow) => string; + noDataMessage: string; +} + +export function SpendByCategoryPanel>({ + title, + headerAction, + loading, + isDateChanging, + data, + indexKey, + columns, + getRowId, + noDataMessage, +}: SpendByCategoryPanelProps) { + return ( + + + {title} + {headerAction && {headerAction}} + + + {loading ? ( + + ) : ( +
+ `$${formatNumberWithCommas(value, 2)}`} + colors={["cyan"]} + showLabel + startAngle={90} + endAngle={-270} + /> + +
+ )} +
+
+ ); +} + +export default SpendByCategoryPanel; From a51066f79e164d71d91db62b1717670cc125cf95 Mon Sep 17 00:00:00 2001 From: Aryan Gupta Date: Fri, 11 Sep 2026 12:17:44 +0530 Subject: [PATCH 4/4] fix: Gate Project Usage behind the enable_projects_ui setting (BerriAI/litellm/issues/40386) Usage page and the logs filters showed the Project Usage view and Project filter unconditionally, unlike the sidebar, key edit, key info, and create-key surfaces which already respect enable_projects_ui Also format the project-not-found 404 message as a sorted, comma-joined list instead of a raw Python list repr, and drop the frontend workaround that parsed it back out Extract SummaryTileCard as a component shared between EntityUsage and ProjectUsage, and move ProjectUsage's derived data behind useMemo ahead of its early return so hook order stays stable across renders --- .../management_endpoints/project_endpoints.py | 4 +- .../test_project_endpoints_prisma.py | 3 +- .../hooks/uiSettings/useUISettings.ts | 2 +- .../components/EntityUsage/EntityUsage.tsx | 33 ++++--------- .../EntityUsage/SummaryTileCard.tsx | 41 ++++++++++++++++ .../ProjectUsage/ProjectUsage.test.tsx | 23 ++++++--- .../components/ProjectUsage/ProjectUsage.tsx | 37 ++++++--------- .../projectUsageAggregations.test.ts | 27 +---------- .../ProjectUsage/projectUsageAggregations.ts | 47 ++++++++++--------- .../_components/components/UsagePageView.tsx | 7 ++- .../UsageViewSelect/UsageViewSelect.test.tsx | 10 ++++ .../UsageViewSelect/UsageViewSelect.tsx | 5 ++ .../view_logs/RequestLogsFilters.test.tsx | 19 ++++++++ .../view_logs/RequestLogsFilters.tsx | 7 ++- 14 files changed, 157 insertions(+), 108 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SummaryTileCard.tsx diff --git a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py index 7e272a2c59c..97ab91a46ac 100644 --- a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py +++ b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py @@ -1178,7 +1178,9 @@ async def _resolve_project_daily_activity_scope( found_by_id: Final = {p.project_id: p for p in projects} missing: Final = [pid for pid in requested if pid not in found_by_id] if missing: - raise _project_daily_activity_error(status_code=404, message=f"Project(s) not found: {missing}") + raise _project_daily_activity_error( + status_code=404, message=f"Project(s) not found: {', '.join(sorted(missing))}" + ) if not user_api_key_has_admin_view(user_api_key_dict): for project_id in requested: diff --git a/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py b/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py index 61a66d46e5c..1f7221fd2dd 100644 --- a/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py +++ b/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py @@ -1517,11 +1517,12 @@ async def test_get_project_daily_activity_unknown_project_404(monkeypatch): with pytest.raises(HTTPException) as exc_info: await get_project_daily_activity( user_api_key_dict=admin, - project_ids="does-not-exist", + project_ids="does-not-exist,also-missing", start_date="2026-09-01", end_date="2026-09-02", ) assert exc_info.value.status_code == 404 + assert exc_info.value.detail == {"error": "Project(s) not found: also-missing, does-not-exist"} mock_prisma.db.query_raw.assert_not_called() diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts index 749fc98c0d8..eaa1c89eab4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts @@ -17,7 +17,7 @@ export const useUISettings = (options?: { staleTime?: number; refetchInterval?: return useQuery>({ queryKey: uiSettingsKeys.list({}), queryFn: async () => await getUiSettings(), - staleTime: options?.staleTime ?? 60 * 60 * 1000, // 1 hour - data rarely changes + staleTime: options?.staleTime ?? 60 * 60 * 1000, gcTime: 60 * 60 * 1000, // 1 hour - keep in cache for 1 hour refetchInterval: options?.refetchInterval, }); 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 273e478528e..f55dc05f5c2 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 @@ -10,16 +10,15 @@ import { type ProviderSpendRow, } from "./entityUsageAggregations"; import { buildCostBreakdownTiles, buildSummaryTiles, hasFlatCost, type SummaryTile } from "./entityUsageSummary"; +import { SummaryTileCard } from "./SummaryTileCard"; 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 type { DateRangePickerValue } from "@/components/shared/date_picker_types"; -import { ChevronDown, ChevronRight, Info } from "lucide-react"; import type { ColumnDef } from "@tanstack/react-table"; import PaginationStatusAlerts from "@/components/shared/PaginationStatusAlerts"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import React, { type ReactNode, useMemo, useState } from "react"; import TeamMultiSelect from "@/components/common_components/team_multi_select"; import UserDropdown from "@/components/common_components/UserDropdown"; @@ -358,29 +357,13 @@ const EntityUsage: React.FC = ({ [], ); - const chev = "size-3 text-muted-foreground"; - const expandIcon = showCostBreakdown ? : ; - - const renderSummaryTile = ({ title, value, className, tooltip, expandable }: SummaryTile) => ( - setShowCostBreakdown(!showCostBreakdown) : undefined} - > - -
-

{title}

- {tooltip ? ( - - } /> - {tooltip} - - ) : null} - {expandable ? expandIcon : null} -
-

{value}

-
-
+ const renderSummaryTile = (tile: SummaryTile) => ( + setShowCostBreakdown(!showCostBreakdown)} + /> ); const breakdownTiles = showFlatCost && showCostBreakdown ? buildCostBreakdownTiles(spendData.metadata) : []; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SummaryTileCard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SummaryTileCard.tsx new file mode 100644 index 00000000000..fcf616bca99 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SummaryTileCard.tsx @@ -0,0 +1,41 @@ +import { ChevronDown, ChevronRight, Info } from "lucide-react"; + +import { Card as ShadcnCard, CardContent } from "@/components/ui/card"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +import type { SummaryTile } from "./entityUsageSummary"; + +interface SummaryTileCardProps { + tile: SummaryTile; + expanded?: boolean; + onToggleExpand?: () => void; +} + +export function SummaryTileCard({ tile, expanded = false, onToggleExpand }: SummaryTileCardProps) { + const { title, value, className, tooltip, expandable } = tile; + const chev = "size-3 text-muted-foreground"; + const expandIcon = expanded ? : ; + + return ( + + +
+

{title}

+ {tooltip ? ( + + } /> + {tooltip} + + ) : null} + {expandable ? expandIcon : null} +
+

{value}

+
+
+ ); +} + +export default SummaryTileCard; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.test.tsx index 3d5a17fe411..7ea78355188 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.test.tsx @@ -73,12 +73,9 @@ describe("ProjectUsage", () => { await user.click(screen.getByRole("option", { name: "Project Alpha" })); await waitFor(() => expect(mockProjectDailyActivityCall).toHaveBeenCalledTimes(1)); - expect(mockProjectDailyActivityCall).toHaveBeenCalledWith( - "test-token", - DATE_VALUE.from, - DATE_VALUE.to, - ["project-alpha"], - ); + expect(mockProjectDailyActivityCall).toHaveBeenCalledWith("test-token", DATE_VALUE.from, DATE_VALUE.to, [ + "project-alpha", + ]); await waitFor(() => expect(screen.getAllByText("$12.50").length).toBeGreaterThan(0)); expect(screen.getAllByText("4").length).toBeGreaterThan(0); @@ -101,6 +98,20 @@ describe("ProjectUsage", () => { expect(screen.queryByText("No project usage data")).not.toBeInTheDocument(); }); + it("renders a backend not-found message verbatim, without mangling the comma-joined list", async () => { + mockProjectDailyActivityCall.mockRejectedValue(new Error("Project(s) not found: also-missing, does-not-exist")); + + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("option", { name: "Project Alpha" })); + + expect(await screen.findByText("Project(s) not found: also-missing, does-not-exist")).toBeInTheDocument(); + }); + it("shows a loading indicator while fetching data for a newly-added project", async () => { let resolveSecondCall: (value: unknown) => void = () => {}; mockProjectDailyActivityCall diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.tsx index 62e3d675318..5e48d05ecf6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.tsx @@ -13,15 +13,11 @@ import { projectDailyActivityCall } from "@/components/networking"; import { extractProxyErrorMessage } from "@/lib/http/client"; import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters"; -import { buildSummaryTiles, type SummaryTile } from "../EntityUsage/entityUsageSummary"; +import { buildSummaryTiles } from "../EntityUsage/entityUsageSummary"; +import { SummaryTileCard } from "../EntityUsage/SummaryTileCard"; import type { EntityList } from "../EntityUsage/EntityUsage"; import ProjectSpendBreakdown from "./ProjectSpendBreakdown"; -import { - buildDailySpendSeries, - buildProjectSpendBreakdown, - humanizeBackendListMessage, - summarizeProjectUsage, -} from "./projectUsageAggregations"; +import { buildDailySpendSeries, buildProjectSpendBreakdown, summarizeProjectUsage } from "./projectUsageAggregations"; interface ProjectUsageProps { accessToken: string | null; @@ -30,15 +26,6 @@ interface ProjectUsageProps { premiumUser: boolean; } -const renderSummaryTile = ({ title, value, className }: SummaryTile) => ( - - -

{title}

-

{value}

-
-
-); - const ProjectUsage: React.FC = ({ accessToken, projectList, dateValue, premiumUser }) => { const [selectedProjectIds, setSelectedProjectIds] = useState([]); @@ -57,12 +44,18 @@ const ProjectUsage: React.FC = ({ accessToken, projectList, d const queryOptions = { queryKey: ["project-daily-activity", selectedProjectIds, startTime?.toISOString(), endTime?.toISOString()], - queryFn: () => projectDailyActivityCall(accessToken as string, startTime as Date, endTime as Date, selectedProjectIds), + queryFn: () => + projectDailyActivityCall(accessToken as string, startTime as Date, endTime as Date, selectedProjectIds), enabled, placeholderData: keepPreviousData, }; const { data, isPending, isFetching, isPlaceholderData, isError, error } = useQuery(queryOptions); + const rows = useMemo(() => data?.results ?? [], [data]); + const summary = useMemo(() => summarizeProjectUsage(rows), [rows]); + const dailySpend = useMemo(() => buildDailySpendSeries(rows), [rows]); + const projectBreakdown = useMemo(() => buildProjectSpendBreakdown(rows), [rows]); + if (!premiumUser) { return ( @@ -78,10 +71,6 @@ const ProjectUsage: React.FC = ({ accessToken, projectList, d ); } - const rows = data?.results ?? []; - const summary = summarizeProjectUsage(rows); - const dailySpend = buildDailySpendSeries(rows); - const projectBreakdown = buildProjectSpendBreakdown(rows); const isLoadingRows = isPending || (isFetching && isPlaceholderData); const renderResultsPanel = () => { @@ -104,7 +93,7 @@ const ProjectUsage: React.FC = ({ accessToken, projectList, d
Could not load project usage - {humanizeBackendListMessage(extractProxyErrorMessage(error))} + {extractProxyErrorMessage(error)}
); @@ -120,7 +109,9 @@ const ProjectUsage: React.FC = ({ accessToken, projectList, d ) : (
- {buildSummaryTiles(summary, false).map(renderSummaryTile)} + {buildSummaryTiles(summary, false).map((tile) => ( + + ))}
)} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts index c95747f7a7f..7ef733f5eac 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts @@ -2,12 +2,7 @@ import { describe, expect, it } from "vitest"; import type { ProjectDailySpendRow } from "@/components/networking"; -import { - buildDailySpendSeries, - buildProjectSpendBreakdown, - humanizeBackendListMessage, - summarizeProjectUsage, -} from "./projectUsageAggregations"; +import { buildDailySpendSeries, buildProjectSpendBreakdown, summarizeProjectUsage } from "./projectUsageAggregations"; const row = (overrides: Partial = {}): ProjectDailySpendRow => ({ date: "2026-09-01", @@ -126,23 +121,3 @@ describe("buildProjectSpendBreakdown", () => { expect(buildProjectSpendBreakdown(rows)[0].project_alias).toBe("Project Alpha"); }); }); - -describe("humanizeBackendListMessage", () => { - it("strips a single-quoted Python list down to plain text", () => { - expect(humanizeBackendListMessage("Project(s) not found: ['proj-123']")).toBe( - "Project(s) not found: proj-123", - ); - }); - - it("comma-joins a multi-item Python list", () => { - expect(humanizeBackendListMessage("Project(s) not found: ['proj-1', 'proj-2']")).toBe( - "Project(s) not found: proj-1, proj-2", - ); - }); - - it("leaves a message with no trailing list untouched", () => { - expect(humanizeBackendListMessage("Not authorized to view this project")).toBe( - "Not authorized to view this project", - ); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts index a94b3aa67e4..ed385c56344 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts @@ -44,20 +44,27 @@ export const summarizeProjectUsage = (rows: ProjectDailySpendRow[]): ProjectUsag ); export const buildDailySpendSeries = (rows: ProjectDailySpendRow[]): DailyProjectSpendPoint[] => { - const spendByDate = rows.reduce>( - (totals, row) => ({ ...totals, [row.date]: (totals[row.date] ?? 0) + row.spend }), - {}, - ); - return Object.entries(spendByDate) + const spendByDate = new Map(); + for (const row of rows) { + spendByDate.set(row.date, (spendByDate.get(row.date) ?? 0) + row.spend); + } + return [...spendByDate.entries()] .map(([date, spend]) => ({ date, spend })) .sort((a, b) => a.date.localeCompare(b.date)); }; -const groupByProjectId = (rows: ProjectDailySpendRow[]): Record => - rows.reduce>( - (groups, row) => ({ ...groups, [row.project_id]: [...(groups[row.project_id] ?? []), row] }), - {}, - ); +const groupByProjectId = (rows: ProjectDailySpendRow[]): ProjectDailySpendRow[][] => { + const groups = new Map(); + for (const row of rows) { + const existing = groups.get(row.project_id); + if (existing) { + existing.push(row); + } else { + groups.set(row.project_id, [row]); + } + } + return [...groups.values()]; +}; const summarizeProjectGroup = (rows: ProjectDailySpendRow[]): ProjectSpendRow => { const [{ project_id, project_alias }] = rows; @@ -75,22 +82,18 @@ const summarizeProjectGroup = (rows: ProjectDailySpendRow[]): ProjectSpendRow => }; const disambiguateAliases = (rows: ProjectSpendRow[]): ProjectSpendRow[] => { - const aliasCounts = rows.reduce>( - (counts, row) => ({ ...counts, [row.project_alias]: (counts[row.project_alias] ?? 0) + 1 }), - {}, - ); + const aliasCounts = new Map(); + for (const row of rows) { + aliasCounts.set(row.project_alias, (aliasCounts.get(row.project_alias) ?? 0) + 1); + } return rows.map((row) => - aliasCounts[row.project_alias] > 1 ? { ...row, project_alias: `${row.project_alias} (${row.project_id})` } : row, + (aliasCounts.get(row.project_alias) ?? 0) > 1 + ? { ...row, project_alias: `${row.project_alias} (${row.project_id})` } + : row, ); }; export const buildProjectSpendBreakdown = (rows: ProjectDailySpendRow[]): ProjectSpendRow[] => { - const summarized = Object.values(groupByProjectId(rows)).map(summarizeProjectGroup); + const summarized = groupByProjectId(rows).map(summarizeProjectGroup); return disambiguateAliases(summarized).sort((a, b) => b.spend - a.spend); }; - -export const humanizeBackendListMessage = (message: string): string => - message.replace(/\[([^\]]*)]\s*$/, (_match, listContents: string) => { - const items = [...listContents.matchAll(/'([^']*)'|"([^"]*)"/g)].map((m) => m[1] ?? m[2]); - return items.length > 0 ? items.join(", ") : listContents; - }); 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 b6ca7397677..b841d3f0a3b 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 @@ -24,6 +24,7 @@ import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import { hasCapability } from "@/utils/capabilities"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { all_admin_roles, internalUserRoles } from "@/utils/roles"; @@ -111,7 +112,9 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const isOrgAdmin = useIsOrgAdmin(); const canViewOrganizationUsage = hasCapability(userRole, "viewOrganizationUsage", isOrgAdmin); const canViewAgentUsage = hasCapability(userRole, "viewAgentUsage"); - const canViewProjectUsage = hasCapability(userRole, "viewProjectUsage"); + const { data: uiSettingsData } = useUISettings(); + const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui); + const canViewProjectUsage = hasCapability(userRole, "viewProjectUsage") && enableProjectsUI; // For admins: null means global view (all users), a string means filter by that user // For non-admins: always set to their own user ID @@ -484,6 +487,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { userRole={userRole} canViewTagUsage={canViewTagUsage} isOrgAdmin={isOrgAdmin} + enableProjectsUI={enableProjectsUI} /> @@ -937,7 +941,6 @@ const UsagePage: React.FC = ({ teams, organizations }) => { /> )} - {/* Project Usage Panel */} {usageView === "project" && canViewProjectUsage && ( { expect(offers(container, optionName)).toBe(expected); }); + it("should hide Project Usage from an admin when enableProjectsUI is false", async () => { + const user = userEvent.setup(); + const { container } = render( + , + ); + + await openMenu(user); + expect(offers(container, "Project Usage")).toBe(false); + }); + it.each(["Team Usage", "Tag Usage"])("should keep %s available to an internal user", async (optionName) => { const user = userEvent.setup(); const { container } = render( 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 a79267860ea..6cf7cfb1140 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 @@ -21,6 +21,7 @@ export interface UsageViewSelectProps { userRole: string | null; canViewTagUsage?: boolean; isOrgAdmin?: boolean; + enableProjectsUI?: boolean; title?: string; description?: string; "data-id"?: string; @@ -118,6 +119,7 @@ export const UsageViewSelect: React.FC = ({ userRole, canViewTagUsage = false, isOrgAdmin = false, + enableProjectsUI = true, title = "Usage View", description = "Select the usage data you want to view", "data-id": dataId, @@ -125,6 +127,9 @@ export const UsageViewSelect: React.FC = ({ const isAdmin = all_admin_roles.includes(userRole ?? ""); const getFilteredOptions = () => { return OPTIONS.filter((option) => { + if (option.value === "project" && !enableProjectsUI) { + return false; + } if (option.capability) { return hasCapability(userRole, option.capability, isOrgAdmin); } diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx index fd7df329f33..56c5b612707 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx @@ -28,11 +28,16 @@ vi.mock("@/app/(dashboard)/hooks/projects/useProjects", () => ({ useProjects: vi.fn(), })); +vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ + useUISettings: vi.fn(), +})); + import { useInfiniteSpendLogEndUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers"; import { useInfiniteSpendLogUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogUsers"; import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases"; import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels"; import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects"; +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; const emptyInfiniteQuery = { data: { pages: [], pageParams: [] }, @@ -85,6 +90,9 @@ describe("RequestLogsFilters", () => { vi.mocked(useProjects).mockReturnValue({ data: [], isLoading: false } as unknown as ReturnType< typeof useProjects >); + vi.mocked(useUISettings).mockReturnValue({ + data: { values: { enable_projects_ui: true } }, + } as unknown as ReturnType); }); it("renders every backend-supported filter field", async () => { @@ -152,6 +160,17 @@ describe("RequestLogsFilters", () => { expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.PROJECT_ID, "project-1"); }); + it("hides the Project filter when the projects UI setting is disabled", async () => { + vi.mocked(useUISettings).mockReturnValue({ + data: { values: { enable_projects_ui: false } }, + } as unknown as ReturnType); + + renderFilters(); + + expect(await screen.findByText("Team ID")).toBeInTheDocument(); + expect(screen.queryByText("Project")).not.toBeInTheDocument(); + }); + it("pushes the User ID picker query to the paginated user lookup", async () => { const user = userEvent.setup(); renderFilters(); diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx index 9203edb8276..fe3e34bccdc 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx @@ -7,6 +7,7 @@ import { useInfiniteSpendLogUsers } from "@/app/(dashboard)/hooks/spendLogs/useS import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases"; import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels"; import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects"; +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import { DataTableFilterField } from "@/components/shared/DataTable"; import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect"; import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect"; @@ -346,6 +347,8 @@ interface RequestLogsFiltersProps { export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsFiltersProps) { const valueOf = (id: string): string => asString(get(id)); const setter = (id: string) => (next: string | undefined) => set(id, next); + const { data: uiSettingsData } = useUISettings(); + const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui); return ( <> @@ -355,7 +358,9 @@ export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsF teams={teams} /> - + {enableProjectsUI && ( + + )}