mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
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
This commit is contained in:
parent
be623b5bde
commit
f10b42b1ce
5 changed files with 253 additions and 5 deletions
|
|
@ -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
|
|||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("customerPortal.common.blockHint")}</p>
|
||||
|
||||
<CustomerUsageOverview customerId={customerId} />
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="flex items-baseline gap-3 border-b px-4 py-3">
|
||||
<h2 className="text-sm font-semibold">{t("customerPortal.detail.keysTitle")}</h2>
|
||||
|
|
|
|||
|
|
@ -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<typeof fetch>();
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||
const requestUrl = (input: Parameters<typeof fetch>[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(<CustomerUsageOverview customerId="customer-1" />);
|
||||
|
||||
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(<CustomerUsageOverview customerId="customer-1" />, {
|
||||
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(<CustomerUsageOverview customerId="customer-1" />);
|
||||
expect(await screen.findByText("customer-primary")).toBeInTheDocument();
|
||||
fetchMock.mockImplementation(() => new Promise<Response>(() => {}));
|
||||
|
||||
rerender(<CustomerUsageOverview customerId="customer-2" />);
|
||||
|
||||
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(<CustomerUsageOverview customerId="customer-1" />);
|
||||
|
||||
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(<CustomerUsageOverview customerId="customer-1" />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<section className="space-y-3" aria-label={t("customerPortal.usage.title")}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">{t("customerPortal.usage.title")}</h2>
|
||||
<p className="text-xs text-muted-foreground">{t("customerPortal.usage.description")}</p>
|
||||
</div>
|
||||
<AdvancedDatePicker
|
||||
value={dateValue}
|
||||
presetShortLabel={preset}
|
||||
showTimeRange={false}
|
||||
onValueChange={(value, selectedPreset) => void setParams(dateRangePatch(value, selectedPreset))}
|
||||
/>
|
||||
</div>
|
||||
{usage.isError ? (
|
||||
<ErrorState title={t("customerPortal.usage.error")} onRetry={() => void usage.refetch()} />
|
||||
) : (
|
||||
<>
|
||||
<UsageStatStrip
|
||||
results={results}
|
||||
totals={totals}
|
||||
loading={usage.isPending}
|
||||
requestCountsPending={usage.isPending}
|
||||
budget={null}
|
||||
showBudgetHint={false}
|
||||
/>
|
||||
<UsageBreakdownChart results={results} loading={usage.isPending} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="grid grid-cols-1 overflow-hidden rounded-xl border bg-card sm:grid-cols-2 lg:grid-cols-4 lg:divide-x">
|
||||
<StatCell trend={<Sparkline data={daily} dataKey="spend" color={BRAND} className="h-14" />}>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</StatCell>
|
||||
<StatCell trend={<Sparkline data={daily} dataKey="requests" color={BRAND} className="h-14" />}>
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue