From e88364d888fc301a4a68d9838b3f3c5f83f60c42 Mon Sep 17 00:00:00 2001 From: Aryan Gupta Date: Thu, 10 Sep 2026 12:20:10 +0530 Subject: [PATCH] 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,