From 6f830f71507bcc8e0a059a40cbb95cbfdce9ef04 Mon Sep 17 00:00:00 2001 From: kerry Date: Sat, 7 Nov 2026 00:25:22 +0000 Subject: [PATCH] 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> --- .../customers/_components/AddKeyDialog.tsx | 79 ++++++++++++ .../_components/CreateCustomerDialog.tsx | 2 +- .../customers/_components/CustomerDetail.tsx | 58 +++++---- .../customers/_components/KeyLimitDialog.tsx | 120 ------------------ .../customers/_components/customerKeys.ts | 26 ++-- .../src/app/(dashboard)/customers/page.tsx | 9 +- 6 files changed, 127 insertions(+), 167 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/customers/_components/AddKeyDialog.tsx delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/customers/_components/KeyLimitDialog.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/AddKeyDialog.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/AddKeyDialog.tsx new file mode 100644 index 00000000000..b7aee0490f5 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/AddKeyDialog.tsx @@ -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; + onSaved: () => void; +} + +function AddKeyForm({ onOpenChange, accessToken, customerId, customerLabel, keys, excludeTokens, onSaved }: Omit) { + 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 ( + + + Add key to {customerLabel} + Tags the key with this customer id so it shows up here. Requests from this key should send user={customerId}. + +
+ + +

+ Don't see the key? Create one in AI Gateway +

+
+ + + + +
+ ); +} + +export default function AddKeyDialog({ open, ...form }: Props) { + return ( + + {open && } + + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CreateCustomerDialog.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CreateCustomerDialog.tsx index 42f42aedd83..3cbdd5a79cc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CreateCustomerDialog.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CreateCustomerDialog.tsx @@ -39,7 +39,7 @@ export default function CreateCustomerDialog({ open, onOpenChange, onCreated }:
Create customer - Track spend and set limits for an end user of your product. + Track spend and set a budget for an end user of your product.
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerDetail.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerDetail.tsx index d6f57f485a5..ddcdc75ccd6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerDetail.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerDetail.tsx @@ -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(); 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 (
@@ -73,38 +83,43 @@ export default function CustomerDetail({ customerId, onBack }: { customerId: str

Keys

-

Keys with traffic from this customer this month, or with a limit set for this customer.

+

Keys tagged with this customer, or with traffic from it this month.

- +
{rows.length === 0 ? (
-

No keys yet. Add a key to set limits for this customer.

- +

No keys yet. Add a gateway key this customer will use.

+
) : ( Key - TPM limit for this key - RPM limit for this key - Status + Models + TPM limit + RPM limit + Source - {rows.map(({ k, limits }) => ( + {rows.map(({ k, tagged, traffic }) => (

{k.alias}

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

- {limits.tpm !== null ? limits.tpm.toLocaleString() : Inherits key limit{k.tpmLimit ? ` (${k.tpmLimit.toLocaleString()})` : ""}} - {limits.rpm !== null ? limits.rpm.toLocaleString() : Inherits key limit{k.rpmLimit ? ` (${k.rpmLimit.toLocaleString()})` : ""}} - {c?.blocked ? "Blocked" : "Active"} + {k.models.length ? k.models.join(", ") : "All models"} + {k.tpmLimit ? k.tpmLimit.toLocaleString() : No limit} + {k.rpmLimit ? k.rpmLimit.toLocaleString() : No limit} + + {tagged && Added} + {traffic && Traffic this month} + - + {tagged && }
))} @@ -115,15 +130,14 @@ export default function CustomerDetail({ customerId, onBack }: { customerId: str {c && info.refetch()} />} {accessToken && ( - setDialog((d) => ({ ...d, open }))} + )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/KeyLimitDialog.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/KeyLimitDialog.tsx deleted file mode 100644 index 2c2f2c9b5b1..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/KeyLimitDialog.tsx +++ /dev/null @@ -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; - 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; - -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 ( - - - {editKey ? `Edit limits for ${editKey.alias}` : `Add key to ${customerLabel}`} - Limits apply to this customer on this key only. Other customers on the same key are unaffected. - -
- {!editKey && ( -
- - -

- Don't see the key? Create one in AI Gateway -

-
- )} -
-
- - setTpm(digits(e.target.value))} placeholder={selected?.tpmLimit ? `Key limit ${selected.tpmLimit.toLocaleString()}` : "No limit"} /> -
-
- - setRpm(digits(e.target.value))} placeholder={selected?.rpmLimit ? `Key limit ${selected.rpmLimit.toLocaleString()}` : "No limit"} /> -
-
-
- - {editKey && } - - - -
- ); -} - -export default function KeyLimitDialog({ open, ...form }: Props) { - return ( - - {open && } - - ); -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerKeys.ts b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerKeys.ts index 5c177d1c37d..68865f9ef9e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerKeys.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerKeys.ts @@ -11,15 +11,12 @@ export interface PortalKey { metadata: Record; } -const limitMap = (metadata: Record, field: string): Record => { - const m = metadata[field]; - return m && typeof m === "object" ? (m as Record) : {}; +const customerIds = (metadata: Record): 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 } }); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/page.tsx index 499b83dc201..838344f0794 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/customers/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/page.tsx @@ -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);