feat(ui): customer-level TPM/RPM limits in the Customer Portal create and edit dialogs

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
kerry 2026-11-07 01:31:04 +00:00
parent e7ad61f89e
commit 753fe187af
3 changed files with 45 additions and 5 deletions

View file

@ -12,6 +12,8 @@ export default function CreateCustomerDialog({ open, onOpenChange, onCreated }:
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(`Created ${alias || userId}`);
@ -30,6 +32,8 @@ export default function CreateCustomerDialog({ open, onOpenChange, onCreated }:
blocked: false,
max_budget: budget ? Number(budget) : null,
budget_duration: budget ? "1mo" : null,
tpm_limit: tpm ? Number(tpm) : null,
rpm_limit: rpm ? Number(rpm) : null,
},
});
};
@ -52,10 +56,23 @@ export default function CreateCustomerDialog({ open, onOpenChange, onCreated }:
<Input id="cp-alias" value={alias} onChange={(e) => setAlias(e.target.value)} placeholder="Acme Corp" />
</div>
</div>
<div className="space-y-1.5 sm:w-1/2">
<Label htmlFor="cp-budget">Max Budget (USD, resets monthly)</Label>
<Input id="cp-budget" inputMode="decimal" value={budget} onChange={(e) => setBudget(e.target.value.replace(/[^0-9.]/g, ""))} placeholder="Leave empty for no budget" />
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="cp-budget">Max Budget (USD, resets monthly)</Label>
<Input id="cp-budget" inputMode="decimal" value={budget} onChange={(e) => setBudget(e.target.value.replace(/[^0-9.]/g, ""))} placeholder="Leave empty for no budget" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="cp-tpm">TPM limit</Label>
<Input id="cp-tpm" inputMode="numeric" value={tpm} onChange={(e) => setTpm(e.target.value.replace(/[^0-9]/g, ""))} placeholder="No limit" />
</div>
<div className="space-y-1.5">
<Label htmlFor="cp-rpm">RPM limit</Label>
<Input id="cp-rpm" inputMode="numeric" value={rpm} onChange={(e) => setRpm(e.target.value.replace(/[^0-9]/g, ""))} placeholder="No limit" />
</div>
</div>
</div>
<p className="text-xs text-muted-foreground">TPM/RPM apply to this customer across every key it uses; the proxy returns 429 above them.</p>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button type="submit" disabled={!userId.trim() || create.isPending}>Create Customer</Button>

View file

@ -43,6 +43,9 @@ export default function CustomerDetail({ customerId, onBack }: { customerId: str
const label = c?.alias || customerId;
const status = c ? customerStatus(c, spend) : "active";
const max = c?.litellm_budget_table?.max_budget ?? null;
const tpmLimit = c?.litellm_budget_table?.tpm_limit ?? null;
const rpmLimit = c?.litellm_budget_table?.rpm_limit ?? null;
const limitsText = [tpmLimit !== null ? `${tpmLimit.toLocaleString()} TPM` : null, rpmLimit !== null ? `${rpmLimit.toLocaleString()} RPM` : null].filter(Boolean).join(" / ");
const refresh = () => { keysQuery.refetch(); trafficQuery.refetch(); };
const removeKey = async (k: PortalKey) => {
if (!accessToken) return;
@ -67,6 +70,7 @@ export default function CustomerDetail({ customerId, onBack }: { customerId: str
<p className="text-sm text-muted-foreground">
Spend this month <span className="font-medium text-foreground">{formatUSD(spend)}</span>
{max !== null && max !== undefined ? <> of <span className="font-medium text-foreground">{formatUSD(max)}</span> budget</> : ", no budget"}
{limitsText && <> &middot; {limitsText}</>}
</p>
</div>
<div className="flex gap-2">

View file

@ -20,8 +20,16 @@ function EditCustomerForm({ onOpenChange, customer, onSaved }: Omit<Props, "open
const [alias, setAlias] = useState(customer.alias ?? "");
const currentBudget = customer.litellm_budget_table?.max_budget;
const [budget, setBudget] = useState(currentBudget === null || currentBudget === undefined ? "" : String(currentBudget));
const budgetId = customer.litellm_budget_table?.budget_id ?? null;
const asText = (n: number | null | undefined) => (n === null || n === undefined ? "" : String(n));
const [tpm, setTpm] = useState(asText(customer.litellm_budget_table?.tpm_limit));
const [rpm, setRpm] = useState(asText(customer.litellm_budget_table?.rpm_limit));
const updateLimits = $api.useMutation("post", "/budget/update");
const update = $api.useMutation("post", "/customer/update", {
onSuccess: () => {
onSuccess: async () => {
if (budgetId) {
await updateLimits.mutateAsync({ body: { budget_id: budgetId, tpm_limit: tpm ? Number(tpm) : null, rpm_limit: rpm ? Number(rpm) : null } });
}
toast.success("Customer updated");
onSaved();
onOpenChange(false);
@ -54,9 +62,20 @@ function EditCustomerForm({ onOpenChange, customer, onSaved }: Omit<Props, "open
<Label htmlFor="ec-budget">Max Budget (USD)</Label>
<Input id="ec-budget" inputMode="decimal" value={budget} onChange={(e) => setBudget(e.target.value.replace(/[^0-9.]/g, ""))} placeholder="Leave empty for no budget" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="ec-tpm">TPM limit</Label>
<Input id="ec-tpm" inputMode="numeric" value={tpm} onChange={(e) => setTpm(e.target.value.replace(/[^0-9]/g, ""))} placeholder="No limit" disabled={!budgetId} />
</div>
<div className="space-y-1.5">
<Label htmlFor="ec-rpm">RPM limit</Label>
<Input id="ec-rpm" inputMode="numeric" value={rpm} onChange={(e) => setRpm(e.target.value.replace(/[^0-9]/g, ""))} placeholder="No limit" disabled={!budgetId} />
</div>
</div>
<p className="text-xs text-muted-foreground">{budgetId ? "Limits apply to this customer across every key it uses." : "Set a budget or limits when creating the customer to enable TPM/RPM here."}</p>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button type="submit" disabled={update.isPending}>Save</Button>
<Button type="submit" disabled={update.isPending || updateLimits.isPending}>Save</Button>
</DialogFooter>
</form>
</DialogContent>