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,