mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
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:
parent
8de4decd81
commit
0fe6927426
7 changed files with 3 additions and 403 deletions
|
|
@ -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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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}}."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue