From c229851924c4187718d9b102ca32e4a804319a94 Mon Sep 17 00:00:00 2001 From: Kerry Lu Date: Tue, 10 Nov 2026 10:00:20 -0800 Subject: [PATCH] feat(ui): customer portal (beta) surfacing the existing customer endpoints and customer usage (#50807) * feat(ui): Customer Portal (Beta) surfacing the existing customer endpoints and customer usage Adds a Customer Portal mode to the dashboard (product switcher entry, Customers and Chargebacks nav) built only on endpoints and data the proxy already serves. Customers: list ranked by this month's spend from the customer daily-activity aggregate, status (active, over budget, blocked), search, Create Customer through /customer/new (id, alias, monthly max_budget, tpm_limit, rpm_limit), Edit alias and budget through /customer/update, Block and Unblock. Customer detail shows the keys that sent traffic as that customer this month from the api_key_breakdown of the same aggregate, read only. Chargebacks: per month, grouped by customer, customer and key, or customer and model, with CSV export. All strings go through i18n. Pure helpers (customerForms, customerPortalData, chargebackRows) carry unit tests, and the ViewSwitcher integration test covers entering and leaving the portal. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): refresh the customers list after editing or blocking from customer detail Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(ui): name the expected objects in the customer portal tests so the inline-object lint budget holds Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(ui): mock next/navigation in the navbar test now that ViewSwitcher routes with useRouter Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): format customer portal money and limits through the format keys instead of inline formatters Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): note that customer blocking needs blocked_user_check and neutralise formula prefixes in the chargeback CSV Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../chargebacks/chargebackRows.test.ts | 106 +++++++++++ .../(dashboard)/chargebacks/chargebackRows.ts | 87 +++++++++ .../src/app/(dashboard)/chargebacks/page.tsx | 175 ++++++++++++++++++ .../_components/CreateCustomerDialog.tsx | 122 ++++++++++++ .../customers/_components/CustomerDetail.tsx | 156 ++++++++++++++++ .../_components/EditCustomerDialog.tsx | 96 ++++++++++ .../customers/_components/StatusBadge.tsx | 15 ++ .../_components/customerForms.test.ts | 49 +++++ .../customers/_components/customerForms.ts | 46 +++++ .../_components/customerPortalData.test.ts | 110 +++++++++++ .../_components/customerPortalData.ts | 89 +++++++++ .../src/app/(dashboard)/customers/page.tsx | 153 +++++++++++++++ .../src/app/(dashboard)/layout.tsx | 10 +- .../src/app/(dashboard)/legacyPageRoutes.ts | 2 + .../Navbar/ViewSwitcher.integration.test.tsx | 28 ++- .../src/components/Navbar/ViewSwitcher.tsx | 40 +++- .../src/components/leftnav.tsx | 32 +++- .../src/components/navbar.test.tsx | 2 + .../src/contexts/PluginModeContext.tsx | 6 +- ui/litellm-dashboard/src/i18n/locales/en.json | 118 +++++++++++- 20 files changed, 1429 insertions(+), 13 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/chargebacks/chargebackRows.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/chargebacks/chargebackRows.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/chargebacks/page.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CreateCustomerDialog.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerDetail.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/customers/_components/EditCustomerDialog.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/customers/_components/StatusBadge.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerForms.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerForms.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerPortalData.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerPortalData.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/customers/page.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/chargebacks/chargebackRows.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/chargebacks/chargebackRows.test.ts new file mode 100644 index 00000000000..041d683803c --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/chargebacks/chargebackRows.test.ts @@ -0,0 +1,106 @@ +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/ui/litellm-dashboard/src/app/(dashboard)/chargebacks/chargebackRows.ts b/ui/litellm-dashboard/src/app/(dashboard)/chargebacks/chargebackRows.ts new file mode 100644 index 00000000000..7f86b7cf6d6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/chargebacks/chargebackRows.ts @@ -0,0 +1,87 @@ +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/ui/litellm-dashboard/src/app/(dashboard)/chargebacks/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/chargebacks/page.tsx new file mode 100644 index 00000000000..262b918bde5 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/chargebacks/page.tsx @@ -0,0 +1,175 @@ +"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/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CreateCustomerDialog.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CreateCustomerDialog.tsx new file mode 100644 index 00000000000..c645c180e53 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CreateCustomerDialog.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { $api } from "@/lib/http/api"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { buildCreateCustomerBody, digitsOnly, decimalOnly } from "./customerForms"; + +interface Props { + open: boolean; + onOpenChange: (o: boolean) => void; + onCreated: (userId: string) => void; +} + +const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +export default function CreateCustomerDialog({ open, onOpenChange, onCreated }: Props) { + const { t } = useTranslation(); + const [userId, setUserId] = useState(""); + const [alias, setAlias] = useState(""); + const [budget, setBudget] = useState(""); + const [tpm, setTpm] = useState(""); + const [rpm, setRpm] = useState(""); + const create = $api.useMutation("post", "/customer/new", { + onSuccess: () => { + toast.success(t("customerPortal.toasts.created", { name: alias.trim() || userId.trim() })); + onCreated(userId.trim()); + onOpenChange(false); + }, + onError: (e: unknown) => toast.error(t("customerPortal.toasts.createFailed", { error: errorText(e) })), + }); + const submit = (e: React.FormEvent) => { + e.preventDefault(); + const fields = { userId, alias, budget, tpm, rpm }; + const body = buildCreateCustomerBody(fields); + if (body) create.mutate({ body }); + }; + return ( + + +
+ + {t("customerPortal.createDialog.title")} + {t("customerPortal.createDialog.description")} + +
+ + setUserId(e.target.value)} + placeholder={t("customerPortal.createDialog.customerIdPlaceholder")} + autoFocus + required + /> +
+
+ + setAlias(e.target.value)} + placeholder={t("customerPortal.createDialog.aliasPlaceholder")} + /> +
+
+ + setBudget(decimalOnly(e.target.value))} + placeholder={t("customerPortal.createDialog.maxBudgetPlaceholder")} + /> +
+
+
+ + setTpm(digitsOnly(e.target.value))} + placeholder={t("customerPortal.createDialog.noLimit")} + /> +
+
+ + setRpm(digitsOnly(e.target.value))} + placeholder={t("customerPortal.createDialog.noLimit")} + /> +
+
+

