diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/chargebacks/chargebackRows.test.ts b/litellm/ui/litellm-dashboard/src/app/(dashboard)/chargebacks/chargebackRows.test.ts deleted file mode 100644 index 041d683803c..00000000000 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/chargebacks/chargebackRows.test.ts +++ /dev/null @@ -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 = 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): 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"', - ); - }); -}); diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/chargebacks/chargebackRows.ts b/litellm/ui/litellm-dashboard/src/app/(dashboard)/chargebacks/chargebackRows.ts deleted file mode 100644 index 7f86b7cf6d6..00000000000 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/chargebacks/chargebackRows.ts +++ /dev/null @@ -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): 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): 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): ChargebackRow[] => - [...perCustomer.entries()] - .flatMap(([customer, days]) => { - const byModel = new Map(); - 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( - (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, - 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"); -}; diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/chargebacks/page.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/chargebacks/page.tsx deleted file mode 100644 index 262b918bde5..00000000000 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/chargebacks/page.tsx +++ /dev/null @@ -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("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 ( -
-
-
-

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

-

{t("customerPortal.chargebacks.subtitle")}

-
-
- - - -
-
-
- - - - {t("customerPortal.chargebacks.columns.customer")} - {dimensionLabel && {dimensionLabel}} - {t("customerPortal.chargebacks.columns.requests")} - {t("customerPortal.chargebacks.columns.inputTokens")} - {t("customerPortal.chargebacks.columns.outputTokens")} - {t("customerPortal.chargebacks.columns.spend")} - - - - {isLoading && ( - - - {t("customerPortal.common.loading")} - - - )} - {!isLoading && rows.length === 0 && ( - - - {t("customerPortal.chargebacks.empty", { month: monthLabel(monthsBack) })} - - - )} - {rows.map((r) => ( - - -

{aliasOf.get(r.customer) ?? r.customer}

-

{r.customer}

-
- {dimensionLabel && {r.dimension}} - {t("format.number", { val: r.requests })} - {t("format.number", { val: r.inputTokens })} - {t("format.number", { val: r.outputTokens })} - {t("format.usd", { val: r.spend })} -
- ))} - {rows.length > 0 && ( - - - {t("customerPortal.common.total")} - - {t("format.number", { val: total.requests })} - - {t("format.number", { val: total.inputTokens })} - - - {t("format.number", { val: total.outputTokens })} - - {t("format.usd", { val: total.spend })} - - )} -
-
-
-
- ); -} diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index d898f1b0373..e03073b64df 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -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(); diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts b/litellm/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts index 74c29fc31b9..c5fde4cb132 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts @@ -11,7 +11,6 @@ const LEGACY_PAGE_ROUTES: ReadonlyMap = new Map( "access-groups": "access-groups", budgets: "budgets", customers: "customers", - chargebacks: "chargebacks", workflows: "workflows", "guardrails-monitor": "guardrails-monitor", "mcp-servers": "mcp-servers", diff --git a/litellm/ui/litellm-dashboard/src/components/leftnav.tsx b/litellm/ui/litellm-dashboard/src/components/leftnav.tsx index 6c23e5b9a70..d3277db3297 100644 --- a/litellm/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/litellm/ui/litellm-dashboard/src/components/leftnav.tsx @@ -52,7 +52,6 @@ import { Terminal, User, Users, - Receipt, Wallet, Wrench, Workflow, @@ -207,13 +206,6 @@ const customerPortalGroups: MenuGroup[] = [ icon: , roles: all_admin_roles, }, - { - key: "chargebacks", - page: "chargebacks", - label: "shell.nav.items.chargebacks", - icon: , - roles: all_admin_roles, - }, ], }, ]; diff --git a/litellm/ui/litellm-dashboard/src/i18n/locales/en.json b/litellm/ui/litellm-dashboard/src/i18n/locales/en.json index de72ec1410e..6e09e3eb785 100644 --- a/litellm/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/litellm/ui/litellm-dashboard/src/i18n/locales/en.json @@ -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}}." } } }