mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
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:
parent
ae894aa645
commit
6f830f7150
6 changed files with 127 additions and 167 deletions
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 } });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue