mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
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>
This commit is contained in:
parent
3e3842046d
commit
c229851924
20 changed files with 1429 additions and 13 deletions
|
|
@ -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<string, CustomerUsage> = new Map([
|
||||
[
|
||||
"acme",
|
||||
{
|
||||
...totals(6, 3),
|
||||
keys: [
|
||||
{ hash: "sk-hash-aaaaaaaaaaaa", alias: "prod", ...totals(5, 2) },
|
||||
{ hash: "sk-hash-bbbbbbbbbbbb", alias: null, ...totals(1, 1) },
|
||||
],
|
||||
},
|
||||
],
|
||||
["idle", { ...totals(0, 0), keys: [] }],
|
||||
["globex", { ...totals(9, 1), keys: [{ hash: "h3", alias: "main", ...totals(9, 1) }] }],
|
||||
]);
|
||||
|
||||
describe("rowsByCustomer", () => {
|
||||
test("one row per customer with traffic, highest spend first", () => {
|
||||
expect(rowsByCustomer(usage).map((r) => [r.customer, r.dimension, r.spend])).toEqual([
|
||||
["globex", null, 9],
|
||||
["acme", null, 6],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rowsByCustomerAndKey", () => {
|
||||
test("one row per customer and key, labelled by alias or hash prefix", () => {
|
||||
expect(rowsByCustomerAndKey(usage).map((r) => [r.customer, r.dimension, r.spend])).toEqual([
|
||||
["globex", "main", 9],
|
||||
["acme", "prod", 5],
|
||||
["acme", "sk-hash-bbbb", 1],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rowsByCustomerAndModel", () => {
|
||||
const metrics = (spend: number): SpendMetrics => ({
|
||||
spend,
|
||||
prompt_tokens: 1,
|
||||
completion_tokens: 1,
|
||||
total_tokens: 2,
|
||||
api_requests: 1,
|
||||
successful_requests: 1,
|
||||
failed_requests: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
});
|
||||
const day = (models: Record<string, number>): DailyData => ({
|
||||
date: "2026-11-01",
|
||||
metrics: metrics(0),
|
||||
breakdown: {
|
||||
models: Object.fromEntries(
|
||||
Object.entries(models).map(([m, s]) => [m, { metrics: metrics(s), metadata: {}, api_key_breakdown: {} }]),
|
||||
),
|
||||
model_groups: {},
|
||||
mcp_servers: {},
|
||||
providers: {},
|
||||
api_keys: {},
|
||||
entities: {},
|
||||
},
|
||||
});
|
||||
test("sums each customer's models across days", () => {
|
||||
const rows = rowsByCustomerAndModel(new Map([["acme", [day({ "gpt-5": 1, claude: 2 }), day({ "gpt-5": 4 })]]]));
|
||||
expect(rows.map((r) => [r.customer, r.dimension, r.spend, r.requests])).toEqual([
|
||||
["acme", "gpt-5", 5, 2],
|
||||
["acme", "claude", 2, 1],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sumRows and toCsv", () => {
|
||||
test("total row matches the sum of the rows", () => {
|
||||
const expected = { spend: 15, requests: 4, inputTokens: 40, outputTokens: 20 };
|
||||
expect(sumRows(rowsByCustomer(usage))).toEqual(expected);
|
||||
});
|
||||
test("csv carries alias, optional dimension column and escaped quotes", () => {
|
||||
const rows = rowsByCustomerAndKey(usage).slice(0, 1);
|
||||
const csv = toCsv(rows, new Map([["globex", 'Globex "Inc"']]), "key");
|
||||
expect(csv.split("\n")).toEqual([
|
||||
'"customer_id","customer","key","requests","input_tokens","output_tokens","spend_usd"',
|
||||
'"globex","Globex ""Inc""","main","1","10","5","9.000000"',
|
||||
]);
|
||||
});
|
||||
test("csv neutralises ids and aliases that a spreadsheet would run as a formula", () => {
|
||||
const rows = rowsByCustomer(new Map([['=HYPERLINK("x")', usage.get("acme")!]]));
|
||||
const line = toCsv(rows, new Map([['=HYPERLINK("x")', "-1+1"]]), null).split("\n")[1];
|
||||
expect(line.startsWith(`"'=HYPERLINK(""x"")","'-1+1",`)).toBe(true);
|
||||
expect(line.endsWith('"3","30","15","6.000000"')).toBe(true);
|
||||
});
|
||||
test("csv without a dimension has no dimension column", () => {
|
||||
expect(toCsv(rowsByCustomer(usage), new Map(), null).split("\n")[0]).toBe(
|
||||
'"customer_id","customer","requests","input_tokens","output_tokens","spend_usd"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, CustomerUsage>): ChargebackRow[] =>
|
||||
[...usage.entries()]
|
||||
.map(([customer, u]) => ({
|
||||
customer,
|
||||
dimension: null,
|
||||
spend: u.spend,
|
||||
requests: u.requests,
|
||||
inputTokens: u.inputTokens,
|
||||
outputTokens: u.outputTokens,
|
||||
}))
|
||||
.filter(hasTraffic)
|
||||
.sort(bySpend);
|
||||
|
||||
export const rowsByCustomerAndKey = (usage: ReadonlyMap<string, CustomerUsage>): ChargebackRow[] =>
|
||||
[...usage.entries()]
|
||||
.flatMap(([customer, u]) => u.keys.map((k) => ({ ...k, customer, dimension: k.alias ?? k.hash.slice(0, 12) })))
|
||||
.filter(hasTraffic)
|
||||
.sort(bySpend);
|
||||
|
||||
export const rowsByCustomerAndModel = (perCustomer: ReadonlyMap<string, readonly DailyData[]>): ChargebackRow[] =>
|
||||
[...perCustomer.entries()]
|
||||
.flatMap(([customer, days]) => {
|
||||
const byModel = new Map<string, Totals>();
|
||||
for (const day of days) {
|
||||
for (const [model, m] of Object.entries(day.breakdown.models))
|
||||
byModel.set(model, addMetrics(byModel.get(model) ?? ZERO, m.metrics));
|
||||
}
|
||||
return [...byModel.entries()].map(([model, t]) => ({ ...t, customer, dimension: model }));
|
||||
})
|
||||
.filter(hasTraffic)
|
||||
.sort(bySpend);
|
||||
|
||||
export const sumRows = (rows: readonly Totals[]): Totals =>
|
||||
rows.reduce<Totals>(
|
||||
(acc, r) => ({
|
||||
spend: acc.spend + r.spend,
|
||||
requests: acc.requests + r.requests,
|
||||
inputTokens: acc.inputTokens + r.inputTokens,
|
||||
outputTokens: acc.outputTokens + r.outputTokens,
|
||||
}),
|
||||
ZERO,
|
||||
);
|
||||
|
||||
const FORMULA_PREFIX = /^[=+\-@\t\r]/;
|
||||
const neutralise = (v: string) => (FORMULA_PREFIX.test(v) ? `'${v}` : v);
|
||||
const csvCell = (v: string | number) => `"${(typeof v === "string" ? neutralise(v) : String(v)).replace(/"/g, '""')}"`;
|
||||
|
||||
export const toCsv = (
|
||||
rows: readonly ChargebackRow[],
|
||||
aliasOf: ReadonlyMap<string, string>,
|
||||
dimension: string | null,
|
||||
): string => {
|
||||
const header = [
|
||||
"customer_id",
|
||||
"customer",
|
||||
...(dimension ? [dimension] : []),
|
||||
"requests",
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"spend_usd",
|
||||
];
|
||||
const lines = rows.map((r) => [
|
||||
r.customer,
|
||||
aliasOf.get(r.customer) ?? r.customer,
|
||||
...(dimension ? [r.dimension ?? ""] : []),
|
||||
r.requests,
|
||||
r.inputTokens,
|
||||
r.outputTokens,
|
||||
r.spend.toFixed(6),
|
||||
]);
|
||||
return [header, ...lines].map((l) => l.map(csvCell).join(",")).join("\n");
|
||||
};
|
||||
175
ui/litellm-dashboard/src/app/(dashboard)/chargebacks/page.tsx
Normal file
175
ui/litellm-dashboard/src/app/(dashboard)/chargebacks/page.tsx
Normal file
|
|
@ -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<GroupBy>("customer");
|
||||
const customersQuery = useCustomers(100);
|
||||
const customers = useMemo(() => customersQuery.data?.pages.flat() ?? [], [customersQuery.data]);
|
||||
const aliasOf = useMemo(() => new Map(customers.map((c) => [c.user_id, c.alias || c.user_id])), [customers]);
|
||||
const usageQuery = useCustomerUsage(accessToken, monthsBack);
|
||||
const customerIds = useMemo(() => [...(usageQuery.data?.keys() ?? [])].sort(), [usageQuery.data]);
|
||||
|
||||
const perModel = useQuery({
|
||||
queryKey: ["customer-portal", "usage-by-model", monthsBack, customerIds],
|
||||
enabled: Boolean(accessToken) && groupBy === "model" && usageQuery.isSuccess,
|
||||
queryFn: async () => {
|
||||
const { start, end } = monthRange(monthsBack);
|
||||
const entries = await Promise.all(
|
||||
customerIds.map(async (id) => {
|
||||
const request = { accessToken: accessToken!, startTime: start, endTime: end, entityIds: [id] };
|
||||
const res = await dailyActivityAggregatedCall("customer", request);
|
||||
return [id, toDailyData(res)] as const;
|
||||
}),
|
||||
);
|
||||
return new Map(entries);
|
||||
},
|
||||
});
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (groupBy === "model") return perModel.data ? rowsByCustomerAndModel(perModel.data) : [];
|
||||
if (!usageQuery.data) return [];
|
||||
return groupBy === "key" ? rowsByCustomerAndKey(usageQuery.data) : rowsByCustomer(usageQuery.data);
|
||||
}, [groupBy, usageQuery.data, perModel.data]);
|
||||
const total = sumRows(rows);
|
||||
const isLoading = usageQuery.isLoading || (groupBy === "model" && perModel.isLoading);
|
||||
const dimensionLabel = groupBy === "customer" ? null : t(`customerPortal.chargebacks.dimension.${groupBy}`);
|
||||
const monthLabel = (back: number) =>
|
||||
new Intl.DateTimeFormat(i18n.language, { month: "long", year: "numeric", timeZone: "UTC" }).format(
|
||||
monthRange(back).start,
|
||||
);
|
||||
const exportCsv = () =>
|
||||
downloadCsv(
|
||||
toCsv(rows, aliasOf, dimensionLabel?.toLowerCase() ?? null),
|
||||
`chargebacks-${monthRange(monthsBack).start.toISOString().slice(0, 7)}-${groupBy}.csv`,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4 px-6 py-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-baseline gap-3">
|
||||
<h1 className="text-lg font-semibold tracking-tight">{t("customerPortal.chargebacks.title")}</h1>
|
||||
<p className="truncate text-sm text-muted-foreground">{t("customerPortal.chargebacks.subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={String(monthsBack)} onValueChange={(v) => setMonthsBack(Number(v))}>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue>{monthLabel(monthsBack)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MONTHS.map((b) => (
|
||||
<SelectItem key={b} value={String(b)}>
|
||||
{monthLabel(b)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={groupBy} onValueChange={(v) => setGroupBy(v as GroupBy)}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue>{t(`customerPortal.chargebacks.groupBy.${groupBy}`)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{GROUPS.map((g) => (
|
||||
<SelectItem key={g} value={g}>
|
||||
{t(`customerPortal.chargebacks.groupBy.${g}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" onClick={exportCsv} disabled={rows.length === 0}>
|
||||
<Download className="size-4" /> {t("customerPortal.chargebacks.exportCsv")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="pl-4">{t("customerPortal.chargebacks.columns.customer")}</TableHead>
|
||||
{dimensionLabel && <TableHead>{dimensionLabel}</TableHead>}
|
||||
<TableHead className="text-right">{t("customerPortal.chargebacks.columns.requests")}</TableHead>
|
||||
<TableHead className="text-right">{t("customerPortal.chargebacks.columns.inputTokens")}</TableHead>
|
||||
<TableHead className="text-right">{t("customerPortal.chargebacks.columns.outputTokens")}</TableHead>
|
||||
<TableHead className="pr-4 text-right">{t("customerPortal.chargebacks.columns.spend")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
|
||||
{t("customerPortal.common.loading")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!isLoading && rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
|
||||
{t("customerPortal.chargebacks.empty", { month: monthLabel(monthsBack) })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{rows.map((r) => (
|
||||
<TableRow key={`${r.customer}-${r.dimension ?? ""}`}>
|
||||
<TableCell className="pl-4">
|
||||
<p className="font-medium">{aliasOf.get(r.customer) ?? r.customer}</p>
|
||||
<p className="font-mono text-[11px] text-muted-foreground">{r.customer}</p>
|
||||
</TableCell>
|
||||
{dimensionLabel && <TableCell>{r.dimension}</TableCell>}
|
||||
<TableCell className="text-right tabular-nums">{t("format.number", { val: r.requests })}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{t("format.number", { val: r.inputTokens })}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{t("format.number", { val: r.outputTokens })}</TableCell>
|
||||
<TableCell className="pr-4 text-right tabular-nums">{t("format.usd", { val: r.spend })}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{rows.length > 0 && (
|
||||
<TableRow className="font-medium">
|
||||
<TableCell className="pl-4" colSpan={dimensionLabel ? 2 : 1}>
|
||||
{t("customerPortal.common.total")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{t("format.number", { val: total.requests })}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{t("format.number", { val: total.inputTokens })}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{t("format.number", { val: total.outputTokens })}
|
||||
</TableCell>
|
||||
<TableCell className="pr-4 text-right tabular-nums">{t("format.usd", { val: total.spend })}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("customerPortal.createDialog.title")}</DialogTitle>
|
||||
<DialogDescription>{t("customerPortal.createDialog.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cc-user-id">{t("customerPortal.createDialog.customerId")}</Label>
|
||||
<Input
|
||||
id="cc-user-id"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
placeholder={t("customerPortal.createDialog.customerIdPlaceholder")}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cc-alias">{t("customerPortal.createDialog.alias")}</Label>
|
||||
<Input
|
||||
id="cc-alias"
|
||||
value={alias}
|
||||
onChange={(e) => setAlias(e.target.value)}
|
||||
placeholder={t("customerPortal.createDialog.aliasPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cc-budget">{t("customerPortal.createDialog.maxBudget")}</Label>
|
||||
<Input
|
||||
id="cc-budget"
|
||||
inputMode="decimal"
|
||||
value={budget}
|
||||
onChange={(e) => setBudget(decimalOnly(e.target.value))}
|
||||
placeholder={t("customerPortal.createDialog.maxBudgetPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cc-tpm">{t("customerPortal.createDialog.tpm")}</Label>
|
||||
<Input
|
||||
id="cc-tpm"
|
||||
inputMode="numeric"
|
||||
value={tpm}
|
||||
onChange={(e) => setTpm(digitsOnly(e.target.value))}
|
||||
placeholder={t("customerPortal.createDialog.noLimit")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cc-rpm">{t("customerPortal.createDialog.rpm")}</Label>
|
||||
<Input
|
||||
id="cc-rpm"
|
||||
inputMode="numeric"
|
||||
value={rpm}
|
||||
onChange={(e) => setRpm(digitsOnly(e.target.value))}
|
||||
placeholder={t("customerPortal.createDialog.noLimit")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("customerPortal.createDialog.limitsHelp")}</p>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("customerPortal.common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!userId.trim() || create.isPending}>
|
||||
{t("customerPortal.createDialog.submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<p className="flex flex-wrap items-center gap-x-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{t("customerPortal.detail.spendThisMonth")}{" "}
|
||||
<span className="font-medium text-foreground">{t("format.usd", { val: spend })}</span>
|
||||
</span>
|
||||
<span>
|
||||
{max === null
|
||||
? t("customerPortal.detail.noBudget")
|
||||
: t("customerPortal.detail.ofBudget", { max: t("format.usd", { val: max }) })}
|
||||
</span>
|
||||
{tpmLimit !== null && (
|
||||
<Badge variant="outline">
|
||||
{t("customerPortal.detail.tpm", { val: t("format.number", { val: tpmLimit }) })}
|
||||
</Badge>
|
||||
)}
|
||||
{rpmLimit !== null && (
|
||||
<Badge variant="outline">
|
||||
{t("customerPortal.detail.rpm", { val: t("format.number", { val: rpmLimit }) })}
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="w-full space-y-4 px-6 py-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-semibold tracking-tight">{label}</h1>
|
||||
{c && <StatusBadge status={customerStatus(c, spend)} />}
|
||||
<span className="font-mono text-xs text-muted-foreground">{customerId}</span>
|
||||
</div>
|
||||
<UsageSummary spend={spend} budget={c?.litellm_budget_table} />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" disabled={!c} onClick={() => setEditOpen(true)}>
|
||||
<Pencil className="size-4" /> {t("customerPortal.common.edit")}
|
||||
</Button>
|
||||
{c?.blocked ? (
|
||||
<Button variant="outline" onClick={() => unblock.mutate({ body: { user_id: customerId, blocked: false } })}>
|
||||
<Ban className="size-4" /> {t("customerPortal.common.unblock")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-destructive"
|
||||
onClick={() => block.mutate({ body: { user_ids: [customerId] } })}
|
||||
>
|
||||
<Ban className="size-4" /> {t("customerPortal.common.block")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("customerPortal.common.blockHint")}</p>
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="flex items-baseline gap-3 border-b px-4 py-3">
|
||||
<h2 className="text-sm font-semibold">{t("customerPortal.detail.keysTitle")}</h2>
|
||||
<p className="text-xs text-muted-foreground">{t("customerPortal.detail.keysSubtitle")}</p>
|
||||
</div>
|
||||
{keys.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">{t("customerPortal.detail.keysEmpty")}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="pl-4">{t("customerPortal.detail.columns.key")}</TableHead>
|
||||
<TableHead className="text-right">{t("customerPortal.detail.columns.requests")}</TableHead>
|
||||
<TableHead className="text-right">{t("customerPortal.detail.columns.tokens")}</TableHead>
|
||||
<TableHead className="pr-4 text-right">{t("customerPortal.detail.columns.spend")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{keys.map((k) => (
|
||||
<TableRow key={k.hash}>
|
||||
<TableCell className="pl-4">
|
||||
<p className="font-medium">{k.alias ?? t("customerPortal.detail.unnamedKey")}</p>
|
||||
<p className="font-mono text-[11px] text-muted-foreground">{k.hash.slice(0, 12)}</p>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{t("format.number", { val: k.requests })}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{t("format.number", { val: k.inputTokens + k.outputTokens })}
|
||||
</TableCell>
|
||||
<TableCell className="pr-4 text-right tabular-nums">{t("format.usd", { val: k.spend })}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="link" size="sm" className="h-auto p-0 text-xs text-muted-foreground" onClick={onBack}>
|
||||
{t("customerPortal.detail.back")}
|
||||
</Button>
|
||||
{c && <EditCustomerDialog open={editOpen} onOpenChange={setEditOpen} customer={c} onSaved={refresh} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Props, "open">) {
|
||||
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 (
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("customerPortal.editDialog.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-mono text-xs">{customer.user_id}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ec-alias">{t("customerPortal.editDialog.alias")}</Label>
|
||||
<Input id="ec-alias" value={alias} onChange={(e) => setAlias(e.target.value)} autoFocus />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ec-budget">{t("customerPortal.editDialog.maxBudget")}</Label>
|
||||
<Input
|
||||
id="ec-budget"
|
||||
inputMode="decimal"
|
||||
value={budget}
|
||||
onChange={(e) => setBudget(decimalOnly(e.target.value))}
|
||||
placeholder={t("customerPortal.editDialog.maxBudgetPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
{budgetId && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("customerPortal.editDialog.sharedBudgetWarning", { budgetId })}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("customerPortal.common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={update.isPending}>
|
||||
{t("customerPortal.common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EditCustomerDialog({ open, ...form }: Props) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={form.onOpenChange}>
|
||||
{open && <EditCustomerForm {...form} />}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 <Badge variant={VARIANT[status]}>{t(LABEL_KEY[status])}</Badge>;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
});
|
||||
|
|
@ -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<string, KeyMetricWithMetadata>, requests = 1): MetricWithMetadata => ({
|
||||
metrics: metrics(spend, requests),
|
||||
metadata: {},
|
||||
api_key_breakdown: keys,
|
||||
});
|
||||
|
||||
const day = (date: string, entities: Record<string, MetricWithMetadata>): 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)));
|
||||
});
|
||||
});
|
||||
|
|
@ -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<SpendMetrics> | 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<string, CustomerUsage> => {
|
||||
const totals = new Map<string, Totals>();
|
||||
const keys = new Map<string, Map<string, CustomerKeyUsage>>();
|
||||
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<string, CustomerKeyUsage>();
|
||||
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<EndUser, "blocked" | "litellm_budget_table">, 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<string, CustomerUsage> | 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);
|
||||
153
ui/litellm-dashboard/src/app/(dashboard)/customers/page.tsx
Normal file
153
ui/litellm-dashboard/src/app/(dashboard)/customers/page.tsx
Normal file
|
|
@ -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 <CustomerDetail customerId={selectedId} onBack={() => setSelectedId(null)} />;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4 px-6 py-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-baseline gap-3">
|
||||
<h1 className="text-lg font-semibold tracking-tight">{t("customerPortal.list.title")}</h1>
|
||||
<p className="truncate text-sm text-muted-foreground">{t("customerPortal.list.subtitle")}</p>
|
||||
</div>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="size-4" /> {t("customerPortal.list.create")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("customerPortal.common.blockHint")}</p>
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="border-b p-3">
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder={t("customerPortal.list.searchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="pl-4">{t("customerPortal.list.columns.customer")}</TableHead>
|
||||
<TableHead className="text-right">{t("customerPortal.list.columns.spend")}</TableHead>
|
||||
<TableHead className="text-right">{t("customerPortal.list.columns.budget")}</TableHead>
|
||||
<TableHead className="text-right">{t("customerPortal.list.columns.keys")}</TableHead>
|
||||
<TableHead>{t("customerPortal.list.columns.status")}</TableHead>
|
||||
<TableHead className="w-28" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{customersQuery.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
|
||||
{t("customerPortal.list.loading")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!customersQuery.isLoading && rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
|
||||
{t("customerPortal.list.empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{rows.map(({ c, spend, keys, status }) => {
|
||||
const max = c.litellm_budget_table?.max_budget ?? null;
|
||||
return (
|
||||
<TableRow
|
||||
key={c.user_id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setSelectedId(c.user_id, { history: "push" })}
|
||||
>
|
||||
<TableCell className="pl-4">
|
||||
<p className="font-medium">{c.alias || c.user_id}</p>
|
||||
<p className="font-mono text-[11px] text-muted-foreground">{c.user_id}</p>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium tabular-nums">
|
||||
{t("format.usd", { val: spend })}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
{max === null
|
||||
? t("customerPortal.common.noBudget")
|
||||
: t("customerPortal.list.budgetUsage", {
|
||||
spend: t("format.usd", { val: spend }),
|
||||
max: t("format.usd", { val: max }),
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{keys}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
||||
{c.blocked ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => unblock.mutate({ body: { user_id: c.user_id, blocked: false } })}
|
||||
>
|
||||
{t("customerPortal.common.unblock")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => block.mutate({ body: { user_ids: [c.user_id] } })}
|
||||
>
|
||||
{t("customerPortal.common.block")}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<CreateCustomerDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onCreated={(id) => {
|
||||
customersQuery.refetch();
|
||||
setSelectedId(id, { history: "push" });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ const LEGACY_PAGE_ROUTES: ReadonlyMap<string, string> = new Map(
|
|||
chat: "chat",
|
||||
"access-groups": "access-groups",
|
||||
budgets: "budgets",
|
||||
customers: "customers",
|
||||
chargebacks: "chargebacks",
|
||||
workflows: "workflows",
|
||||
"guardrails-monitor": "guardrails-monitor",
|
||||
"mcp-servers": "mcp-servers",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
<div className="flex w-full items-center justify-between gap-6 py-0.5">
|
||||
<span className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{t("customerPortal.switcher.label")} <BetaBadge />
|
||||
</span>{" "}
|
||||
<span className="text-xs leading-snug text-muted-foreground">{t("customerPortal.switcher.description")}</span>
|
||||
</span>
|
||||
{!isStandaloneRoute && mode === CUSTOMER_PORTAL && <Check className="size-4 text-info" />}
|
||||
</div>
|
||||
),
|
||||
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
|
|||
<>
|
||||
<span data-slot="workspace-label" className="truncate">
|
||||
{activeLabel}
|
||||
{!isStandaloneRoute && mode === CUSTOMER_PORTAL && (
|
||||
<>
|
||||
{" "}
|
||||
<BetaBadge />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown data-slot="workspace-chevron" className="size-3.5 flex-none text-muted-foreground" />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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: <Users {...ICON} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "chargebacks",
|
||||
page: "chargebacks",
|
||||
label: "shell.nav.items.chargebacks",
|
||||
icon: <Receipt {...ICON} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const menuGroups: MenuGroup[] = [
|
||||
{
|
||||
groupLabel: "AI GATEWAY",
|
||||
|
|
@ -542,6 +566,7 @@ const findMenuItemKey = (route: string): string => {
|
|||
|
||||
const GROUP_LABELS: Record<string, ParseKeys> = {
|
||||
"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<string, ParseKeys> = {
|
||||
"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<SidebarProps> = ({
|
|||
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<SidebarProps> = ({
|
|||
disableVectorStoresForInternalUsers,
|
||||
allowVectorStoresForTeamAdmins,
|
||||
};
|
||||
return visibleMenuGroups(menuGroups, context);
|
||||
return visibleMenuGroups(pluginMode === "customer-portal" ? customerPortalGroups : menuGroups, context);
|
||||
}, [
|
||||
pluginMode,
|
||||
allowAgentsForTeamAdmins,
|
||||
allowVectorStoresForTeamAdmins,
|
||||
disableAgentsForInternalUsers,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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<string> = 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);
|
||||
|
|
|
|||
|
|
@ -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}}."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue