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}}." + } } }