feat(ui): customer portal Add key tags the key with the customer id instead of per-customer limits

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
kerry 2026-11-07 00:25:22 +00:00
parent ae894aa645
commit 6f830f7150
6 changed files with 127 additions and 167 deletions

View file

@ -0,0 +1,79 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { uiHref } from "@/utils/uiHref";
import { setCustomerTag, type PortalKey } from "./customerKeys";
interface Props {
open: boolean;
onOpenChange: (o: boolean) => void;
accessToken: string;
customerId: string;
customerLabel: string;
keys: PortalKey[];
excludeTokens: ReadonlySet<string>;
onSaved: () => void;
}
function AddKeyForm({ onOpenChange, accessToken, customerId, customerLabel, keys, excludeTokens, onSaved }: Omit<Props, "open">) {
const available = keys.filter((k) => !excludeTokens.has(k.token));
const [token, setToken] = useState(available[0]?.token ?? "");
const [saving, setSaving] = useState(false);
const selected = available.find((k) => k.token === token) ?? null;
const save = async () => {
if (!selected) return;
setSaving(true);
try {
await setCustomerTag(accessToken, selected, customerId, true);
toast.success(`Added ${selected.alias} to ${customerLabel}`);
onSaved();
onOpenChange(false);
} catch (e) {
toast.error(`Failed to update key: ${e instanceof Error ? e.message : String(e)}`);
} finally {
setSaving(false);
}
};
return (
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add key to {customerLabel}</DialogTitle>
<DialogDescription>Tags the key with this customer id so it shows up here. Requests from this key should send <code>user={customerId}</code>.</DialogDescription>
</DialogHeader>
<div className="space-y-1.5">
<Label htmlFor="ak-key">Key</Label>
<Select value={token} onValueChange={(v) => setToken(v ?? "")}>
<SelectTrigger id="ak-key" className="w-full"><SelectValue placeholder="Select a key">{selected?.alias}</SelectValue></SelectTrigger>
<SelectContent>
{available.map((k) => (
<SelectItem key={k.token} value={k.token}>{k.alias}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Don&apos;t see the key? <Link id="ak-create-link" href={uiHref("api-keys")} className="font-medium text-primary hover:underline">Create one in AI Gateway</Link>
</p>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button type="button" disabled={!selected || saving} onClick={save}>Add key</Button>
</DialogFooter>
</DialogContent>
);
}
export default function AddKeyDialog({ open, ...form }: Props) {
return (
<Dialog open={open} onOpenChange={form.onOpenChange}>
{open && <AddKeyForm {...form} />}
</Dialog>
);
}

View file

@ -39,7 +39,7 @@ export default function CreateCustomerDialog({ open, onOpenChange, onCreated }:
<form onSubmit={submit} className="space-y-4">
<DialogHeader>
<DialogTitle>Create customer</DialogTitle>
<DialogDescription>Track spend and set limits for an end user of your product.</DialogDescription>
<DialogDescription>Track spend and set a budget for an end user of your product.</DialogDescription>
</DialogHeader>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">

View file

@ -1,7 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { Ban, Pencil, Plus, SlidersHorizontal } from "lucide-react";
import { Ban, Pencil, Plus, X } from "lucide-react";
import { toast } from "sonner";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { $api } from "@/lib/http/api";
@ -9,8 +9,8 @@ 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, formatUSD, useCustomerSpendByCustomer } from "./customerPortalData";
import { customerLimitsOnKey, useAllKeys, useCustomerTrafficKeys, type PortalKey } from "./customerKeys";
import KeyLimitDialog from "./KeyLimitDialog";
import { keyTaggedWith, setCustomerTag, useAllKeys, useCustomerTrafficKeys, type PortalKey } from "./customerKeys";
import AddKeyDialog from "./AddKeyDialog";
import EditCustomerDialog from "./EditCustomerDialog";
const STATUS_BADGE = {
@ -25,7 +25,7 @@ export default function CustomerDetail({ customerId, onBack }: { customerId: str
const spendQuery = useCustomerSpendByCustomer(accessToken);
const keysQuery = useAllKeys(accessToken);
const trafficQuery = useCustomerTrafficKeys(accessToken, customerId);
const [dialog, setDialog] = useState<{ open: boolean; editKey: PortalKey | null }>({ open: false, editKey: null });
const [addOpen, setAddOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const block = $api.useMutation("post", "/customer/block", { onSuccess: () => { toast.success("Customer blocked"); info.refetch(); } });
const unblock = $api.useMutation("post", "/customer/update", { onSuccess: () => { toast.success("Customer unblocked"); info.refetch(); } });
@ -36,14 +36,24 @@ export default function CustomerDetail({ customerId, onBack }: { customerId: str
const keys = keysQuery.data ?? [];
const traffic = trafficQuery.data ?? new Set<string>();
return keys
.map((k) => ({ k, limits: customerLimitsOnKey(k, customerId), traffic: traffic.has(k.token) }))
.filter((r) => r.traffic || r.limits.tpm !== null || r.limits.rpm !== null);
.map((k) => ({ k, tagged: keyTaggedWith(k, customerId), traffic: traffic.has(k.token) }))
.filter((r) => r.traffic || r.tagged);
}, [keysQuery.data, trafficQuery.data, customerId]);
const shownTokens = useMemo(() => new Set(rows.map((r) => r.k.token)), [rows]);
const label = c?.alias || customerId;
const status = c ? customerStatus(c, spend) : "active";
const max = c?.litellm_budget_table?.max_budget ?? null;
const refresh = () => { keysQuery.refetch(); trafficQuery.refetch(); };
const removeKey = async (k: PortalKey) => {
if (!accessToken) return;
try {
await setCustomerTag(accessToken, k, customerId, false);
toast.success(`Removed ${k.alias} from ${label}`);
refresh();
} catch (e) {
toast.error(`Failed to update key: ${e instanceof Error ? e.message : String(e)}`);
}
};
return (
<div className="w-full space-y-4 px-6 py-5">
@ -73,38 +83,43 @@ export default function CustomerDetail({ customerId, onBack }: { customerId: str
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-baseline gap-3">
<h2 className="text-sm font-semibold">Keys</h2>
<p className="text-xs text-muted-foreground">Keys with traffic from this customer this month, or with a limit set for this customer.</p>
<p className="text-xs text-muted-foreground">Keys tagged with this customer, or with traffic from it this month.</p>
</div>
<Button size="sm" variant="outline" onClick={() => setDialog({ open: true, editKey: null })}><Plus className="size-4" /> Add key</Button>
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}><Plus className="size-4" /> Add key</Button>
</div>
{rows.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-10 text-center text-sm text-muted-foreground">
<p>No keys yet. Add a key to set limits for this customer.</p>
<Button size="sm" onClick={() => setDialog({ open: true, editKey: null })}><Plus className="size-4" /> Add key</Button>
<p>No keys yet. Add a gateway key this customer will use.</p>
<Button size="sm" onClick={() => setAddOpen(true)}><Plus className="size-4" /> Add key</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="pl-4">Key</TableHead>
<TableHead>TPM limit for this key</TableHead>
<TableHead>RPM limit for this key</TableHead>
<TableHead>Status</TableHead>
<TableHead>Models</TableHead>
<TableHead>TPM limit</TableHead>
<TableHead>RPM limit</TableHead>
<TableHead>Source</TableHead>
<TableHead className="w-32" />
</TableRow>
</TableHeader>
<TableBody>
{rows.map(({ k, limits }) => (
{rows.map(({ k, tagged, traffic }) => (
<TableRow key={k.token}>
<TableCell className="pl-4">
<p className="font-medium">{k.alias}</p>
<p className="font-mono text-[11px] text-muted-foreground">{k.token.slice(0, 12)}...</p>
</TableCell>
<TableCell className="tabular-nums">{limits.tpm !== null ? limits.tpm.toLocaleString() : <span className="text-muted-foreground">Inherits key limit{k.tpmLimit ? ` (${k.tpmLimit.toLocaleString()})` : ""}</span>}</TableCell>
<TableCell className="tabular-nums">{limits.rpm !== null ? limits.rpm.toLocaleString() : <span className="text-muted-foreground">Inherits key limit{k.rpmLimit ? ` (${k.rpmLimit.toLocaleString()})` : ""}</span>}</TableCell>
<TableCell><Badge variant={c?.blocked ? "outline" : "secondary"}>{c?.blocked ? "Blocked" : "Active"}</Badge></TableCell>
<TableCell className="max-w-56 truncate text-muted-foreground">{k.models.length ? k.models.join(", ") : "All models"}</TableCell>
<TableCell className="tabular-nums">{k.tpmLimit ? k.tpmLimit.toLocaleString() : <span className="text-muted-foreground">No limit</span>}</TableCell>
<TableCell className="tabular-nums">{k.rpmLimit ? k.rpmLimit.toLocaleString() : <span className="text-muted-foreground">No limit</span>}</TableCell>
<TableCell className="space-x-1">
{tagged && <Badge variant="secondary">Added</Badge>}
{traffic && <Badge variant="outline">Traffic this month</Badge>}
</TableCell>
<TableCell className="text-right">
<Button size="sm" variant="outline" onClick={() => setDialog({ open: true, editKey: k })}><SlidersHorizontal className="size-4" /> Edit limits</Button>
{tagged && <Button size="sm" variant="ghost" onClick={() => removeKey(k)}><X className="size-4" /> Remove</Button>}
</TableCell>
</TableRow>
))}
@ -115,15 +130,14 @@ export default function CustomerDetail({ customerId, onBack }: { customerId: str
<Button variant="link" size="sm" className="h-auto p-0 text-xs text-muted-foreground" onClick={onBack}>Back to customers</Button>
{c && <EditCustomerDialog open={editOpen} onOpenChange={setEditOpen} customer={c} onSaved={() => info.refetch()} />}
{accessToken && (
<KeyLimitDialog
open={dialog.open}
onOpenChange={(open) => setDialog((d) => ({ ...d, open }))}
<AddKeyDialog
open={addOpen}
onOpenChange={setAddOpen}
accessToken={accessToken}
customerId={customerId}
customerLabel={label}
keys={keysQuery.data ?? []}
excludeTokens={shownTokens}
editKey={dialog.editKey}
onSaved={refresh}
/>
)}

View file

@ -1,120 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { toast } from "sonner";
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { uiHref } from "@/utils/uiHref";
import { customerLimitsOnKey, writeCustomerLimits, type PortalKey } from "./customerKeys";
interface Props {
open: boolean;
onOpenChange: (o: boolean) => void;
accessToken: string;
customerId: string;
customerLabel: string;
keys: PortalKey[];
excludeTokens: ReadonlySet<string>;
editKey: PortalKey | null;
onSaved: () => void;
}
const digits = (s: string) => s.replace(/[^0-9]/g, "");
const num = (s: string) => (s ? Number(s) : null);
const asText = (n: number | null) => (n === null ? "" : String(n));
const successMessage = (editKey: PortalKey | null, alias: string, customerLabel: string, cleared: boolean) => {
if (cleared) return `Removed ${alias} limits for ${customerLabel}`;
if (editKey) return `Rate limits updated for ${customerLabel}`;
return `Added ${alias} to ${customerLabel}`;
};
type FormProps = Omit<Props, "open">;
function LimitForm({ onOpenChange, accessToken, customerId, customerLabel, keys, excludeTokens, editKey, onSaved }: FormProps) {
const available = editKey ? [editKey] : keys.filter((k) => !excludeTokens.has(k.token));
const initial = editKey ?? available[0] ?? null;
const initialLimits = initial ? customerLimitsOnKey(initial, customerId) : { tpm: null, rpm: null };
const [token, setToken] = useState(initial?.token ?? "");
const [tpm, setTpm] = useState(asText(initialLimits.tpm));
const [rpm, setRpm] = useState(asText(initialLimits.rpm));
const [saving, setSaving] = useState(false);
const selected = keys.find((k) => k.token === token) ?? null;
const pickKey = (next: string | null) => {
const k = keys.find((x) => x.token === next) ?? null;
const cur = k ? customerLimitsOnKey(k, customerId) : { tpm: null, rpm: null };
setToken(next ?? "");
setTpm(asText(cur.tpm));
setRpm(asText(cur.rpm));
};
const save = async (clear = false) => {
if (!selected) return;
setSaving(true);
try {
await writeCustomerLimits(accessToken, selected, customerId, clear ? { tpm: null, rpm: null } : { tpm: num(tpm), rpm: num(rpm) });
toast.success(successMessage(editKey, selected.alias, customerLabel, clear));
onSaved();
onOpenChange(false);
} catch (e) {
toast.error(`Failed to update key: ${e instanceof Error ? e.message : String(e)}`);
} finally {
setSaving(false);
}
};
return (
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{editKey ? `Edit limits for ${editKey.alias}` : `Add key to ${customerLabel}`}</DialogTitle>
<DialogDescription>Limits apply to this customer on this key only. Other customers on the same key are unaffected.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{!editKey && (
<div className="space-y-1.5">
<Label htmlFor="ak-key">Key</Label>
<Select value={token} onValueChange={pickKey}>
<SelectTrigger id="ak-key" className="w-full"><SelectValue placeholder="Select a key">{selected?.alias}</SelectValue></SelectTrigger>
<SelectContent>
{available.map((k) => (
<SelectItem key={k.token} value={k.token}>{k.alias}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Don&apos;t see the key? <Link id="ak-create-link" href={uiHref("api-keys")} className="font-medium text-primary hover:underline">Create one in AI Gateway</Link>
</p>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="ak-tpm">TPM limit for this customer</Label>
<Input id="ak-tpm" inputMode="numeric" value={tpm} onChange={(e) => setTpm(digits(e.target.value))} placeholder={selected?.tpmLimit ? `Key limit ${selected.tpmLimit.toLocaleString()}` : "No limit"} />
</div>
<div className="space-y-1.5">
<Label htmlFor="ak-rpm">RPM limit for this customer</Label>
<Input id="ak-rpm" inputMode="numeric" value={rpm} onChange={(e) => setRpm(digits(e.target.value))} placeholder={selected?.rpmLimit ? `Key limit ${selected.rpmLimit.toLocaleString()}` : "No limit"} />
</div>
</div>
</div>
<DialogFooter>
{editKey && <Button type="button" variant="ghost" className="mr-auto text-destructive" disabled={saving} onClick={() => save(true)}>Remove key</Button>}
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button type="button" disabled={!selected || saving} onClick={() => save(false)}>{editKey ? "Save changes" : "Add key"}</Button>
</DialogFooter>
</DialogContent>
);
}
export default function KeyLimitDialog({ open, ...form }: Props) {
return (
<Dialog open={open} onOpenChange={form.onOpenChange}>
{open && <LimitForm key={form.editKey?.token ?? "new"} {...form} />}
</Dialog>
);
}

View file

@ -11,15 +11,12 @@ export interface PortalKey {
metadata: Record<string, unknown>;
}
const limitMap = (metadata: Record<string, unknown>, field: string): Record<string, number> => {
const m = metadata[field];
return m && typeof m === "object" ? (m as Record<string, number>) : {};
const customerIds = (metadata: Record<string, unknown>): string[] => {
const ids = metadata.customer_ids;
return Array.isArray(ids) ? ids.filter((x): x is string => typeof x === "string") : [];
};
export const customerLimitsOnKey = (key: PortalKey, customerId: string): { tpm: number | null; rpm: number | null } => ({
tpm: limitMap(key.metadata, "customer_tpm_limit")[customerId] ?? null,
rpm: limitMap(key.metadata, "customer_rpm_limit")[customerId] ?? null,
});
export const keyTaggedWith = (key: PortalKey, customerId: string): boolean => customerIds(key.metadata).includes(customerId);
export const useAllKeys = (accessToken: string | null) =>
useQuery({
@ -52,15 +49,8 @@ export const useCustomerTrafficKeys = (accessToken: string | null, customerId: s
},
});
export const writeCustomerLimits = async (
accessToken: string,
key: PortalKey,
customerId: string,
limits: { tpm: number | null; rpm: number | null },
) => {
const tpmMap = { ...limitMap(key.metadata, "customer_tpm_limit") };
const rpmMap = { ...limitMap(key.metadata, "customer_rpm_limit") };
if (limits.tpm === null) delete tpmMap[customerId]; else tpmMap[customerId] = limits.tpm;
if (limits.rpm === null) delete rpmMap[customerId]; else rpmMap[customerId] = limits.rpm;
await keyUpdateCall(accessToken, { key: key.token, metadata: { ...key.metadata, customer_tpm_limit: tpmMap, customer_rpm_limit: rpmMap } });
export const setCustomerTag = async (accessToken: string, key: PortalKey, customerId: string, tagged: boolean) => {
const others = customerIds(key.metadata).filter((id) => id !== customerId);
const ids = tagged ? [...others, customerId] : others;
await keyUpdateCall(accessToken, { key: key.token, metadata: { ...key.metadata, customer_ids: ids } });
};

View file

@ -1,6 +1,6 @@
"use client";
import { customerLimitsOnKey, useAllKeys } from "./_components/customerKeys";
import { keyTaggedWith, useAllKeys } from "./_components/customerKeys";
import { useMemo, useState } from "react";
import { parseAsString, useQueryState } from "nuqs";
import { Plus } from "lucide-react";
@ -36,11 +36,8 @@ export default function CustomersPage() {
return customers
.map((c) => {
const s = spend?.get(c.user_id);
const limited = (keysQuery.data ?? []).filter((k) => {
const l = customerLimitsOnKey(k, c.user_id);
return l.tpm !== null || l.rpm !== null;
}).map((k) => k.token);
const keys = new Set([...(s?.keys ?? []), ...limited]).size;
const tagged = (keysQuery.data ?? []).filter((k) => keyTaggedWith(k, c.user_id)).map((k) => k.token);
const keys = new Set([...(s?.keys ?? []), ...tagged]).size;
return { c, spend: s?.spend ?? 0, keys, status: customerStatus(c, s?.spend ?? 0) };
})
.sort((a, b) => b.spend - a.spend);