diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerHierarchyManager.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerHierarchyManager.tsx
index f3d33f82db1..8ed6fec1ea5 100644
--- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerHierarchyManager.tsx
+++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerHierarchyManager.tsx
@@ -4,7 +4,6 @@ import { useState } from "react";
import { ChevronRight, Pencil, Plus } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
-import { formatUsd } from "@/app/(dashboard)/usage/_components/components/overview/overviewData";
import { ErrorState } from "@/components/shared/ErrorState";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
@@ -13,7 +12,8 @@ import CustomerHierarchyNavigation, { useCustomerHierarchyLinks } from "./Custom
import CustomerHierarchySettings from "./CustomerHierarchySettings";
import CustomerBudgetGroups from "./CustomerBudgetGroups";
import CustomerBudgetSummary from "./CustomerBudgetSummary";
-import type { CustomerUsageEntity } from "./customerUsageApi";
+import { customerUsageEntity, isCustomerUsageIdentifier, type CustomerUsageEntity } from "./customerUsageApi";
+import { CustomerSpendCells, CustomerSpendHeaders, CustomerSpendIdentity } from "./CustomerSpendCells";
import CreateCustomerHierarchyDialog from "./CreateCustomerHierarchyDialog";
import { customerEntityId, customerEntityLabel, type CreatedCustomerHierarchyEntity } from "./customerHierarchyForms";
import useCustomerHierarchy, { type CustomerHierarchyContext } from "./useCustomerHierarchy";
@@ -123,8 +123,7 @@ function hierarchyRows({ hierarchy, selected, scope }: ScopeProps) {
return list.data.map((team) => ({
id: team.customer_team_id,
label: team.alias || team.customer_team_id,
- spend: team.spend,
- budget: team.budget?.max_budget,
+ entity: customerUsageEntity(team.customer_team_id, team),
open: () => hierarchy.openTeam(team.customer_team_id),
edit: () => hierarchy.openTeam(team.customer_team_id, true),
parentTeamId: null,
@@ -135,8 +134,7 @@ function hierarchyRows({ hierarchy, selected, scope }: ScopeProps) {
return list.data.map((member) => ({
id: member.customer_member_id,
label: member.alias || member.customer_member_id,
- spend: member.spend,
- budget: member.effective_budget.max_budget,
+ entity: customerUsageEntity(member.customer_member_id, member),
open: () => hierarchy.openMember(member.customer_member_id, member.parent_team_ids[0] ?? ""),
edit: () => hierarchy.openMember(member.customer_member_id, member.parent_team_ids[0] ?? ""),
parentTeamId: member.parent_team_ids[0] ?? "",
@@ -182,7 +180,7 @@ function HierarchyChildTable(props: ScopeProps) {
const { customerHierarchyHref } = useCustomerHierarchyLinks();
const { t } = useTranslation();
const { hierarchy } = props;
- const { children, childTab, page } = hierarchy;
+ const { children, childUsage, childTab, page } = hierarchy;
const showTeam = childTab === "members" && props.selected.team === null;
const hint = showTeam ? "customerPortal.hierarchy.organizationMembersHint" : CHILD_HINTS[childTab];
const childLabel = childTab === "teams" ? t("customerPortal.hierarchy.teams") : t("customerPortal.hierarchy.members");
@@ -206,6 +204,8 @@ function HierarchyChildTable(props: ScopeProps) {
);
const rows = hierarchyRows(props);
const pages = Math.max(1, children.data.meta.total_pages);
+ const monthly = new Map(childUsage.data?.monthly.map((row) => [row.id, row]));
+ const yearly = new Map(childUsage.data?.yearly.map((row) => [row.id, row.spend]));
return (
<>
@@ -215,13 +215,16 @@ function HierarchyChildTable(props: ScopeProps) {
+ {childUsage.isError && (
+ void childUsage.refetch()} />
+ )}
+ {t("customerPortal.analytics.memberSpendHint")}
{t("customerPortal.hierarchy.name")}
{showTeam && {t("customerPortal.hierarchy.team")}}
- {t("customerPortal.hierarchy.spend")}
- {t("customerPortal.hierarchy.budget")}
+
{t("customerPortal.hierarchy.settings")}
@@ -231,10 +234,7 @@ function HierarchyChildTable(props: ScopeProps) {
{rows.map((row) => (
-
- {row.id}
+
{showTeam && (
@@ -247,10 +247,23 @@ function HierarchyChildTable(props: ScopeProps) {
)}
- {formatUsd(row.spend)}
-
- {row.budget == null ? t("customerPortal.hierarchy.noBudget") : formatUsd(row.budget)}
-
+ {childUsage.data && isCustomerUsageIdentifier(row.id) ? (
+
+ ) : (
+
+ {t(
+ childUsage.isError || !isCustomerUsageIdentifier(row.id)
+ ? "customerPortal.analytics.unknownSpend"
+ : "customerPortal.hierarchy.loading",
+ )}
+
+ )}
-
+
{t(
childTab === "teams"
? "customerPortal.hierarchy.emptyTeams"
diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerMemberSpend.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerMemberSpend.tsx
index bc4791e87ec..0d87b4dc646 100644
--- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerMemberSpend.tsx
+++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerMemberSpend.tsx
@@ -5,13 +5,10 @@ import { parseAsString, parseAsInteger, useQueryStates } from "nuqs";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import { Search } from "lucide-react";
-import { formatCompact, formatUsd } from "@/app/(dashboard)/usage/_components/components/overview/overviewData";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
-import { cn } from "@/lib/cva.config";
import type { CustomerHierarchyUsage } from "./customerUsageApi";
-import CustomerBudgetSpend, { CustomerBudgetWindow } from "./CustomerBudgetSpend";
-import { customerBudgetPeriod, customerBudgetStatus, customerBudgetUsage } from "./customerBudgetUsage";
+import { CustomerSpendCells, CustomerSpendHeaders, CustomerSpendIdentity } from "./CustomerSpendCells";
const MEMBER_TABLE_PARSERS = { memberSearch: parseAsString.withDefault(""), memberPage: parseAsInteger.withDefault(1) };
const PAGE_SIZE = 20;
@@ -64,126 +61,24 @@ export default function CustomerMemberSpend({
{t("customerPortal.analytics.member")}
- {t("customerPortal.analytics.monthlySpend")}
- {t("customerPortal.analytics.yearlySpend")}
- {t("customerPortal.analytics.customerSpendShare")}
- {t("customerPortal.analytics.tokens")}
- {t("customerPortal.analytics.status")}
+
- {members.slice(start, start + PAGE_SIZE).map((member) => {
- const monthlySpend = member.monthlyUsage?.spend ?? 0;
- const budgets = customerBudgetUsage(member);
- const monthlyBudgets = budgets.filter(
- (budget) => customerBudgetPeriod(budget.budget_duration) === "monthly",
- );
- const yearlyBudgets = budgets.filter((budget) => customerBudgetPeriod(budget.budget_duration) === "yearly");
- const otherBudgets = budgets.filter((budget) => customerBudgetPeriod(budget.budget_duration) === "other");
- const yearlySpend = yearlyUsage.get(member.id) ?? 0;
- const spendShare =
- data.monthlyOrganizationSpend > 0 ? (monthlySpend / data.monthlyOrganizationSpend) * 100 : 0;
- const sharePercentage = Math.max(0, Math.min(100, spendShare));
- const shareLabel = t("format.number", {
- val: spendShare / 100,
- style: "percent",
- maximumFractionDigits: 1,
- });
- const status = customerBudgetStatus(budgets);
- const atLimit = status === "customerPortal.analytics.reachedLimit";
- return (
-
-
-
-
- {member.label
- .split(/\s+/)
- .slice(0, 2)
- .map((part) => part.charAt(0))
- .join("")
- .toLocaleUpperCase()}
-
-
-
- {member.id}
-
-
-
-
-
- {monthlyBudgets.map((budget) => (
-
1} />
- ))}
- {monthlyBudgets.length === 0 && (
-
- )}
- {otherBudgets.map((budget) => (
-
-
- {t("customerPortal.analytics.budgetPeriod")}
- {budget.budget_duration && ` (${budget.budget_duration})`}
-
-
-
- ))}
-
-
-
-
- {yearlyBudgets.map((budget) => (
- 1} />
- ))}
- {yearlyBudgets.length === 0 &&
- `${formatUsd(yearlySpend)} / ${t("customerPortal.analytics.noYearlyLimit")}`}
-
-
-
-
-
-
- {formatCompact(member.monthlyUsage?.tokens ?? 0)}
-
-
-
- {t(status)}
-
-
-
- );
- })}
+ {members.slice(start, start + PAGE_SIZE).map((member) => (
+
+
+ onMemberClick(member.id)} />
+
+
+
+ ))}
{members.length === 0 && (
diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerSpendCells.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerSpendCells.tsx
new file mode 100644
index 00000000000..7e88a49e915
--- /dev/null
+++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerSpendCells.tsx
@@ -0,0 +1,149 @@
+import { useTranslation } from "react-i18next";
+import { formatCompact, formatUsd } from "@/app/(dashboard)/usage/_components/components/overview/overviewData";
+import { TableCell, TableHead } from "@/components/ui/table";
+import { cn } from "@/lib/cva.config";
+import type { CustomerUsageEntity } from "./customerUsageApi";
+import CustomerBudgetSpend, { CustomerBudgetWindow } from "./CustomerBudgetSpend";
+import { customerBudgetPeriod, customerBudgetStatus, customerBudgetUsage } from "./customerBudgetUsage";
+
+export function CustomerSpendIdentity({
+ entity: member,
+ onClick,
+}: {
+ entity: CustomerUsageEntity;
+ onClick: () => void;
+}) {
+ return (
+
+
+ {member.label
+ .split(/\s+/)
+ .slice(0, 2)
+ .map((part) => part.charAt(0))
+ .join("")
+ .toLocaleUpperCase()}
+
+
+
+ {member.id}
+
+
+ );
+}
+
+export function CustomerSpendHeaders() {
+ const { t } = useTranslation();
+ return (
+ <>
+ {t("customerPortal.analytics.monthlySpend")}
+ {t("customerPortal.analytics.yearlySpend")}
+ {t("customerPortal.analytics.customerSpendShare")}
+ {t("customerPortal.analytics.tokens")}
+ {t("customerPortal.analytics.status")}
+ >
+ );
+}
+
+export function CustomerSpendCells({
+ entity: member,
+ monthlySpend,
+ yearlySpend,
+ tokens,
+ organizationSpend,
+}: {
+ entity: CustomerUsageEntity;
+ monthlySpend: number;
+ yearlySpend: number;
+ tokens: number;
+ organizationSpend: number;
+}) {
+ const { t } = useTranslation();
+ const budgets = customerBudgetUsage(member);
+ const monthlyBudgets = budgets.filter((budget) => customerBudgetPeriod(budget.budget_duration) === "monthly");
+ const yearlyBudgets = budgets.filter((budget) => customerBudgetPeriod(budget.budget_duration) === "yearly");
+ const otherBudgets = budgets.filter((budget) => customerBudgetPeriod(budget.budget_duration) === "other");
+
+ const spendShare = organizationSpend > 0 ? (monthlySpend / organizationSpend) * 100 : 0;
+ const sharePercentage = Math.max(0, Math.min(100, spendShare));
+ const shareLabel = t("format.number", {
+ val: spendShare / 100,
+ style: "percent",
+ maximumFractionDigits: 1,
+ });
+ const status = customerBudgetStatus(budgets);
+ const atLimit = status === "customerPortal.analytics.reachedLimit";
+
+ return (
+ <>
+
+
+ {monthlyBudgets.map((budget) => (
+
1} />
+ ))}
+ {monthlyBudgets.length === 0 && (
+
+ )}
+ {otherBudgets.map((budget) => (
+
+
+ {t("customerPortal.analytics.budgetPeriod")}
+ {budget.budget_duration && ` (${budget.budget_duration})`}
+
+
+
+ ))}
+
+
+
+
+ {yearlyBudgets.map((budget) => (
+ 1} />
+ ))}
+ {yearlyBudgets.length === 0 && `${formatUsd(yearlySpend)} / ${t("customerPortal.analytics.noYearlyLimit")}`}
+
+
+
+
+
+ {formatCompact(tokens)}
+
+
+ {t(status)}
+
+
+ >
+ );
+}
diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerUsageApi.test.ts b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerUsageApi.test.ts
index cd13a5c3d6f..02cd0b077de 100644
--- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerUsageApi.test.ts
+++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerUsageApi.test.ts
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerBaseUrlGetter } from "@/lib/http/runtime";
import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi";
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
-import { customerHierarchyUsageCall } from "./customerUsageApi";
+import { customerChildSpendCall, customerHierarchyUsageCall, customerUsageEntity } from "./customerUsageApi";
const audit = {
created_at: "2026-11-01T00:00:00Z",
@@ -94,6 +94,7 @@ const requestUrl = (input: Parameters[0]) =>
const fetchMock = vi.fn();
const urls = () => fetchMock.mock.calls.map(([input]) => requestUrl(input));
const dailyUrls = () => urls().filter((url) => url.pathname.endsWith("/daily/activity"));
+const organizationTotalUrls = () => urls().filter((url) => url.pathname.endsWith("/daily/activity/aggregated"));
const request = {
scope: { org: "org-1", team: null, member: null },
accessToken: "test-session",
@@ -145,6 +146,17 @@ const activityResponse = (url: URL) => {
const serve = (input: Parameters[0]): Response => {
const url = requestUrl(input);
+ if (url.pathname === "/management/v1/customer_organizations/daily/activity/aggregated") {
+ const organizationId = url.searchParams.get("customer_org_id");
+ const selected = records.filter((row) => row.organization === organizationId);
+ return json({
+ results: selected.map((row) => daily(row, row.organization)),
+ metadata: {
+ ...EMPTY_DAILY_ACTIVITY_METADATA,
+ total_spend: selected.reduce((sum, row) => sum + row.metrics.spend, 0),
+ },
+ });
+ }
if (url.pathname.endsWith("/daily/activity")) return activityResponse(url);
if (url.pathname === "/management/v1/customer_organizations/org-1") return json(organization);
if (url.pathname === "/management/v1/customer_teams") {
@@ -353,9 +365,10 @@ describe("customerHierarchyUsageCall", () => {
expect(result.monthlyOrganizationSpend).toBe(4);
expect(result.monthlyMembers.find((member) => member.id === "member-a")?.spend).toBe(3);
expect(result.yearlyMembers.find((member) => member.id === "member-a")?.spend).toBe(3);
- const organizationReads = dailyUrls().filter((url) => url.searchParams.has("customer_org_ids"));
- expect(organizationReads.map((url) => url.searchParams.get("page"))).toEqual(["1", "2"]);
- expect(organizationReads.every((url) => url.searchParams.get("start_date") === request.monthStartDate)).toBe(true);
+ expect(organizationTotalUrls()).toHaveLength(1);
+ expect(organizationTotalUrls()[0].searchParams.get("start_date")).toBe(request.monthStartDate);
+ expect(organizationTotalUrls()[0].searchParams.get("end_date")).toBe(request.monthEndDate);
+ expect(organizationTotalUrls()[0].searchParams.get("customer_org_id")).toBe(request.scope.org);
});
it("should never send an empty descendant filter and should retain unassigned usage", async () => {
@@ -423,3 +436,120 @@ describe("customerHierarchyUsageCall", () => {
expect(dailyUrls().some((url) => url.searchParams.has("customer_member_ids"))).toBe(false);
});
});
+
+describe("customerChildSpendCall", () => {
+ it.each([" org-1 ", "org-1,org-2"])(
+ "should retain the exact organization ID %s for its denominator",
+ async (organizationId) => {
+ fetchMock.mockImplementation(async (input) => {
+ const url = requestUrl(input);
+ if (
+ url.pathname.endsWith("/daily/activity/aggregated") &&
+ url.searchParams.get("customer_org_id") === organizationId
+ )
+ return json({ results: [], metadata: { ...EMPTY_DAILY_ACTIVITY_METADATA, total_spend: 29 } });
+ return serve(input);
+ });
+ const childRequest = {
+ ...request,
+ organizationId,
+ kind: "teams" as const,
+ entities: [customerUsageEntity("team-a", teams[0])],
+ };
+ const result = await customerChildSpendCall(childRequest);
+ expect(result.organizationSpend).toBe(29);
+ expect(result.monthly[0].spend).toBe(3);
+ },
+ );
+
+ it.each(["teams", "members"] as const)(
+ "should load complete totals for a busy organization without downloading its raw history for %s",
+ async (kind) => {
+ const aggregateSpend = 100_001;
+ fetchMock.mockImplementation(async (input) => {
+ const url = requestUrl(input);
+ if (url.pathname === "/management/v1/customer_organizations/daily/activity")
+ return json({ detail: "Raw activity history exceeds the page limit" }, 503);
+ if (url.pathname.endsWith("/daily/activity/aggregated"))
+ return json({
+ results: [],
+ metadata: { ...EMPTY_DAILY_ACTIVITY_METADATA, total_spend: aggregateSpend },
+ });
+ return serve(input);
+ });
+ const entities =
+ kind === "teams"
+ ? teams.map((team) => customerUsageEntity(team.customer_team_id, team))
+ : members.map((member) => customerUsageEntity(member.customer_member_id, member));
+ const childRequest = { ...request, organizationId: "org-1", kind, entities };
+ const result = await customerChildSpendCall(childRequest);
+ expect(result.organizationSpend).toBe(aggregateSpend);
+ expect(result.monthly.map((row) => [row.spend, row.tokens])).toEqual([
+ [3, 100],
+ [1, 500],
+ ]);
+ expect(result.yearly).toEqual(result.monthly);
+ expect(organizationTotalUrls()).toHaveLength(1);
+ expect(Object.fromEntries(organizationTotalUrls()[0].searchParams)).toEqual({
+ customer_org_id: "org-1",
+ start_date: request.monthStartDate,
+ end_date: request.monthEndDate,
+ });
+ },
+ );
+
+ it.each([
+ { body: { detail: "Organization totals unavailable" }, status: 503 },
+ { body: { results: [] }, status: 200 },
+ ])("should reject unavailable organization totals instead of presenting zero spend", async ({ body, status }) => {
+ fetchMock.mockImplementation(async (input) =>
+ requestUrl(input).pathname.endsWith("/daily/activity/aggregated") ? json(body, status) : serve(input),
+ );
+ const childRequest = {
+ ...request,
+ organizationId: "org-1",
+ kind: "teams" as const,
+ entities: [customerUsageEntity("team-a", teams[0])],
+ };
+ await expect(customerChildSpendCall(childRequest)).rejects.toThrow();
+ });
+
+ it("should avoid unfiltered reads for empty management pages", async () => {
+ const childRequest = { ...request, organizationId: "org-1", kind: "teams" as const, entities: [] };
+ await expect(customerChildSpendCall(childRequest)).resolves.toEqual({
+ monthly: [],
+ yearly: [],
+ organizationSpend: 0,
+ });
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("should reject empty organization filters before requesting any management usage", async () => {
+ const childRequest = {
+ ...request,
+ organizationId: "",
+ kind: "teams" as const,
+ entities: [customerUsageEntity("team-a", teams[0])],
+ };
+ await expect(customerChildSpendCall(childRequest)).rejects.toThrow();
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it.each(["team-a,team-b", ""])(
+ "should avoid unfiltered reads when only an unrepresentable ID %s exists",
+ async (id) => {
+ const childRequest = {
+ ...request,
+ organizationId: "org-1",
+ kind: "teams" as const,
+ entities: [customerUsageEntity(id, teams[0])],
+ };
+ await expect(customerChildSpendCall(childRequest)).resolves.toEqual({
+ monthly: [],
+ yearly: [],
+ organizationSpend: 0,
+ });
+ expect(fetchMock).not.toHaveBeenCalled();
+ },
+ );
+});
diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerUsageApi.ts b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerUsageApi.ts
index 4852af7540b..e6f4a14e80b 100644
--- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerUsageApi.ts
+++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerUsageApi.ts
@@ -70,8 +70,11 @@ const ID_FILTER: Record = {
customer_members: "customer_member_ids",
};
+export const isCustomerUsageIdentifier = (id: string): boolean => id.length > 0 && !id.includes(",");
+
const assertIdentifiers = (ids: readonly string[]) => {
- if (ids.some((id) => !id || id.includes(","))) throw new Error(i18n.t("customerPortal.analytics.invalidIdentifier"));
+ if (ids.some((id) => !isCustomerUsageIdentifier(id)))
+ throw new Error(i18n.t("customerPortal.analytics.invalidIdentifier"));
};
const batches = async (items: readonly T[], task: (item: T) => Promise): Promise => {
@@ -123,7 +126,11 @@ const descendants = async (scope: ResolvedScope, request: CustomerUsageRequest)
};
};
-const activityPages = async (resource: Collection, ids: readonly string[], request: CustomerUsageRequest) => {
+const activityPages = async (
+ resource: Collection,
+ ids: readonly string[],
+ request: Pick,
+) => {
assertIdentifiers(ids);
const pages: DailyActivityAggregatedResponse[] = [];
for (let page = 1; page <= MAX_PAGES; page++) {
@@ -145,6 +152,61 @@ const activityPages = async (resource: Collection, ids: readonly string[], reque
throw new Error(i18n.t("customerPortal.analytics.incompletePages"));
};
+const organizationActivityTotals = async (
+ organizationId: string,
+ request: Pick,
+) => {
+ const result = await apiClient.get(
+ `${PREFIX}/customer_organizations/daily/activity/aggregated`,
+ {
+ accessToken: request.accessToken,
+ signal: request.signal,
+ query: {
+ start_date: request.startDate,
+ end_date: request.endDate,
+ customer_org_id: organizationId,
+ },
+ },
+ );
+ if (!result.metadata) throw new Error(i18n.t("customerPortal.analytics.incompletePages"));
+ return { ...result, metadata: result.metadata };
+};
+
+export const customerChildSpendCall = async ({
+ organizationId,
+ kind,
+ entities,
+ ...request
+}: Pick<
+ CustomerUsageRequest,
+ "accessToken" | "monthStartDate" | "monthEndDate" | "yearStartDate" | "yearEndDate" | "signal"
+> & {
+ organizationId: string;
+ kind: "teams" | "members";
+ entities: readonly CustomerUsageEntity[];
+}) => {
+ const ids = entities.map((item) => item.id).filter(isCustomerUsageIdentifier);
+ if (ids.length === 0) return { monthly: [], yearly: [], organizationSpend: 0 };
+ if (!organizationId) throw new Error(i18n.t("customerPortal.analytics.invalidIdentifier"));
+ const resource = kind === "teams" ? "customer_teams" : "customer_members";
+ const monthRequest = { ...request, startDate: request.monthStartDate, endDate: request.monthEndDate };
+ const yearRequest = { ...request, startDate: request.yearStartDate, endDate: request.yearEndDate };
+ const [monthlyPages, yearlyPages, organizationActivity] = await Promise.all([
+ activityPages(resource, ids, monthRequest),
+ activityPages(resource, ids, yearRequest),
+ organizationActivityTotals(organizationId, monthRequest),
+ ]);
+ const monthly = mergeCustomerActivityPages(monthlyPages);
+ const yearly = mergeCustomerActivityPages(yearlyPages);
+ const labels = new Map(entities.map((item) => [item.id, item.label]));
+ const options = { unassignedLabel: "" };
+ return {
+ monthly: customerEntityRankings(monthly.results, labels, monthly.metadata, options),
+ yearly: customerEntityRankings(yearly.results, labels, yearly.metadata, options),
+ organizationSpend: organizationActivity.metadata.total_spend,
+ };
+};
+
interface ActivityJob {
dimension: "scope" | "teams" | "members" | "monthlyOrganization" | "monthlyMembers" | "yearlyMembers";
resource: Collection;
@@ -158,7 +220,7 @@ const chunkJobs = (resource: Collection, dimension: ActivityJob["dimension"], id
ids: ids.slice(index * PAGE_SIZE, (index + 1) * PAGE_SIZE),
}));
-const entity = (id: string, record: Organization | Team | Member): CustomerUsageEntity => {
+export const customerUsageEntity = (id: string, record: Organization | Team | Member): CustomerUsageEntity => {
const budget = "effective_budget" in record ? record.effective_budget : record.budget;
return {
id,
@@ -246,7 +308,10 @@ export const customerHierarchyUsageCall = async (request: CustomerUsageRequest):
};
const activity = await batches(jobs, async (job) => ({
dimension: job.dimension,
- pages: await activityPages(job.resource, job.ids, requests[job.dimension]),
+ pages:
+ job.dimension === "monthlyOrganization"
+ ? [await organizationActivityTotals(request.scope.org, monthRequest)]
+ : await activityPages(job.resource, job.ids, requests[job.dimension]),
}));
const primary = mergeCustomerActivityPages(
activity.filter((result) => result.dimension === "scope").flatMap((result) => result.pages),
@@ -285,9 +350,9 @@ export const customerHierarchyUsageCall = async (request: CustomerUsageRequest):
);
return {
...primary,
- organization: entity(selected.organization.customer_org_id, selected.organization),
- team: selected.team ? entity(selected.team.customer_team_id, selected.team) : null,
- member: selected.member ? entity(selected.member.customer_member_id, selected.member) : null,
+ organization: customerUsageEntity(selected.organization.customer_org_id, selected.organization),
+ team: selected.team ? customerUsageEntity(selected.team.customer_team_id, selected.team) : null,
+ member: selected.member ? customerUsageEntity(selected.member.customer_member_id, selected.member) : null,
teams: customerEntityRankings(teamDays, teamLabels, primary.metadata, {
unassignedLabel: i18n.t("customerPortal.analytics.unassignedTeam"),
onlyEntityId: selected.member ? selected.team?.customer_team_id : undefined,
@@ -303,7 +368,7 @@ export const customerHierarchyUsageCall = async (request: CustomerUsageRequest):
selected.member ? selected.team?.customer_team_id : undefined,
),
memberResults: customerEntityDailyData(primary.results, memberDays, memberLabels),
- memberBudgets: children.members.map((member) => entity(member.customer_member_id, member)),
+ memberBudgets: children.members.map((member) => customerUsageEntity(member.customer_member_id, member)),
monthlyOrganizationSpend: monthlyOrganization.metadata.total_spend,
monthlyMembers: customerEntityRankings(monthlyMemberDays, memberLabels, monthlyOrganization.metadata, {
unassignedLabel: i18n.t("customerPortal.analytics.unassignedMember"),
diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/useCustomerHierarchy.ts b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/useCustomerHierarchy.ts
index 1155d3b52c8..eb37354158a 100644
--- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/useCustomerHierarchy.ts
+++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/useCustomerHierarchy.ts
@@ -7,6 +7,8 @@ import i18n from "@/i18n/i18n";
import { isProxyAdminRole } from "@/utils/roles";
import type { components } from "@/lib/http/schema";
import { resolveCustomerHierarchy, type CustomerHierarchyScope } from "./customerHierarchyApi";
+import { customerChildSpendCall, customerUsageEntity } from "./customerUsageApi";
+import { calendarDate, customerPresetRange } from "./customerUsageRange";
import type {
CreatedCustomerHierarchyEntity,
CustomerHierarchyEntity,
@@ -103,6 +105,56 @@ function organizationView(
return requested === "budget_groups" && !team ? "budget_groups" : children;
}
+function useChildSpend(
+ list: ChildList | undefined,
+ scope: CustomerHierarchyScope | null,
+ accessToken: string | null,
+ enabled: boolean,
+) {
+ const childEntities =
+ list?.kind === "teams"
+ ? list.data.map((team) => customerUsageEntity(team.customer_team_id, team))
+ : list?.data.map((member) => customerUsageEntity(member.customer_member_id, member)) ?? [];
+ const month = customerPresetRange("month");
+ const year = customerPresetRange("YTD");
+ const monthStartDate = calendarDate(month.from);
+ const monthEndDate = calendarDate(month.to);
+ const yearStartDate = calendarDate(year.from);
+ const yearEndDate = calendarDate(year.to);
+ const activeList = enabled ? list : undefined;
+ return useQuery({
+ queryKey: [
+ "customer-hierarchy",
+ "child-spend",
+ scope?.org,
+ scope?.team,
+ list?.kind,
+ childEntities.map((entity) => entity.id),
+ monthStartDate,
+ monthEndDate,
+ yearStartDate,
+ yearEndDate,
+ ],
+ queryFn:
+ activeList && accessToken && scope
+ ? ({ signal }) => {
+ const request = {
+ organizationId: scope.org,
+ kind: activeList.kind,
+ entities: childEntities,
+ accessToken,
+ monthStartDate,
+ monthEndDate,
+ yearStartDate,
+ yearEndDate,
+ signal,
+ };
+ return customerChildSpendCall(request);
+ }
+ : skipToken,
+ });
+}
+
export default function useCustomerHierarchy() {
const { accessToken, userRole, isLoading } = useAuthorized();
const canManage = isProxyAdminRole(userRole);
@@ -140,6 +192,7 @@ export default function useCustomerHierarchy() {
? ({ signal }) => customerHierarchyChildren(childContext, childTab, page, signal)
: skipToken,
});
+ const childUsage = useChildSpend(children.data, scope, accessToken, enabled && showChildren);
useEffect(() => {
if (!children.isSuccess) return;
if (page <= Math.max(1, children.data.meta.total_pages)) return;
@@ -190,6 +243,7 @@ export default function useCustomerHierarchy() {
scope,
context,
children,
+ childUsage,
selected,
tab,
childTab,
diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/manage/page.integration.test.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/manage/page.integration.test.tsx
index 7da730f1aec..93b42b3d5c3 100644
--- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/manage/page.integration.test.tsx
+++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/manage/page.integration.test.tsx
@@ -4,6 +4,7 @@ import { toast } from "sonner";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerBaseUrlGetter } from "@/lib/http/runtime";
import { uiHref } from "@/utils/uiHref";
+import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi";
import {
act,
fireEvent,
@@ -66,6 +67,9 @@ const requestedUrls = () => fetchMock.mock.calls.map(([input]) => requestUrl(inp
function serve(input: Parameters[0]): Response {
const url = requestUrl(input);
+ if (url.pathname.endsWith("/daily/activity/aggregated"))
+ return json({ results: [], metadata: EMPTY_DAILY_ACTIVITY_METADATA });
+ if (url.pathname.endsWith("/daily/activity")) return json({ results: [], metadata: EMPTY_DAILY_ACTIVITY_METADATA });
if (url.pathname === "/models")
return json({ data: [{ id: "configured-model", object: "model", created: 0, owned_by: "openai" }] });
if (url.pathname === "/management/v1/customer_organizations/org-1") return json(organization);
@@ -113,6 +117,118 @@ function BrowserNavigationProbe() {
}
describe("customer hierarchy management", () => {
+ it.each(["teams", "members"] as const)(
+ "should show usage-style budgets, spend share, tokens and actions for customer %s",
+ async (kind) => {
+ const id = kind === "teams" ? "team-a" : "member-a";
+ const label = kind === "teams" ? "Product" : "Member One";
+ const collection = kind === "teams" ? "customer_teams" : "customer_members";
+ const budgetUsage = [
+ { source: "scalar", budget_duration: "1mo", max_budget: 10, spend: 3, reset_at: null },
+ { source: "window", budget_duration: "12mo", max_budget: 100, spend: 7, reset_at: null },
+ ];
+ fetchMock.mockImplementation(async (input) => {
+ const url = requestUrl(input);
+ if (url.pathname === `/management/v1/${collection}`) {
+ return json({
+ data: [
+ { ...(kind === "teams" ? product : member), budget_usage: budgetUsage },
+ {
+ ...(kind === "teams" ? product : member),
+ [kind === "teams" ? "customer_team_id" : "customer_member_id"]: `${id},other`,
+ alias: "Comma ID",
+ },
+ ],
+ meta: { total_count: 2, page: 1, page_size: 20, total_pages: 1 },
+ });
+ }
+ if (url.pathname.endsWith("/daily/activity/aggregated"))
+ return json({ results: [], metadata: { ...EMPTY_DAILY_ACTIVITY_METADATA, total_spend: 12 } });
+ if (!url.pathname.endsWith("/daily/activity")) return serve(input);
+ const spend = url.pathname.includes("customer_organizations") ? 12 : 3;
+ const metrics = {
+ spend,
+ total_tokens: 250,
+ prompt_tokens: 250,
+ completion_tokens: 0,
+ api_requests: 1,
+ successful_requests: 1,
+ failed_requests: 0,
+ cache_read_input_tokens: 0,
+ cache_creation_input_tokens: 0,
+ };
+ return json({
+ results: [
+ {
+ date: url.searchParams.get("start_date"),
+ metrics,
+ breakdown: {
+ models: {},
+ model_groups: {},
+ providers: {},
+ mcp_servers: {},
+ api_keys: {},
+ entities: { [id]: { metrics, metadata: {}, api_key_breakdown: {} } },
+ },
+ },
+ ],
+ metadata: {
+ ...EMPTY_DAILY_ACTIVITY_METADATA,
+ total_spend: spend,
+ total_tokens: 250,
+ total_api_requests: 1,
+ },
+ });
+ });
+ renderWithProviders(, { searchParams: `?org=org-1&tab=${kind}` });
+ const table = await screen.findByRole("table", {
+ name: kind === "teams" ? "Customer teams" : "Customer members",
+ });
+ const progress = await within(table).findByRole("progressbar", { name: "Spend this month / monthly limit" });
+ expect(progress).toHaveAttribute("aria-valuenow", "30");
+ expect(within(table).getByText("$3.00 / $10.00")).toBeInTheDocument();
+ expect(within(table).getByText("$7.00 remains")).toBeInTheDocument();
+ expect(within(table).getByText("$7.00 / $100.00")).toBeInTheDocument();
+ expect(within(table).getByRole("meter", { name: "Share of customer spend" })).toHaveAttribute(
+ "aria-valuenow",
+ "25",
+ );
+ expect(within(table).getByRole("cell", { name: "250" })).toBeInTheDocument();
+ expect(within(table).getByText("Within budget")).toBeInTheDocument();
+ expect(within(table).getByRole("button", { name: `Edit ${label}` })).toBeEnabled();
+ const validRow = within(table).getByRole("row", { name: new RegExp(label) });
+ expect(within(validRow).getByRole("link", { name: "View usage" })).toHaveAttribute(
+ "href",
+ `${uiHref("customers/usage")}?org=org-1&${kind === "teams" ? "team" : "member"}=${id}`,
+ );
+ const unavailableRow = within(table).getByRole("row", { name: /Comma ID/ });
+ expect(within(unavailableRow).getByRole("status")).toHaveTextContent("Unavailable");
+ expect(within(unavailableRow).queryByRole("meter")).not.toBeInTheDocument();
+ const activity = requestedUrls().filter((url) => url.pathname.endsWith("/daily/activity"));
+ expect(activity).toHaveLength(2);
+ expect(
+ activity
+ .filter((url) => url.pathname.includes(collection))
+ .every((url) => url.searchParams.get(kind === "teams" ? "customer_team_ids" : "customer_member_ids") === id),
+ ).toBe(true);
+ },
+ );
+
+ it.each(["/daily/activity", "/daily/activity/aggregated"])(
+ "should keep row actions without showing zero usage when %s fails",
+ async (route) => {
+ fetchMock.mockImplementation(async (input) =>
+ requestUrl(input).pathname.endsWith(route) ? json({ detail: "Usage unavailable" }, 503) : serve(input),
+ );
+ renderWithProviders(, { searchParams: "?org=org-1" });
+ const table = await screen.findByRole("table", { name: "Customer teams" });
+ expect(await screen.findByRole("alert")).toHaveTextContent("Usage unavailable");
+ expect(within(table).getByRole("status")).toHaveTextContent("Unavailable");
+ expect(within(table).queryByRole("meter")).not.toBeInTheDocument();
+ expect(within(table).getByRole("button", { name: "Edit Product" })).toBeEnabled();
+ },
+ );
+
it("should preserve a draft on refresh and conflict, then reload and save the current configuration", async () => {
const user = userEvent.setup();
const standard = {
diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/usage/page.integration.test.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/usage/page.integration.test.tsx
index af9ee65d601..3648c9169a1 100644
--- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/usage/page.integration.test.tsx
+++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/usage/page.integration.test.tsx
@@ -148,11 +148,12 @@ const activityUrl = (resource: string, id: string, endDate = "2026-11-03") => {
};
const serve = (input: Parameters[0]): Response => {
const url = requestUrl(input);
- if (url.pathname.endsWith("/daily/activity")) {
- const resource = url.pathname.split("/").at(-3);
+ const aggregated = url.pathname.endsWith("/daily/activity/aggregated");
+ if (url.pathname.endsWith("/daily/activity") || aggregated) {
+ const resource = url.pathname.split("/").at(aggregated ? -4 : -3);
const dimension = activityDimension(resource);
const filter = filterFor(resource);
- const ids = url.searchParams.get(filter)?.split(",") ?? [];
+ const ids = aggregated ? [url.searchParams.get("customer_org_id")] : url.searchParams.get(filter)?.split(",") ?? [];
const selected = records
.filter((record) => ids.includes(record[dimension]))
.filter(
@@ -173,7 +174,13 @@ const serve = (input: Parameters[0]): Response => {
},
};
});
- return json({ results, metadata: EMPTY_DAILY_ACTIVITY_METADATA });
+ return json({
+ results,
+ metadata: {
+ ...EMPTY_DAILY_ACTIVITY_METADATA,
+ total_spend: selected.reduce((total, record) => total + record.metrics.spend, 0),
+ },
+ });
}
if (url.pathname === "/management/v1/customer_organizations/org-1") return json(organization);
if (url.pathname === "/management/v1/customer_teams") return json(list(teams));