{t("customerPortal.createDialog.limitsHelp")}

+ + + + +
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerDetail.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerDetail.tsx new file mode 100644 index 00000000000..6e9e94dedf2 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerDetail.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; +import { Ban, Pencil } from "lucide-react"; +import { toast } from "sonner"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { $api } from "@/lib/http/api"; +import type { EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { customerStatus, useCustomerUsage } from "./customerPortalData"; +import EditCustomerDialog from "./EditCustomerDialog"; +import StatusBadge from "./StatusBadge"; + +function UsageSummary({ spend, budget }: { spend: number; budget: EndUser["litellm_budget_table"] | undefined }) { + const { t } = useTranslation(); + const max = budget?.max_budget ?? null; + const tpmLimit = budget?.tpm_limit ?? null; + const rpmLimit = budget?.rpm_limit ?? null; + return ( +

+ + {t("customerPortal.detail.spendThisMonth")}{" "} + {t("format.usd", { val: spend })} + + + {max === null + ? t("customerPortal.detail.noBudget") + : t("customerPortal.detail.ofBudget", { max: t("format.usd", { val: max }) })} + + {tpmLimit !== null && ( + + {t("customerPortal.detail.tpm", { val: t("format.number", { val: tpmLimit }) })} + + )} + {rpmLimit !== null && ( + + {t("customerPortal.detail.rpm", { val: t("format.number", { val: rpmLimit }) })} + + )} +

+ ); +} + +export default function CustomerDetail({ customerId, onBack }: { customerId: string; onBack: () => void }) { + const { t } = useTranslation(); + const { accessToken } = useAuthorized(); + const info = $api.useQuery( + "get", + "/customer/info", + { params: { query: { end_user_id: customerId } } }, + { enabled: Boolean(accessToken) }, + ); + const usageQuery = useCustomerUsage(accessToken); + const [editOpen, setEditOpen] = useState(false); + const queryClient = useQueryClient(); + const refresh = () => { + info.refetch(); + queryClient.invalidateQueries({ queryKey: ["get", "/customer/list"] }); + }; + const block = $api.useMutation("post", "/customer/block", { + onSuccess: () => { + toast.success(t("customerPortal.toasts.blocked")); + refresh(); + }, + }); + const unblock = $api.useMutation("post", "/customer/update", { + onSuccess: () => { + toast.success(t("customerPortal.toasts.unblocked")); + refresh(); + }, + }); + + const c = info.data; + const usage = usageQuery.data?.get(customerId); + const spend = usage?.spend ?? 0; + const keys = usage?.keys ?? []; + const label = c?.alias || customerId; + + return ( +
+
+
+
+

{label}

+ {c && } + {customerId} +
+ +
+
+ + {c?.blocked ? ( + + ) : ( + + )} +
+
+

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

+ +
+
+

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

+

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

+
+ {keys.length === 0 ? ( +

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

+ ) : ( + + + + {t("customerPortal.detail.columns.key")} + {t("customerPortal.detail.columns.requests")} + {t("customerPortal.detail.columns.tokens")} + {t("customerPortal.detail.columns.spend")} + + + + {keys.map((k) => ( + + +

{k.alias ?? t("customerPortal.detail.unnamedKey")}

+

{k.hash.slice(0, 12)}

+
+ {t("format.number", { val: k.requests })} + + {t("format.number", { val: k.inputTokens + k.outputTokens })} + + {t("format.usd", { val: k.spend })} +
+ ))} +
+
+ )} +
+ + {c && } +
+ ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/EditCustomerDialog.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/EditCustomerDialog.tsx new file mode 100644 index 00000000000..aebc8d60053 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/EditCustomerDialog.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { $api } from "@/lib/http/api"; +import type { EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { buildUpdateCustomerBody, decimalOnly } from "./customerForms"; + +interface Props { + open: boolean; + onOpenChange: (o: boolean) => void; + customer: EndUser; + onSaved: () => void; +} + +const asText = (n: number | null | undefined) => (n === null || n === undefined ? "" : String(n)); + +function EditCustomerForm({ onOpenChange, customer, onSaved }: Omit) { + const { t } = useTranslation(); + const [alias, setAlias] = useState(customer.alias ?? ""); + const [budget, setBudget] = useState(asText(customer.litellm_budget_table?.max_budget)); + const budgetId = customer.litellm_budget_table?.budget_id ?? null; + const update = $api.useMutation("post", "/customer/update", { + onSuccess: () => { + toast.success(t("customerPortal.toasts.updated")); + onSaved(); + onOpenChange(false); + }, + onError: (e: unknown) => + toast.error(t("customerPortal.toasts.updateFailed", { error: e instanceof Error ? e.message : String(e) })), + }); + const submit = (e: React.FormEvent) => { + e.preventDefault(); + const fields = { userId: customer.user_id, alias, budget, blocked: Boolean(customer.blocked) }; + update.mutate({ body: buildUpdateCustomerBody(fields) }); + }; + return ( + +
+ + {t("customerPortal.editDialog.title")} + + {customer.user_id} + + +
+ + setAlias(e.target.value)} autoFocus /> +
+
+ + setBudget(decimalOnly(e.target.value))} + placeholder={t("customerPortal.editDialog.maxBudgetPlaceholder")} + /> +
+ {budgetId && ( +

+ {t("customerPortal.editDialog.sharedBudgetWarning", { budgetId })} +

+ )} + + + + +
+
+ ); +} + +export default function EditCustomerDialog({ open, ...form }: Props) { + return ( + + {open && } + + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/StatusBadge.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/StatusBadge.tsx new file mode 100644 index 00000000000..005171abc53 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/StatusBadge.tsx @@ -0,0 +1,15 @@ +import { useTranslation } from "react-i18next"; +import { Badge } from "@/components/ui/badge"; +import type { CustomerStatus } from "./customerPortalData"; + +const VARIANT = { active: "secondary", over_budget: "destructive", blocked: "outline" } as const; +const LABEL_KEY = { + active: "customerPortal.status.active", + over_budget: "customerPortal.status.overBudget", + blocked: "customerPortal.status.blocked", +} as const; + +export default function StatusBadge({ status }: { status: CustomerStatus }) { + const { t } = useTranslation(); + return {t(LABEL_KEY[status])}; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerForms.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerForms.test.ts new file mode 100644 index 00000000000..3c7ecc97484 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerForms.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "vitest"; +import { buildCreateCustomerBody, buildUpdateCustomerBody } from "./customerForms"; + +describe("buildCreateCustomerBody", () => { + const fields = { userId: " acme ", alias: " Acme Corp ", budget: "25", tpm: "1000", rpm: "60" }; + const emptyFields = { userId: "acme", alias: "", budget: "", tpm: "", rpm: "" }; + + test("trims ids and aliases and sends numeric limits with a monthly budget reset", () => { + const expected = { + user_id: "acme", + alias: "Acme Corp", + blocked: false, + max_budget: 25, + budget_duration: "1mo", + tpm_limit: 1000, + rpm_limit: 60, + }; + expect(buildCreateCustomerBody(fields)).toEqual(expected); + }); + + test("empty optional fields become null and no budget means no budget_duration", () => { + const expected = { + user_id: "acme", + alias: null, + blocked: false, + max_budget: null, + budget_duration: null, + tpm_limit: null, + rpm_limit: null, + }; + expect(buildCreateCustomerBody(emptyFields)).toEqual(expected); + }); + + test("a blank customer id produces no request", () => { + expect(buildCreateCustomerBody({ ...fields, userId: " " })).toBeNull(); + }); +}); + +describe("buildUpdateCustomerBody", () => { + test("preserves the blocked flag and clears the budget when emptied", () => { + const fields = { userId: "acme", alias: "", budget: "", blocked: true }; + const expected = { user_id: "acme", alias: null, blocked: true, max_budget: null }; + expect(buildUpdateCustomerBody(fields)).toEqual(expected); + }); + test("parses a decimal budget", () => { + const fields = { userId: "acme", alias: "Acme", budget: "12.50", blocked: false }; + expect(buildUpdateCustomerBody(fields).max_budget).toBe(12.5); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerForms.ts b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerForms.ts new file mode 100644 index 00000000000..f1fafc0c718 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerForms.ts @@ -0,0 +1,46 @@ +import type { components } from "@/lib/http/schema"; + +export type NewCustomerBody = components["schemas"]["NewCustomerRequest"]; +export type UpdateCustomerBody = components["schemas"]["UpdateCustomerRequest"]; + +export const digitsOnly = (s: string) => s.replace(/[^0-9]/g, ""); +export const decimalOnly = (s: string) => s.replace(/[^0-9.]/g, ""); + +const numberOrNull = (s: string): number | null => (s.trim() === "" ? null : Number(s)); + +export interface CreateCustomerFields { + userId: string; + alias: string; + budget: string; + tpm: string; + rpm: string; +} + +export const buildCreateCustomerBody = (f: CreateCustomerFields): NewCustomerBody | null => { + const userId = f.userId.trim(); + if (!userId) return null; + const maxBudget = numberOrNull(f.budget); + return { + user_id: userId, + alias: f.alias.trim() || null, + blocked: false, + max_budget: maxBudget, + budget_duration: maxBudget === null ? null : "1mo", + tpm_limit: numberOrNull(f.tpm), + rpm_limit: numberOrNull(f.rpm), + }; +}; + +export interface EditCustomerFields { + userId: string; + alias: string; + budget: string; + blocked: boolean; +} + +export const buildUpdateCustomerBody = (f: EditCustomerFields): UpdateCustomerBody => ({ + user_id: f.userId, + alias: f.alias.trim() || null, + blocked: f.blocked, + max_budget: numberOrNull(f.budget), +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerPortalData.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerPortalData.test.ts new file mode 100644 index 00000000000..cf48c81db5b --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerPortalData.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "vitest"; +import type { DailyData, KeyMetricWithMetadata, MetricWithMetadata, SpendMetrics } from "@/components/UsagePage/types"; +import { customerStatus, monthRange, rankBySpend, usageByCustomer } from "./customerPortalData"; + +const metrics = (spend: number, requests = 1, prompt = 10, completion = 5): SpendMetrics => ({ + spend, + prompt_tokens: prompt, + completion_tokens: completion, + total_tokens: prompt + completion, + api_requests: requests, + successful_requests: requests, + failed_requests: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, +}); + +const key = (alias: string | null, spend: number, requests = 1): KeyMetricWithMetadata => ({ + metrics: metrics(spend, requests), + metadata: { key_alias: alias, team_id: null }, +}); + +const entity = (spend: number, keys: Record, requests = 1): MetricWithMetadata => ({ + metrics: metrics(spend, requests), + metadata: {}, + api_key_breakdown: keys, +}); + +const day = (date: string, entities: Record): DailyData => ({ + date, + metrics: metrics(0, 0, 0, 0), + breakdown: { models: {}, model_groups: {}, mcp_servers: {}, providers: {}, api_keys: {}, entities }, +}); + +describe("usageByCustomer", () => { + test("sums a customer's spend, requests and tokens across days", () => { + const usage = usageByCustomer([ + day("2026-11-01", { acme: entity(1.5, {}, 2) }), + day("2026-11-02", { acme: entity(2.25, {}, 3) }), + ]); + const expected = { spend: 3.75, requests: 5, inputTokens: 20, outputTokens: 10 }; + expect(usage.get("acme")).toMatchObject(expected); + }); + + test("keeps customers separate", () => { + const usage = usageByCustomer([day("2026-11-01", { acme: entity(1, {}), globex: entity(4, {}) })]); + expect(usage.get("acme")?.spend).toBe(1); + expect(usage.get("globex")?.spend).toBe(4); + }); + + test("merges the same key across days and sorts keys by spend", () => { + const usage = usageByCustomer([ + day("2026-11-01", { acme: entity(3, { h1: key("prod", 1), h2: key("staging", 2) }) }), + day("2026-11-02", { acme: entity(5, { h1: key("prod", 5) }) }), + ]); + const prod = { hash: "h1", alias: "prod", spend: 6, requests: 2 }; + const staging = { hash: "h2", alias: "staging", spend: 2, requests: 1 }; + expect(usage.get("acme")?.keys).toEqual([expect.objectContaining(prod), expect.objectContaining(staging)]); + }); + + test("keeps the first non-null alias seen for a key", () => { + const usage = usageByCustomer([ + day("2026-11-01", { acme: entity(1, { h1: key(null, 1) }) }), + day("2026-11-02", { acme: entity(1, { h1: key("prod", 1) }) }), + ]); + expect(usage.get("acme")?.keys[0]?.alias).toBe("prod"); + }); +}); + +describe("customerStatus", () => { + test("blocked wins over budget", () => { + expect(customerStatus({ blocked: true, litellm_budget_table: { max_budget: 1 } }, 5)).toBe("blocked"); + }); + test("over budget once spend reaches max_budget", () => { + expect(customerStatus({ blocked: false, litellm_budget_table: { max_budget: 10 } }, 10)).toBe("over_budget"); + expect(customerStatus({ blocked: false, litellm_budget_table: { max_budget: 10 } }, 9.99)).toBe("active"); + }); + test("no budget is active regardless of spend", () => { + expect(customerStatus({ blocked: false, litellm_budget_table: null }, 1000)).toBe("active"); + }); +}); + +describe("rankBySpend", () => { + const customer = (user_id: string) => ({ user_id, blocked: false, spend: 0, litellm_budget_table: null }); + test("orders customers by this month's spend, highest first, zero for customers without traffic", () => { + const usage = usageByCustomer([day("2026-11-01", { b: entity(2, {}), c: entity(9, { h1: key("k", 9) }) })]); + const rows = rankBySpend([customer("a"), customer("b"), customer("c")], usage); + expect(rows.map((r) => [r.c.user_id, r.spend, r.keys])).toEqual([ + ["c", 9, 1], + ["b", 2, 0], + ["a", 0, 0], + ]); + }); +}); + +describe("monthRange", () => { + test("covers the whole UTC month, monthsBack months ago", () => { + const now = new Date(Date.UTC(2026, 10, 9, 12)); + expect(monthRange(0, now)).toEqual({ + start: new Date(Date.UTC(2026, 10, 1)), + end: new Date(Date.UTC(2026, 10, 30, 23, 59, 59)), + }); + expect(monthRange(1, now)).toEqual({ + start: new Date(Date.UTC(2026, 9, 1)), + end: new Date(Date.UTC(2026, 9, 31, 23, 59, 59)), + }); + }); + test("crosses a year boundary", () => { + expect(monthRange(2, new Date(Date.UTC(2026, 0, 15))).start).toEqual(new Date(Date.UTC(2025, 10, 1))); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerPortalData.ts b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerPortalData.ts new file mode 100644 index 00000000000..5bc1c2ec437 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerPortalData.ts @@ -0,0 +1,89 @@ +import { useQuery } from "@tanstack/react-query"; +import { dailyActivityAggregatedCall } from "@/components/networking"; +import { toDailyData } from "@/components/UsagePage/dailyActivityApi"; +import type { DailyData, SpendMetrics } from "@/components/UsagePage/types"; +import type { EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers"; + +export type CustomerStatus = "active" | "over_budget" | "blocked"; + +export const monthRange = (monthsBack = 0, now = new Date()): { start: Date; end: Date } => ({ + start: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - monthsBack, 1)), + end: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - monthsBack + 1, 0, 23, 59, 59)), +}); + +export interface Totals { + spend: number; + requests: number; + inputTokens: number; + outputTokens: number; +} + +export interface CustomerKeyUsage extends Totals { + hash: string; + alias: string | null; +} + +export interface CustomerUsage extends Totals { + keys: readonly CustomerKeyUsage[]; +} + +const ZERO: Totals = { spend: 0, requests: 0, inputTokens: 0, outputTokens: 0 }; + +export const addMetrics = (t: Totals, m: Partial | undefined): Totals => ({ + spend: t.spend + (m?.spend ?? 0), + requests: t.requests + (m?.api_requests ?? 0), + inputTokens: t.inputTokens + (m?.prompt_tokens ?? 0), + outputTokens: t.outputTokens + (m?.completion_tokens ?? 0), +}); + +export const usageByCustomer = (days: readonly DailyData[]): Map => { + const totals = new Map(); + const keys = new Map>(); + for (const day of days) { + for (const [id, entity] of Object.entries(day.breakdown.entities)) { + totals.set(id, addMetrics(totals.get(id) ?? ZERO, entity.metrics)); + const byKey = keys.get(id) ?? new Map(); + for (const [hash, key] of Object.entries(entity.api_key_breakdown)) { + const prev = byKey.get(hash) ?? { ...ZERO, hash, alias: key.metadata.key_alias }; + byKey.set(hash, { ...prev, ...addMetrics(prev, key.metrics), alias: prev.alias ?? key.metadata.key_alias }); + } + keys.set(id, byKey); + } + } + return new Map( + [...totals.entries()].map(([id, t]) => [ + id, + { ...t, keys: [...(keys.get(id)?.values() ?? [])].sort((a, b) => b.spend - a.spend) }, + ]), + ); +}; + +export const useCustomerUsage = (accessToken: string | null, monthsBack = 0) => + useQuery({ + queryKey: ["customer-portal", "usage", monthsBack], + enabled: Boolean(accessToken), + queryFn: async () => { + const { start, end } = monthRange(monthsBack); + const res = await dailyActivityAggregatedCall("customer", { + accessToken: accessToken!, + startTime: start, + endTime: end, + }); + return usageByCustomer(toDailyData(res)); + }, + }); + +export const customerStatus = (c: Pick, spend: number): CustomerStatus => { + if (c.blocked) return "blocked"; + const max = c.litellm_budget_table?.max_budget; + return max !== null && max !== undefined && spend >= max ? "over_budget" : "active"; +}; + +export const rankBySpend = (customers: readonly EndUser[], usage: ReadonlyMap | undefined) => + customers + .map((c) => { + const u = usage?.get(c.user_id); + const spend = u?.spend ?? 0; + return { c, spend, keys: u?.keys.length ?? 0, status: customerStatus(c, spend) }; + }) + .sort((a, b) => b.spend - a.spend); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/page.tsx new file mode 100644 index 00000000000..f80d15f5acf --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/page.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { parseAsString, useQueryState } from "nuqs"; +import { Plus } from "lucide-react"; +import { toast } from "sonner"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useCustomers, type EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers"; +import { $api } from "@/lib/http/api"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import CustomerDetail from "./_components/CustomerDetail"; +import CreateCustomerDialog from "./_components/CreateCustomerDialog"; +import StatusBadge from "./_components/StatusBadge"; +import { rankBySpend, useCustomerUsage } from "./_components/customerPortalData"; + +export default function CustomersPage() { + const { t } = useTranslation(); + const { accessToken } = useAuthorized(); + const [selectedId, setSelectedId] = useQueryState("id", parseAsString); + const [search, setSearch] = useState(""); + const [createOpen, setCreateOpen] = useState(false); + const customersQuery = useCustomers(100, search || undefined); + const usageQuery = useCustomerUsage(accessToken); + const customers: EndUser[] = useMemo(() => customersQuery.data?.pages.flat() ?? [], [customersQuery.data]); + const rows = useMemo(() => rankBySpend(customers, usageQuery.data), [customers, usageQuery.data]); + const block = $api.useMutation("post", "/customer/block", { + onSuccess: () => { + toast.success(t("customerPortal.toasts.blocked")); + customersQuery.refetch(); + }, + }); + const unblock = $api.useMutation("post", "/customer/update", { + onSuccess: () => { + toast.success(t("customerPortal.toasts.unblocked")); + customersQuery.refetch(); + }, + }); + + if (selectedId) return setSelectedId(null)} />; + + return ( +
+
+
+

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

+

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

+
+ +
+

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

+
+
+ setSearch(e.target.value)} + /> +
+ + + + {t("customerPortal.list.columns.customer")} + {t("customerPortal.list.columns.spend")} + {t("customerPortal.list.columns.budget")} + {t("customerPortal.list.columns.keys")} + {t("customerPortal.list.columns.status")} + + + + + {customersQuery.isLoading && ( + + + {t("customerPortal.list.loading")} + + + )} + {!customersQuery.isLoading && rows.length === 0 && ( + + + {t("customerPortal.list.empty")} + + + )} + {rows.map(({ c, spend, keys, status }) => { + const max = c.litellm_budget_table?.max_budget ?? null; + return ( + setSelectedId(c.user_id, { history: "push" })} + > + +

{c.alias || c.user_id}

+

{c.user_id}

+
+ + {t("format.usd", { val: spend })} + + + {max === null + ? t("customerPortal.common.noBudget") + : t("customerPortal.list.budgetUsage", { + spend: t("format.usd", { val: spend }), + max: t("format.usd", { val: max }), + })} + + {keys} + + + + e.stopPropagation()}> + {c.blocked ? ( + + ) : ( + + )} + +
+ ); + })} +
+
+
+ { + customersQuery.refetch(); + setSelectedId(id, { history: "push" }); + }} + /> +
+ ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index 5c93d214812..d898f1b0373 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -116,13 +116,19 @@ export function AgentControlPlaneView() { } const FULL_BLEED_SEGMENTS = new Set(["logs"]); +const CUSTOMER_PORTAL_SEGMENTS = new Set(["customers", "chargebacks"]); function DashboardShell({ children }: { children: React.ReactNode }) { const { t } = useTranslation(); const { accessToken } = useAuth(); - const { mode } = usePluginMode(); + const { mode, setMode } = usePluginMode(); const pathname = usePathname(); const routeSegment = routeSegmentForPathname(pathname); + const isPortalRoute = CUSTOMER_PORTAL_SEGMENTS.has(routeSegment); + useEffect(() => { + if (isPortalRoute && mode !== "customer-portal") setMode("customer-portal"); + if (!isPortalRoute && mode === "customer-portal") setMode("ai-gateway"); + }, [isPortalRoute, mode, setMode]); useEffect(() => { void recordUiEvent({ page: routeSegment || "home", action: "view" }); }, [routeSegment]); @@ -142,7 +148,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) { const sidebarCollapsed = sidebarOverride?.segment === routeSegment ? sidebarOverride.collapsed : isFullBleed; const toggleSidebar = () => setSidebarOverride({ segment: routeSegment, collapsed: !sidebarCollapsed }); - const isGateway = mode === "ai-gateway"; + const isGateway = mode === "ai-gateway" || mode === "customer-portal"; // Non-gateway (agent control plane) mode keeps the original full-width Navbar, // which carries the account menu; the redesigned sidebar + header shell is diff --git a/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts b/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts index 19dac805948..90b49e56c5a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts @@ -12,6 +12,8 @@ const LEGACY_PAGE_ROUTES: ReadonlyMap = new Map( chat: "chat", "access-groups": "access-groups", budgets: "budgets", + customers: "customers", + chargebacks: "chargebacks", workflows: "workflows", "guardrails-monitor": "guardrails-monitor", "mcp-servers": "mcp-servers", diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx index 64bb8f15585..a0f6e59d029 100644 --- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx @@ -8,13 +8,14 @@ import { setServerRootPath } from "@/lib/serverRootPath"; import ViewSwitcher from "./ViewSwitcher"; -const { state, getUiSettingsMock, getUiConfigMock } = vi.hoisted(() => ({ +const { state, getUiSettingsMock, getUiConfigMock, pushMock } = vi.hoisted(() => ({ state: { pathname: "/ui/", plugins: [] as Plugin[] }, getUiSettingsMock: vi.fn(), getUiConfigMock: vi.fn(), + pushMock: vi.fn(), })); -vi.mock("next/navigation", () => ({ usePathname: () => state.pathname })); +vi.mock("next/navigation", () => ({ usePathname: () => state.pathname, useRouter: () => ({ push: pushMock }) })); vi.mock("@/components/networking", () => ({ getProxyBaseUrl: () => "", getUiSettings: getUiSettingsMock, @@ -56,6 +57,8 @@ describe("ViewSwitcher", () => { }); afterEach(() => { + pushMock.mockClear(); + assignSpy.mockClear(); Object.defineProperty(window, "location", { configurable: true, value: originalLocation }); testQueryClient.clear(); localStorage.clear(); @@ -103,6 +106,27 @@ describe("ViewSwitcher", () => { expect(assignSpy).not.toHaveBeenCalled(); }); + it("should open the Customer Portal at the customers page and come back to the gateway", async () => { + const user = userEvent.setup(); + renderSwitcher(); + + await user.click(screen.getByRole("button", { name: "Switch workspace, current: AI Gateway" })); + await user.click(await screen.findByRole("menuitem", { name: /Customer Portal/ })); + expect(pushMock).toHaveBeenCalledWith("/ui/customers"); + expect(assignSpy).not.toHaveBeenCalled(); + expect(localStorage.getItem("litellm_plugin_mode")).toBeNull(); + }); + + it("should route back to the gateway from Customer Portal mode instead of only flipping the mode", async () => { + localStorage.setItem("litellm_plugin_mode", "customer-portal"); + const user = userEvent.setup(); + renderSwitcher(); + + await user.click(await screen.findByRole("button", { name: "Switch workspace, current: Customer Portal" })); + await user.click(await screen.findByRole("menuitem", { name: /^AI Gateway/ })); + expect(pushMock).toHaveBeenCalledWith("/ui/api-keys"); + }); + it("should navigate from the gateway to enabled Chat without changing plugin mode", async () => { getUiSettingsMock.mockResolvedValue({ values: { enable_chat_ui: true } }); const user = userEvent.setup(); diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx index 5c8d4a7a850..38b051515fb 100644 --- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx @@ -1,7 +1,7 @@ import { useTerminology } from "@/contexts/TerminologyContext"; import React from "react"; import { useTranslation } from "react-i18next"; -import { usePathname } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -11,6 +11,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Check, ChevronDown, ChevronsUpDown } from "lucide-react"; import { useSidebar } from "@/components/shared/Sidebar"; +import BetaBadge from "@/components/BetaBadge"; import { usePluginMode } from "@/contexts/PluginModeContext"; import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import { uiHref } from "@/utils/uiHref"; @@ -19,6 +20,7 @@ import i18n from "@/i18n/i18n"; import { productNames } from "@/i18n/untranslated/productNames"; const GATEWAY = "ai-gateway"; +const CUSTOMER_PORTAL = "customer-portal"; const CHAT = "chat"; const MOYAI = "moyai"; @@ -51,6 +53,9 @@ interface ViewSwitcherProps { contentClassName?: string; } +const pluginLabel = (mode: string, plugins: readonly { name: string; display_name: string }[], portalLabel: string) => + mode === CUSTOMER_PORTAL ? portalLabel : plugins.find((p) => p.name === mode)?.display_name; + export default function ViewSwitcher({ className, contentClassName }: ViewSwitcherProps) { const { t } = useTranslation(); const term = useTerminology(); @@ -58,6 +63,7 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch const { mode, setMode, plugins } = usePluginMode(); const { data: uiSettings } = useUISettings(); const pathname = usePathname(); + const router = useRouter(); const chatEnabled = Boolean(uiSettings?.values?.enable_chat_ui); const moyaiUrl = (uiSettings?.values?.moyai_url as string | undefined) ?? null; @@ -71,7 +77,7 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch const activeLabel = activeWorkspaceLabel( isChatRoute, isMoyaiRoute, - plugins.find((p) => p.name === mode)?.display_name, + pluginLabel(mode, plugins, t("customerPortal.switcher.label")), gatewayLabel, ); @@ -81,6 +87,14 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch ]; const selectMode = (key: string) => { + if (key === CUSTOMER_PORTAL) { + router.push(uiHref("customers")); + return; + } + if (mode === CUSTOMER_PORTAL && key === GATEWAY) { + router.push(uiHref("api-keys")); + return; + } setMode(key); if (isStandaloneRoute) { window.location.assign(uiHref("")); @@ -107,6 +121,21 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch onClick: chatEnabled ? () => window.location.assign(uiHref(CHAT)) : undefined, }; + const customerPortalItem: ViewSwitcherItem = { + key: CUSTOMER_PORTAL, + label: ( +
+ + + {t("customerPortal.switcher.label")} + {" "} + {t("customerPortal.switcher.description")} + + {!isStandaloneRoute && mode === CUSTOMER_PORTAL && } +
+ ), + onClick: () => selectMode(CUSTOMER_PORTAL), + }; const items: ViewSwitcherItem[] = [ ...modeEntries.map((e) => ({ key: e.key, @@ -118,6 +147,7 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch ), onClick: () => selectMode(e.key), })), + customerPortalItem, chatItem, { key: MOYAI, @@ -161,6 +191,12 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch <> {activeLabel} + {!isStandaloneRoute && mode === CUSTOMER_PORTAL && ( + <> + {" "} + + + )} diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index f48271a872c..6040da32f6c 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -55,12 +55,14 @@ import { Trophy, User, Users, + Receipt, Wallet, Wrench, Workflow, } from "lucide-react"; import Link from "next/link"; import ViewSwitcher from "@/components/Navbar/ViewSwitcher"; +import { usePluginMode } from "@/contexts/PluginModeContext"; import { AriaSidebarLink } from "@/components/liteadmin/LiteAdmin"; import { usePathname } from "next/navigation"; import { useMemo, useState } from "react"; @@ -197,6 +199,28 @@ export const visibleMenuGroups = (groups: readonly MenuGroup[], context: MenuVis // Menu groups organized by category - defined outside component for export. // Shape (key/page/label/roles/children) is consumed by page_utils.ts; only the // icons changed to lucide as part of the sidebar redesign. +const customerPortalGroups: MenuGroup[] = [ + { + groupLabel: "CUSTOMER PORTAL", + items: [ + { + key: "customers", + page: "customers", + label: "shell.nav.items.customers", + icon: , + roles: all_admin_roles, + }, + { + key: "chargebacks", + page: "chargebacks", + label: "shell.nav.items.chargebacks", + icon: , + roles: all_admin_roles, + }, + ], + }, +]; + const menuGroups: MenuGroup[] = [ { groupLabel: "AI GATEWAY", @@ -542,6 +566,7 @@ const findMenuItemKey = (route: string): string => { const GROUP_LABELS: Record = { "AI GATEWAY": "shell.nav.groups.aiGateway", + "CUSTOMER PORTAL": "shell.nav.groups.customerPortal", OBSERVABILITY: "shell.nav.groups.observability", "ACCESS CONTROL": "shell.nav.groups.accessControl", "DEVELOPER TOOLS": "shell.nav.groups.developerTools", @@ -553,6 +578,7 @@ export const groupText = (groupLabel: string): string => const SECTION_DISPLAY: Record = { "AI GATEWAY": "shell.nav.sections.aiGateway", + "CUSTOMER PORTAL": "shell.nav.sections.customerPortal", OBSERVABILITY: "shell.nav.sections.observability", "ACCESS CONTROL": "shell.nav.sections.accessControl", "DEVELOPER TOOLS": "shell.nav.sections.developerTools", @@ -575,7 +601,7 @@ export const labelText = (item: MenuItem): string => export const getBreadcrumb = (pathname: string): { section: string | null; title: string } => { const route = routeForPathname(pathname); if (route === "aria") return { section: null, title: "Aria Agent" }; - for (const group of menuGroups) { + for (const group of [...menuGroups, ...customerPortalGroups]) { for (const item of group.items) { const section = sectionText(group.groupLabel); if (routeOf(item) === route) return { section, title: labelText(item) }; @@ -597,6 +623,7 @@ const Sidebar_: React.FC = ({ allowVectorStoresForTeamAdmins, }) => { const { userId, accessToken, userRole, isViewOnly } = useAuthorized(); + const { mode: pluginMode } = usePluginMode(); const { t } = useTranslation(); const term = useTerminology(); const labelOf = (item: MenuItem) => (typeof item.label === "string" ? term(labelText(item)) : item.label); @@ -639,8 +666,9 @@ const Sidebar_: React.FC = ({ disableVectorStoresForInternalUsers, allowVectorStoresForTeamAdmins, }; - return visibleMenuGroups(menuGroups, context); + return visibleMenuGroups(pluginMode === "customer-portal" ? customerPortalGroups : menuGroups, context); }, [ + pluginMode, allowAgentsForTeamAdmins, allowVectorStoresForTeamAdmins, disableAgentsForInternalUsers, diff --git a/ui/litellm-dashboard/src/components/navbar.test.tsx b/ui/litellm-dashboard/src/components/navbar.test.tsx index cc90e0b8e99..38b539c51b9 100644 --- a/ui/litellm-dashboard/src/components/navbar.test.tsx +++ b/ui/litellm-dashboard/src/components/navbar.test.tsx @@ -4,6 +4,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders, screen, waitFor, within } from "../../tests/test-utils"; import Navbar from "./navbar"; +vi.mock("next/navigation", () => ({ usePathname: () => "/api-keys", useRouter: () => ({ push: vi.fn() }) })); + // Mock the hooks and utilities vi.mock("@/components/networking", () => ({ getProxyBaseUrl: vi.fn(() => "http://localhost:4000"), diff --git a/ui/litellm-dashboard/src/contexts/PluginModeContext.tsx b/ui/litellm-dashboard/src/contexts/PluginModeContext.tsx index 380a9479829..247cae44235 100644 --- a/ui/litellm-dashboard/src/contexts/PluginModeContext.tsx +++ b/ui/litellm-dashboard/src/contexts/PluginModeContext.tsx @@ -4,7 +4,8 @@ import React, { createContext, useContext, useState, useEffect } from "react"; import { createApiClient } from "@/lib/http/client"; import { getProxyBaseUrl } from "@/components/networking"; -export type PluginMode = "ai-gateway" | string; // "ai-gateway" or a registered plugin name +export type PluginMode = "ai-gateway" | "customer-portal" | string; // built-in modes or a registered plugin name +export const BUILT_IN_MODES: ReadonlySet = new Set(["ai-gateway", "customer-portal"]); export interface PluginNavItem { key: string; @@ -84,7 +85,8 @@ export function PluginModeProvider({ children, accessToken }: PluginModeProvider // Once plugins have loaded, fall back to ai-gateway if the persisted mode is // no longer registered — including when the list came back empty (all plugins // removed). Derived rather than setState-in-effect to avoid cascading renders. - const effectiveMode = mode !== "ai-gateway" && loaded && !plugins.some((p) => p.name === mode) ? "ai-gateway" : mode; + const effectiveMode = + !BUILT_IN_MODES.has(mode) && loaded && !plugins.some((p) => p.name === mode) ? "ai-gateway" : mode; const setMode = (m: PluginMode) => { setModeState(m); diff --git a/ui/litellm-dashboard/src/i18n/locales/en.json b/ui/litellm-dashboard/src/i18n/locales/en.json index d4ba9c14998..113c77e1916 100644 --- a/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/ui/litellm-dashboard/src/i18n/locales/en.json @@ -7994,14 +7994,16 @@ "observability": "OBSERVABILITY", "accessControl": "ACCESS CONTROL", "developerTools": "DEVELOPER TOOLS", - "settings": "SETTINGS" + "settings": "SETTINGS", + "customerPortal": "CUSTOMER PORTAL" }, "sections": { "aiGateway": "AI Gateway", "observability": "Observability", "accessControl": "Access Control", "developerTools": "Developer Tools", - "settings": "Settings" + "settings": "Settings", + "customerPortal": "Customer Portal" }, "items": { "home": "Home", @@ -8047,7 +8049,9 @@ "adminSettings": "Admin Settings", "costTracking": "Cost Tracking", "uiTheme": "UI Theme", - "telemetry": "Telemetry" + "telemetry": "Telemetry", + "customers": "Customers", + "chargebacks": "Chargebacks" }, "homeLink": "LiteLLM home" }, @@ -20493,5 +20497,113 @@ "removeGroup": "Remove weights for {{group}}", "summary": "Deployment Weights:", "loadFailed": "Could not load this model group's deployments. Saved weights are kept." + }, + "customerPortal": { + "switcher": { + "label": "Customer Portal", + "description": "End customers, budgets and chargebacks" + }, + "status": { + "active": "Active", + "overBudget": "Over budget", + "blocked": "Blocked" + }, + "common": { + "noBudget": "No budget", + "cancel": "Cancel", + "save": "Save", + "block": "Block", + "blockHint": "Block and Unblock change the customer record. The proxy only rejects a blocked customer's requests when the blocked_user_check callback is enabled in litellm_settings.callbacks.", + "unblock": "Unblock", + "edit": "Edit", + "total": "Total", + "loading": "Loading..." + }, + "toasts": { + "blocked": "Customer blocked", + "unblocked": "Customer unblocked", + "created": "Created {{name}}", + "updated": "Customer updated", + "createFailed": "Failed to create customer: {{error}}", + "updateFailed": "Failed to update customer: {{error}}" + }, + "list": { + "title": "Customers", + "subtitle": "End users of your product, identified by customer id on every request.", + "create": "Create Customer", + "searchPlaceholder": "Search by customer id or alias", + "columns": { + "customer": "Customer", + "spend": "Spend this month", + "budget": "Budget", + "keys": "Keys", + "status": "Status" + }, + "loading": "Loading customers...", + "empty": "No customers yet. Create one to start tracking spend.", + "budgetUsage": "{{spend}} / {{max}}" + }, + "detail": { + "spendThisMonth": "Spend this month", + "ofBudget": "of {{max}} budget", + "noBudget": "no budget", + "tpm": "{{val}} TPM", + "rpm": "{{val}} RPM", + "keysTitle": "Keys", + "keysSubtitle": "Keys that sent requests as this customer this month.", + "keysEmpty": "No traffic from this customer this month.", + "columns": { + "key": "Key", + "requests": "Requests", + "tokens": "Tokens", + "spend": "Spend" + }, + "unnamedKey": "Unnamed key", + "back": "Back to customers" + }, + "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.", + "customerId": "Customer id", + "customerIdPlaceholder": "e.g. acme-corp", + "alias": "Alias", + "aliasPlaceholder": "Optional display name", + "maxBudget": "Monthly budget (USD)", + "maxBudgetPlaceholder": "Leave empty for no budget", + "tpm": "TPM limit", + "rpm": "RPM limit", + "noLimit": "No limit", + "limitsHelp": "Budget and limits apply to this customer across every key it uses.", + "submit": "Create" + }, + "editDialog": { + "title": "Edit customer", + "alias": "Alias", + "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}}." + } } }