From f10b42b1ce08c455b86ae26192b83cb3fa74b7ac Mon Sep 17 00:00:00 2001 From: ishaan-j-berri Date: Fri, 13 Nov 2026 01:45:40 +0800 Subject: [PATCH] feat(ui): add daily model usage to customer pages (#51419) * feat(ui): show daily model usage on customer pages * test(ui): assert the customer budget hint is hidden --- .../customers/_components/CustomerDetail.tsx | 3 + ...CustomerUsageOverview.integration.test.tsx | 155 ++++++++++++++++++ .../_components/CustomerUsageOverview.tsx | 84 ++++++++++ .../components/overview/UsageStatStrip.tsx | 11 +- .../src/i18n/locales/en.json | 5 + 5 files changed, 253 insertions(+), 5 deletions(-) create mode 100644 litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerUsageOverview.integration.test.tsx create mode 100644 litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerUsageOverview.tsx diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerDetail.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerDetail.tsx index 6e9e94dedf2..bc398a48238 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerDetail.tsx +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerDetail.tsx @@ -14,6 +14,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { customerStatus, useCustomerUsage } from "./customerPortalData"; import EditCustomerDialog from "./EditCustomerDialog"; import StatusBadge from "./StatusBadge"; +import CustomerUsageOverview from "./CustomerUsageOverview"; function UsageSummary({ spend, budget }: { spend: number; budget: EndUser["litellm_budget_table"] | undefined }) { const { t } = useTranslation(); @@ -112,6 +113,8 @@ export default function CustomerDetail({ customerId, onBack }: { customerId: str

{t("customerPortal.common.blockHint")}

+ +

{t("customerPortal.detail.keysTitle")}

diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerUsageOverview.integration.test.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerUsageOverview.integration.test.tsx new file mode 100644 index 00000000000..1c873acb6ff --- /dev/null +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerUsageOverview.integration.test.tsx @@ -0,0 +1,155 @@ +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerBaseUrlGetter } from "@/lib/http/runtime"; +import { formatDate } from "@/components/networking"; +import { relativeTimeOptions } from "@/components/shared/date_range_presets"; +import { + EMPTY_DAILY_ACTIVITY_METADATA, + EMPTY_DAILY_ACTIVITY_RESPONSE, + type DailyActivityAggregatedResponse, +} from "@/components/UsagePage/dailyActivityApi"; +import type { SpendMetrics } from "@/components/UsagePage/types"; +import { renderWithProviders, screen, testQueryClient, waitFor } from "../../../../../tests/test-utils"; +import CustomerUsageOverview from "./CustomerUsageOverview"; + +const metrics = (spend: number, requests: number, tokens: number): SpendMetrics => ({ + spend, + api_requests: requests, + successful_requests: requests, + failed_requests: 0, + total_tokens: tokens, + prompt_tokens: tokens * 0.8, + completion_tokens: tokens * 0.2, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, +}); + +const daily = (date: string, model: string, values: SpendMetrics) => ({ + date, + metrics: values, + breakdown: { model_groups: { [model]: { metrics: values, metadata: {} } } }, +}); + +const activity: DailyActivityAggregatedResponse = { + results: [ + daily("2026-11-01", "customer-primary", metrics(12, 5, 500)), + daily("2026-11-02", "customer-reserve", metrics(3, 2, 200)), + ], + metadata: { + ...EMPTY_DAILY_ACTIVITY_METADATA, + total_spend: 15, + total_api_requests: 7, + total_successful_requests: 7, + total_tokens: 700, + total_prompt_tokens: 560, + total_completion_tokens: 140, + }, +}; + +const fetchMock = vi.fn(); +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +const requestUrl = (input: Parameters[0]) => + new URL(input instanceof Request ? input.url : String(input)); +const requestedUrls = () => fetchMock.mock.calls.map(([input]) => requestUrl(input)); + +describe("CustomerUsageOverview", () => { + beforeEach(() => { + testQueryClient.clear(); + registerBaseUrlGetter(() => "http://localhost"); + fetchMock.mockReset(); + fetchMock.mockResolvedValue(json(activity)); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("requestIdleCallback", (callback: IdleRequestCallback) => { + callback({ didTimeout: false, timeRemaining: () => 50 }); + return 1; + }); + }); + + afterEach(() => { + testQueryClient.clear(); + vi.unstubAllGlobals(); + }); + + it("should render customer-scoped daily usage with the Usage page's totals and model breakdown", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + expect(await screen.findByText("$15.00")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Daily usage" })).toBeInTheDocument(); + expect(screen.getByTitle("700 tokens")).toBeInTheDocument(); + expect(screen.getByText("customer-primary")).toBeInTheDocument(); + expect(screen.getByText("customer-reserve")).toBeInTheDocument(); + expect(screen.queryByText("No budget set")).not.toBeInTheDocument(); + const url = requestedUrls()[0]; + expect(url.pathname).toBe("/customer/daily/activity/aggregated"); + expect(url.searchParams.get("end_user_ids")).toBe("customer-1"); + expect(url.searchParams.get("include_entity_breakdown")).toBe("false"); + const month = relativeTimeOptions.find((option) => option.shortLabel === "MTD")!.getValue(); + expect(url.searchParams.get("start_date")).toBe(formatDate(month.from)); + expect(url.searchParams.get("end_date")).toBe(formatDate(month.to)); + + await user.click(screen.getByRole("radio", { name: "Tokens" })); + expect(screen.getByText("500")).toBeInTheDocument(); + expect(screen.getByText("200")).toBeInTheDocument(); + await user.click(screen.getByRole("radio", { name: "Requests" })); + expect(screen.getByText("5")).toBeInTheDocument(); + }); + + it("should change the customer date range without losing the selected customer or unrelated URL state", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { + searchParams: "?id=customer-1&source=overview", + onUrlUpdate, + }); + expect(await screen.findByText("customer-primary")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: / - / })); + await user.click(screen.getByRole("button", { name: /Last 7 days/ })); + await user.click(screen.getByRole("button", { name: "Apply" })); + + const week = relativeTimeOptions.find((option) => option.shortLabel === "7d")!.getValue(); + await waitFor(() => expect(requestedUrls().at(-1)?.searchParams.get("start_date")).toBe(formatDate(week.from))); + expect(requestedUrls().at(-1)?.searchParams.get("end_user_ids")).toBe("customer-1"); + expect(onUrlUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ queryString: "?id=customer-1&source=overview&range=7d" }), + ); + }); + + it("should remove the previous customer's values while the next customer's usage loads", async () => { + const { rerender } = renderWithProviders(); + expect(await screen.findByText("customer-primary")).toBeInTheDocument(); + fetchMock.mockImplementation(() => new Promise(() => {})); + + rerender(); + + expect(screen.queryByText("customer-primary")).not.toBeInTheDocument(); + expect(screen.queryByText("$15.00")).not.toBeInTheDocument(); + expect(screen.getByRole("status", { name: "Loading top models" })).toBeInTheDocument(); + await waitFor(() => expect(requestedUrls().at(-1)?.searchParams.get("end_user_ids")).toBe("customer-2")); + }); + + it("should show a failed usage request as an error and let the customer retry", async () => { + const user = userEvent.setup(); + fetchMock.mockResolvedValueOnce(json({ error: "Unavailable" }, 500)); + renderWithProviders(); + + expect(await screen.findByRole("alert")).toHaveTextContent("Could not load customer usage"); + expect(screen.queryByText("$0.00")).not.toBeInTheDocument(); + expect(screen.queryByText("No usage in this range")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Retry" })); + + expect(await screen.findByText("$15.00")).toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("should show an empty range only after the customer query returns no activity", async () => { + fetchMock.mockResolvedValue(json(EMPTY_DAILY_ACTIVITY_RESPONSE)); + renderWithProviders(); + + expect(await screen.findByText("No usage in this range")).toBeInTheDocument(); + expect(screen.getByText("$0.00")).toBeInTheDocument(); + expect(screen.queryByRole("status", { name: "Loading top models" })).not.toBeInTheDocument(); + }); +}); diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerUsageOverview.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerUsageOverview.tsx new file mode 100644 index 00000000000..8adc6760896 --- /dev/null +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerUsageOverview.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { skipToken, useQuery } from "@tanstack/react-query"; +import { useQueryStates } from "nuqs"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { + dateRangeFromParams, + dateRangePatch, + defaultUsageDateRange, + usageUrlParsers, +} from "@/app/(dashboard)/usage/_components/usageUrlState"; +import { overviewTotals } from "@/app/(dashboard)/usage/_components/components/overview/overviewData"; +import UsageBreakdownChart from "@/app/(dashboard)/usage/_components/components/overview/UsageBreakdownChart"; +import UsageStatStrip from "@/app/(dashboard)/usage/_components/components/overview/UsageStatStrip"; +import { dailyActivityAggregatedCall } from "@/components/networking"; +import AdvancedDatePicker from "@/components/shared/advanced_date_picker"; +import { ErrorState } from "@/components/shared/ErrorState"; +import { + EMPTY_DAILY_ACTIVITY_METADATA, + EMPTY_DAILY_ACTIVITY_RESPONSE, + toDailyData, +} from "@/components/UsagePage/dailyActivityApi"; + +const dateParsers = { + range: usageUrlParsers.range, + from: usageUrlParsers.from, + to: usageUrlParsers.to, +}; + +export default function CustomerUsageOverview({ customerId }: { customerId: string }) { + const { t } = useTranslation(); + const { accessToken } = useAuthorized(); + const [params, setParams] = useQueryStates(dateParsers); + const defaultRange = useMemo(() => defaultUsageDateRange("month_to_date"), []); + const dateValue = useMemo(() => dateRangeFromParams(params) ?? defaultRange.value, [params, defaultRange]); + const preset = params.range ?? (params.from === null && params.to === null ? defaultRange.preset : null); + const startTime = dateValue.from; + const endTime = dateValue.to; + const request = + accessToken && startTime && endTime + ? { accessToken, startTime, endTime, entityIds: [customerId], includeEntityBreakdown: false } + : null; + const usage = useQuery({ + queryKey: ["customer-portal", "daily-usage", customerId, startTime?.getTime(), endTime?.getTime()], + queryFn: request ? () => dailyActivityAggregatedCall("customer", request) : skipToken, + }); + const data = usage.data ?? EMPTY_DAILY_ACTIVITY_RESPONSE; + const results = useMemo(() => toDailyData(data), [data]); + const totals = useMemo(() => overviewTotals(data.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA, null), [data]); + + return ( +
+
+
+

{t("customerPortal.usage.title")}

+

{t("customerPortal.usage.description")}

+
+ void setParams(dateRangePatch(value, selectedPreset))} + /> +
+ {usage.isError ? ( + void usage.refetch()} /> + ) : ( + <> + + + + )} +
+ ); +} diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.tsx index 48eb1e96691..361f53b32d9 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.tsx +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.tsx @@ -21,6 +21,7 @@ interface UsageStatStripProps { loading: boolean; requestCountsPending: boolean; budget: number | null; + showBudgetHint?: boolean; } /** Spend, requests, tokens and averages for a range, each with a daily sparkline. */ @@ -30,9 +31,13 @@ export default function UsageStatStrip({ loading, requestCountsPending, budget, + showBudgetHint = true, }: UsageStatStripProps) { const { t } = useTranslation(); const daily = useMemo(() => dailyTotals(results), [results]); + const budgetHint = budget + ? t("usage.statStrip.budgetHint", { budget: t("format.usd", { val: budget }) }) + : t("usage.statStrip.noBudget"); return (
}> @@ -40,11 +45,7 @@ export default function UsageStatStrip({ label={t("usage.statStrip.spend")} value={t("format.usd", { val: totals.spend })} pending={loading} - hint={ - budget - ? t("usage.statStrip.budgetHint", { budget: t("format.usd", { val: budget }) }) - : t("usage.statStrip.noBudget") - } + hint={showBudgetHint ? budgetHint : undefined} /> }> diff --git a/litellm/ui/litellm-dashboard/src/i18n/locales/en.json b/litellm/ui/litellm-dashboard/src/i18n/locales/en.json index 491aeb058db..9bdb41da104 100644 --- a/litellm/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/litellm/ui/litellm-dashboard/src/i18n/locales/en.json @@ -20656,6 +20656,11 @@ "unnamedKey": "Unnamed key", "back": "Back to customers" }, + "usage": { + "title": "Usage", + "description": "Daily activity by model across this customer's keys.", + "error": "Could not load customer usage" + }, "createDialog": { "title": "Create customer", "description": "A customer is an end user of your product. Pass its id as `user` on every request to track spend and apply its limits.",