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.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.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));