fix(ui): remove the Chargebacks page from Customer Portal (#51474)

* fix(ui): remove Chargebacks from Customer Portal navigation

* fix(ui): delete the Chargebacks page and unused resources
This commit is contained in:
tin-berri 2026-11-12 15:11:40 -08:00 • committed by GitHub
parent 8de4decd81
commit 0fe6927426
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 3 additions and 403 deletions

View file

@ -1,106 +0,0 @@
import { describe, expect, test } from "vitest";
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
import type { CustomerUsage } from "../customers/_components/customerPortalData";
import { rowsByCustomer, rowsByCustomerAndKey, rowsByCustomerAndModel, sumRows, toCsv } from "./chargebackRows";
const totals = (spend: number, requests = 1) => ({
spend,
requests,
inputTokens: requests * 10,
outputTokens: requests * 5,
});
const usage: ReadonlyMap<string, CustomerUsage> = new Map([
[
"acme",
{
...totals(6, 3),
keys: [
{ hash: "sk-hash-aaaaaaaaaaaa", alias: "prod", ...totals(5, 2) },
{ hash: "sk-hash-bbbbbbbbbbbb", alias: null, ...totals(1, 1) },
],
},
],
["idle", { ...totals(0, 0), keys: [] }],
["globex", { ...totals(9, 1), keys: [{ hash: "h3", alias: "main", ...totals(9, 1) }] }],
]);
describe("rowsByCustomer", () => {
test("one row per customer with traffic, highest spend first", () => {
expect(rowsByCustomer(usage).map((r) => [r.customer, r.dimension, r.spend])).toEqual([
["globex", null, 9],
["acme", null, 6],
]);
});
});
describe("rowsByCustomerAndKey", () => {
test("one row per customer and key, labelled by alias or hash prefix", () => {
expect(rowsByCustomerAndKey(usage).map((r) => [r.customer, r.dimension, r.spend])).toEqual([
["globex", "main", 9],
["acme", "prod", 5],
["acme", "sk-hash-bbbb", 1],
]);
});
});
describe("rowsByCustomerAndModel", () => {
const metrics = (spend: number): SpendMetrics => ({
spend,
prompt_tokens: 1,
completion_tokens: 1,
total_tokens: 2,
api_requests: 1,
successful_requests: 1,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
});
const day = (models: Record<string, number>): DailyData => ({
date: "2026-11-01",
metrics: metrics(0),
breakdown: {
models: Object.fromEntries(
Object.entries(models).map(([m, s]) => [m, { metrics: metrics(s), metadata: {}, api_key_breakdown: {} }]),
),
model_groups: {},
mcp_servers: {},
providers: {},
api_keys: {},
entities: {},
},
});
test("sums each customer's models across days", () => {
const rows = rowsByCustomerAndModel(new Map([["acme", [day({ "gpt-5": 1, claude: 2 }), day({ "gpt-5": 4 })]]]));
expect(rows.map((r) => [r.customer, r.dimension, r.spend, r.requests])).toEqual([
["acme", "gpt-5", 5, 2],
["acme", "claude", 2, 1],
]);
});
});
describe("sumRows and toCsv", () => {
test("total row matches the sum of the rows", () => {
const expected = { spend: 15, requests: 4, inputTokens: 40, outputTokens: 20 };
expect(sumRows(rowsByCustomer(usage))).toEqual(expected);
});
test("csv carries alias, optional dimension column and escaped quotes", () => {
const rows = rowsByCustomerAndKey(usage).slice(0, 1);
const csv = toCsv(rows, new Map([["globex", 'Globex "Inc"']]), "key");
expect(csv.split("\n")).toEqual([
'"customer_id","customer","key","requests","input_tokens","output_tokens","spend_usd"',
'"globex","Globex ""Inc""","main","1","10","5","9.000000"',
]);
});
test("csv neutralises ids and aliases that a spreadsheet would run as a formula", () => {
const rows = rowsByCustomer(new Map([['=HYPERLINK("x")', usage.get("acme")!]]));
const line = toCsv(rows, new Map([['=HYPERLINK("x")', "-1+1"]]), null).split("\n")[1];
expect(line.startsWith(`"'=HYPERLINK(""x"")","'-1+1",`)).toBe(true);
expect(line.endsWith('"3","30","15","6.000000"')).toBe(true);
});
test("csv without a dimension has no dimension column", () => {
expect(toCsv(rowsByCustomer(usage), new Map(), null).split("\n")[0]).toBe(
'"customer_id","customer","requests","input_tokens","output_tokens","spend_usd"',
);
});
});

View file

@ -1,87 +0,0 @@
import type { DailyData } from "@/components/UsagePage/types";
import { addMetrics, type CustomerUsage, type Totals } from "../customers/_components/customerPortalData";
export type GroupBy = "customer" | "key" | "model";
export interface ChargebackRow extends Totals {
customer: string;
dimension: string | null;
}
const ZERO: Totals = { spend: 0, requests: 0, inputTokens: 0, outputTokens: 0 };
const hasTraffic = (r: Totals) => r.requests > 0 || r.spend > 0;
const bySpend = (a: Totals, b: Totals) => b.spend - a.spend;
export const rowsByCustomer = (usage: ReadonlyMap<string, CustomerUsage>): ChargebackRow[] =>
[...usage.entries()]
.map(([customer, u]) => ({
customer,
dimension: null,
spend: u.spend,
requests: u.requests,
inputTokens: u.inputTokens,
outputTokens: u.outputTokens,
}))
.filter(hasTraffic)
.sort(bySpend);
export const rowsByCustomerAndKey = (usage: ReadonlyMap<string, CustomerUsage>): ChargebackRow[] =>
[...usage.entries()]
.flatMap(([customer, u]) => u.keys.map((k) => ({ ...k, customer, dimension: k.alias ?? k.hash.slice(0, 12) })))
.filter(hasTraffic)
.sort(bySpend);
export const rowsByCustomerAndModel = (perCustomer: ReadonlyMap<string, readonly DailyData[]>): ChargebackRow[] =>
[...perCustomer.entries()]
.flatMap(([customer, days]) => {
const byModel = new Map<string, Totals>();
for (const day of days) {
for (const [model, m] of Object.entries(day.breakdown.models))
byModel.set(model, addMetrics(byModel.get(model) ?? ZERO, m.metrics));
}
return [...byModel.entries()].map(([model, t]) => ({ ...t, customer, dimension: model }));
})
.filter(hasTraffic)
.sort(bySpend);
export const sumRows = (rows: readonly Totals[]): Totals =>
rows.reduce<Totals>(
(acc, r) => ({
spend: acc.spend + r.spend,
requests: acc.requests + r.requests,
inputTokens: acc.inputTokens + r.inputTokens,
outputTokens: acc.outputTokens + r.outputTokens,
}),
ZERO,
);
const FORMULA_PREFIX = /^[=+\-@\t\r]/;
const neutralise = (v: string) => (FORMULA_PREFIX.test(v) ? `'${v}` : v);
const csvCell = (v: string | number) => `"${(typeof v === "string" ? neutralise(v) : String(v)).replace(/"/g, '""')}"`;
export const toCsv = (
rows: readonly ChargebackRow[],
aliasOf: ReadonlyMap<string, string>,
dimension: string | null,
): string => {
const header = [
"customer_id",
"customer",
...(dimension ? [dimension] : []),
"requests",
"input_tokens",
"output_tokens",
"spend_usd",
];
const lines = rows.map((r) => [
r.customer,
aliasOf.get(r.customer) ?? r.customer,
...(dimension ? [r.dimension ?? ""] : []),
r.requests,
r.inputTokens,
r.outputTokens,
r.spend.toFixed(6),
]);
return [header, ...lines].map((l) => l.map(csvCell).join(",")).join("\n");
};

View file

@ -1,175 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { Download } from "lucide-react";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
import { dailyActivityAggregatedCall } from "@/components/networking";
import { toDailyData } from "@/components/UsagePage/dailyActivityApi";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { monthRange, useCustomerUsage } from "../customers/_components/customerPortalData";
import {
rowsByCustomer,
rowsByCustomerAndKey,
rowsByCustomerAndModel,
sumRows,
toCsv,
type GroupBy,
} from "./chargebackRows";
const GROUPS: readonly GroupBy[] = ["customer", "key", "model"];
const MONTHS = [0, 1, 2] as const;
const downloadCsv = (csv: string, filename: string) => {
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv" }));
Object.assign(document.createElement("a"), { href: url, download: filename }).click();
URL.revokeObjectURL(url);
};
export default function ChargebacksPage() {
const { t, i18n } = useTranslation();
const { accessToken } = useAuthorized();
const [monthsBack, setMonthsBack] = useState(0);
const [groupBy, setGroupBy] = useState<GroupBy>("customer");
const customersQuery = useCustomers(100);
const customers = useMemo(() => customersQuery.data?.pages.flat() ?? [], [customersQuery.data]);
const aliasOf = useMemo(() => new Map(customers.map((c) => [c.user_id, c.alias || c.user_id])), [customers]);
const usageQuery = useCustomerUsage(accessToken, monthsBack);
const customerIds = useMemo(() => [...(usageQuery.data?.keys() ?? [])].sort(), [usageQuery.data]);
const perModel = useQuery({
queryKey: ["customer-portal", "usage-by-model", monthsBack, customerIds],
enabled: Boolean(accessToken) && groupBy === "model" && usageQuery.isSuccess,
queryFn: async () => {
const { start, end } = monthRange(monthsBack);
const entries = await Promise.all(
customerIds.map(async (id) => {
const request = { accessToken: accessToken!, startTime: start, endTime: end, entityIds: [id] };
const res = await dailyActivityAggregatedCall("customer", request);
return [id, toDailyData(res)] as const;
}),
);
return new Map(entries);
},
});
const rows = useMemo(() => {
if (groupBy === "model") return perModel.data ? rowsByCustomerAndModel(perModel.data) : [];
if (!usageQuery.data) return [];
return groupBy === "key" ? rowsByCustomerAndKey(usageQuery.data) : rowsByCustomer(usageQuery.data);
}, [groupBy, usageQuery.data, perModel.data]);
const total = sumRows(rows);
const isLoading = usageQuery.isLoading || (groupBy === "model" && perModel.isLoading);
const dimensionLabel = groupBy === "customer" ? null : t(`customerPortal.chargebacks.dimension.${groupBy}`);
const monthLabel = (back: number) =>
new Intl.DateTimeFormat(i18n.language, { month: "long", year: "numeric", timeZone: "UTC" }).format(
monthRange(back).start,
);
const exportCsv = () =>
downloadCsv(
toCsv(rows, aliasOf, dimensionLabel?.toLowerCase() ?? null),
`chargebacks-${monthRange(monthsBack).start.toISOString().slice(0, 7)}-${groupBy}.csv`,
);
return (
<div className="w-full space-y-4 px-6 py-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-baseline gap-3">
<h1 className="text-lg font-semibold tracking-tight">{t("customerPortal.chargebacks.title")}</h1>
<p className="truncate text-sm text-muted-foreground">{t("customerPortal.chargebacks.subtitle")}</p>
</div>
<div className="flex items-center gap-2">
<Select value={String(monthsBack)} onValueChange={(v) => setMonthsBack(Number(v))}>
<SelectTrigger className="w-44">
<SelectValue>{monthLabel(monthsBack)}</SelectValue>
</SelectTrigger>
<SelectContent>
{MONTHS.map((b) => (
<SelectItem key={b} value={String(b)}>
{monthLabel(b)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={groupBy} onValueChange={(v) => setGroupBy(v as GroupBy)}>
<SelectTrigger className="w-48">
<SelectValue>{t(`customerPortal.chargebacks.groupBy.${groupBy}`)}</SelectValue>
</SelectTrigger>
<SelectContent>
{GROUPS.map((g) => (
<SelectItem key={g} value={g}>
{t(`customerPortal.chargebacks.groupBy.${g}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" onClick={exportCsv} disabled={rows.length === 0}>
<Download className="size-4" /> {t("customerPortal.chargebacks.exportCsv")}
</Button>
</div>
</div>
<div className="rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead className="pl-4">{t("customerPortal.chargebacks.columns.customer")}</TableHead>
{dimensionLabel && <TableHead>{dimensionLabel}</TableHead>}
<TableHead className="text-right">{t("customerPortal.chargebacks.columns.requests")}</TableHead>
<TableHead className="text-right">{t("customerPortal.chargebacks.columns.inputTokens")}</TableHead>
<TableHead className="text-right">{t("customerPortal.chargebacks.columns.outputTokens")}</TableHead>
<TableHead className="pr-4 text-right">{t("customerPortal.chargebacks.columns.spend")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading && (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
{t("customerPortal.common.loading")}
</TableCell>
</TableRow>
)}
{!isLoading && rows.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
{t("customerPortal.chargebacks.empty", { month: monthLabel(monthsBack) })}
</TableCell>
</TableRow>
)}
{rows.map((r) => (
<TableRow key={`${r.customer}-${r.dimension ?? ""}`}>
<TableCell className="pl-4">
<p className="font-medium">{aliasOf.get(r.customer) ?? r.customer}</p>
<p className="font-mono text-[11px] text-muted-foreground">{r.customer}</p>
</TableCell>
{dimensionLabel && <TableCell>{r.dimension}</TableCell>}
<TableCell className="text-right tabular-nums">{t("format.number", { val: r.requests })}</TableCell>
<TableCell className="text-right tabular-nums">{t("format.number", { val: r.inputTokens })}</TableCell>
<TableCell className="text-right tabular-nums">{t("format.number", { val: r.outputTokens })}</TableCell>
<TableCell className="pr-4 text-right tabular-nums">{t("format.usd", { val: r.spend })}</TableCell>
</TableRow>
))}
{rows.length > 0 && (
<TableRow className="font-medium">
<TableCell className="pl-4" colSpan={dimensionLabel ? 2 : 1}>
{t("customerPortal.common.total")}
</TableCell>
<TableCell className="text-right tabular-nums">{t("format.number", { val: total.requests })}</TableCell>
<TableCell className="text-right tabular-nums">
{t("format.number", { val: total.inputTokens })}
</TableCell>
<TableCell className="text-right tabular-nums">
{t("format.number", { val: total.outputTokens })}
</TableCell>
<TableCell className="pr-4 text-right tabular-nums">{t("format.usd", { val: total.spend })}</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}

View file

@ -116,7 +116,7 @@ export function AgentControlPlaneView() {
}
const FULL_BLEED_SEGMENTS = new Set(["logs"]);
const CUSTOMER_PORTAL_SEGMENTS = new Set(["customers", "chargebacks"]);
const CUSTOMER_PORTAL_SEGMENTS = new Set(["customers"]);
function DashboardShell({ children }: { children: React.ReactNode }) {
const { t } = useTranslation();

View file

@ -11,7 +11,6 @@ const LEGACY_PAGE_ROUTES: ReadonlyMap<string, string> = new Map(
"access-groups": "access-groups",
budgets: "budgets",
customers: "customers",
chargebacks: "chargebacks",
workflows: "workflows",
"guardrails-monitor": "guardrails-monitor",
"mcp-servers": "mcp-servers",

View file

@ -52,7 +52,6 @@ import {
Terminal,
User,
Users,
Receipt,
Wallet,
Wrench,
Workflow,
@ -207,13 +206,6 @@ const customerPortalGroups: MenuGroup[] = [
icon: <Users {...ICON} />,
roles: all_admin_roles,
},
{
key: "chargebacks",
page: "chargebacks",
label: "shell.nav.items.chargebacks",
icon: <Receipt {...ICON} />,
roles: all_admin_roles,
},
],
},
];

View file

@ -8165,8 +8165,7 @@
"costTracking": "Cost Tracking",
"uiTheme": "UI Theme",
"telemetry": "Telemetry",
"customers": "Customers",
"chargebacks": "Chargebacks"
"customers": "Customers"
},
"homeLink": "LiteLLM home"
},
@ -20585,7 +20584,7 @@
"customerPortal": {
"switcher": {
"label": "Customer Portal",
"description": "End customers, budgets and chargebacks"
"description": "End customers and budgets"
},
"status": {
"active": "Active",
@ -20671,28 +20670,6 @@
"maxBudget": "Max budget (USD)",
"maxBudgetPlaceholder": "Leave empty for no budget",
"sharedBudgetWarning": "This customer is on budget {{budgetId}}. Changing the budget here changes it for every customer on that budget."
},
"chargebacks": {
"title": "Chargebacks",
"subtitle": "Spend per customer for a month, from the daily spend rollup.",
"groupBy": {
"customer": "Group by customer",
"key": "Customer and key",
"model": "Customer and model"
},
"dimension": {
"key": "Key",
"model": "Model"
},
"exportCsv": "Export CSV",
"columns": {
"customer": "Customer",
"requests": "Requests",
"inputTokens": "Input tokens",
"outputTokens": "Output tokens",
"spend": "Spend"
},
"empty": "No customer traffic in {{month}}."
}
}
}