diff --git a/web/src/app/admin/token-rate-limits/CreateRateLimitModal.test.tsx b/web/src/app/admin/token-rate-limits/CreateRateLimitModal.test.tsx index 8310215ec3..122096a0f8 100644 --- a/web/src/app/admin/token-rate-limits/CreateRateLimitModal.test.tsx +++ b/web/src/app/admin/token-rate-limits/CreateRateLimitModal.test.tsx @@ -1,19 +1,99 @@ -import { render, screen, setupUser } from "@tests/setup/test-utils"; +import { fireEvent, render, screen, waitFor } from "@tests/setup/test-utils"; import CreateRateLimitModal from "@/app/admin/token-rate-limits/CreateRateLimitModal"; -test("requires at least one enforcement budget", async () => { - const user = setupUser(); +beforeEach(() => { + global.fetch = jest + .fn() + .mockResolvedValue({ ok: true, json: async () => [] } as Response); +}); + +test("requires a cost budget when no token budget is set", async () => { const onSubmit = jest.fn().mockResolvedValue(undefined); render( ); - await user.type(screen.getByLabelText("Time Window (UTC Days)"), "1"); - await user.click(screen.getByRole("button", { name: "Create" })); + const modal = screen.getByRole("dialog"); + const resetPeriod = screen.getByRole("textbox", { name: /Reset period/ }); + + expect(modal).toHaveAttribute("data-width", "sm"); + expect(modal).toHaveAttribute("data-height", "fit"); + expect(resetPeriod).toHaveAttribute("type", "text"); + expect(resetPeriod).toHaveAttribute("inputmode", "numeric"); + expect(resetPeriod).toHaveValue("30"); + + fireEvent.click(screen.getByRole("button", { name: "Create limit" })); expect( - await screen.findByText("Set a token budget and/or a cost budget") + await screen.findByText("Enter a cost budget, a token budget, or both") ).toBeInTheDocument(); expect(onSubmit).not.toHaveBeenCalled(); }); + +test("formats and submits a token-only budget", async () => { + const onSubmit = jest.fn().mockResolvedValue(undefined); + + render( + + ); + + const tokenBudget = screen.getByRole("textbox", { + name: /Token budget/, + }); + + fireEvent.change(tokenBudget, { + target: { value: "1500000000" }, + }); + + expect(tokenBudget).toHaveValue("1,500,000,000"); + + fireEvent.click(screen.getByRole("button", { name: "Create limit" })); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + "global", + 720, + 1_500_000, + null, + undefined + ) + ); +}); + +test("rejects a cost budget that rounds below one cent", async () => { + const onSubmit = jest.fn().mockResolvedValue(undefined); + + render( + + ); + + fireEvent.change(screen.getByRole("textbox", { name: /Cost budget/ }), { + target: { value: "0.001" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Create limit" })); + + expect( + await screen.findByText("Cost budget must be at least $0.01") + ).toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); +}); + +test("supports arrow-key navigation between scope options", async () => { + render( + + ); + + const workspace = screen.getByRole("radio", { name: "Workspace" }); + const perUser = screen.getByRole("radio", { name: "Per user" }); + + workspace.focus(); + fireEvent.keyDown(workspace, { key: "ArrowRight" }); + + await waitFor(() => expect(perUser).toHaveAttribute("aria-checked", "true")); + expect(perUser).toHaveFocus(); +}); diff --git a/web/src/app/admin/token-rate-limits/CreateRateLimitModal.tsx b/web/src/app/admin/token-rate-limits/CreateRateLimitModal.tsx index 9e48483818..f0276ab854 100644 --- a/web/src/app/admin/token-rate-limits/CreateRateLimitModal.tsx +++ b/web/src/app/admin/token-rate-limits/CreateRateLimitModal.tsx @@ -1,17 +1,347 @@ "use client"; import * as Yup from "yup"; -import { Button } from "@opal/components"; -import { useEffect, useState } from "react"; -import { Modal } from "@opal/components"; -import { Form, Formik } from "formik"; -import { SelectorFormField, TextFormField } from "@/components/Field"; -import { UserGroup } from "@/lib/types"; +import { + Button, + InputTypeIn, + LineItemButton, + Modal, + Popover, + SelectCard, + Text, +} from "@opal/components"; +import React, { useEffect, useState } from "react"; +import { Form, Formik, useField, useFormikContext } from "formik"; import { Scope } from "./types"; -import { toast } from "@opal/layouts"; -import { SvgSettings } from "@opal/icons"; +import { + ContentAction, + InputErrorText, + InputVertical, + Section, + toast, +} from "@opal/layouts"; +import { SvgCheck, SvgGlobe, SvgUser, SvgUsers } from "@opal/icons"; +import type { IconFunctionComponent } from "@opal/types"; +import InputTypeInField from "@/refresh-components/form/InputTypeInField"; const HOURS_PER_DAY = 24; +// 1T tokens stored as thousands (1e9) stays well inside the column's int32. +const MAX_TOKEN_BUDGET = 1_000_000_000_000; +// Keeps *100 finite: an unbounded value can overflow to Infinity, which JSON.stringify serializes as null. +const MAX_COST_BUDGET_DOLLARS = 10_000_000_000; + +interface RateLimitFormValues { + enabled: boolean; + period_days: string; + token_budget: string; + cost_budget_dollars: string; + target_scope: Scope; + user_group_id: number | undefined; +} + +interface ScopeOptionConfig { + value: Scope; + icon: IconFunctionComponent; + title: string; +} + +const SCOPE_OPTIONS: ScopeOptionConfig[] = [ + { value: Scope.GLOBAL, icon: SvgGlobe, title: "Workspace" }, + { value: Scope.USER, icon: SvgUser, title: "Per user" }, + { value: Scope.USER_GROUP, icon: SvgUsers, title: "User group" }, +]; + +interface ScopeOptionProps extends React.HTMLAttributes { + option: ScopeOptionConfig; + selected: boolean; + onSelect: () => void; + ref?: React.Ref; +} + +function handleRadioOptionKeyDown( + event: React.KeyboardEvent, + onSelect: () => void +): void { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelect(); + return; + } + if ( + event.key !== "ArrowRight" && + event.key !== "ArrowDown" && + event.key !== "ArrowLeft" && + event.key !== "ArrowUp" && + event.key !== "Home" && + event.key !== "End" + ) { + return; + } + event.preventDefault(); + const group = event.currentTarget.closest('[role="radiogroup"]'); + const options = Array.from( + group?.querySelectorAll('[role="radio"]') ?? [] + ); + const currentIndex = options.indexOf(event.currentTarget); + const nextIndex = + event.key === "Home" + ? 0 + : event.key === "End" + ? options.length - 1 + : event.key === "ArrowRight" || event.key === "ArrowDown" + ? (currentIndex + 1) % options.length + : (currentIndex - 1 + options.length) % options.length; + options[nextIndex]?.focus(); + options[nextIndex]?.click(); +} + +function ScopeOption({ + option, + selected, + onSelect, + ref, + ...rest +}: ScopeOptionProps) { + return ( + { + rest.onClick?.(event); + onSelect(); + }} + onKeyDown={(event) => { + rest.onKeyDown?.(event); + handleRadioOptionKeyDown(event, onSelect); + }} + > + + ) : undefined + } + /> + + ); +} + +interface GroupScopeOptionProps { + option: ScopeOptionConfig; + selected: boolean; + groups: { name: string; value: number }[]; + selectedGroupId: number | undefined; + onSelectScope: () => void; + onSelectGroup: (groupId: number) => void; +} + +interface GroupMenuContentProps { + groups: { name: string; value: number }[]; + selectedGroupId: number | undefined; + onSelectGroup: (groupId: number) => void; +} + +function GroupMenuContent({ + groups, + selectedGroupId, + onSelectGroup, +}: GroupMenuContentProps) { + return ( + + + {groups.length === 0 + ? [ +
+ + No user groups yet. Create one under Users & Groups. + +
, + ] + : groups.map((group) => ( + + onSelectGroup(group.value)} + rounding="md" + selectVariant="select-heavy" + sizePreset="main-ui" + state={ + String(group.value) === String(selectedGroupId) + ? "selected" + : "empty" + } + title={group.name} + variant="section" + width="full" + /> + + ))} +
+
+ ); +} + +function GroupScopeOption({ + option, + selected, + groups, + selectedGroupId, + onSelectScope, + onSelectGroup, +}: GroupScopeOptionProps) { + return ( + + + + + + + ); +} + +function TokenBudgetField() { + const [field, meta, helpers] = useField("token_budget"); + const digits = String(field.value ?? "") + .replace(/\D/g, "") + .slice(0, 15); + const formattedValue = digits + ? new Intl.NumberFormat("en-US").format(Number(digits)) + : ""; + + return ( + { + const nextDigits = event.target.value.replace(/\D/g, "").slice(0, 15); + void helpers.setValue(nextDigits); + }} + onBlur={() => void helpers.setTouched(true)} + variant={meta.touched && meta.error ? "error" : "primary"} + rightChildren={ +
+ + tokens + +
+ } + /> + ); +} + +function formatDollarBudgetInput(raw: string): string | null { + const amount = Number(raw); + if (raw === "" || !Number.isFinite(amount) || amount <= 0) return null; + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(amount); +} + +function formatTokenBudgetInput(raw: string): string | null { + const amount = Number(raw); + if (raw === "" || !Number.isFinite(amount) || amount <= 0) return null; + return `${new Intl.NumberFormat("en-US").format(amount)} tokens`; +} + +interface GroupPickerProps { + groups: { name: string; value: number }[]; + selectedGroupId: number | undefined; + onSelectGroup: (groupId: number) => void; +} + +function GroupPicker({ + groups, + selectedGroupId, + onSelectGroup, +}: GroupPickerProps) { + const selectedGroup = groups.find( + (group) => String(group.value) === String(selectedGroupId) + ); + + return ( + + + + + + + ); +} + +interface LimitSummaryProps { + groupName?: string; +} + +function LimitSummary({ groupName }: LimitSummaryProps) { + const { values } = useFormikContext(); + + const period = Number(values.period_days); + if (!Number.isInteger(period) || period < 1) return null; + + const budgets = [ + formatDollarBudgetInput(values.cost_budget_dollars), + formatTokenBudgetInput(values.token_budget), + ].filter((budget): budget is string => budget !== null); + if (budgets.length === 0) return null; + + const budgetText = budgets.join(" or "); + const periodText = period === 1 ? "every day" : `every ${period} days`; + + const subjectText = + values.target_scope === Scope.GLOBAL + ? "Everyone in the workspace shares" + : values.target_scope === Scope.USER + ? "Each user can spend" + : groupName + ? `Members of ${groupName} share` + : "Group members share"; + + const summary = `${subjectText} up to ${budgetText} ${periodText}. Usage is blocked until the period resets.`; + + return ( +
+ + {summary} + +
+ ); +} interface CreateRateLimitModalProps { isOpen: boolean; @@ -21,7 +351,7 @@ interface CreateRateLimitModalProps { period_hours: number, token_budget: number | null, cost_budget_cents: number | null, - group_id: number + group_id?: number ) => Promise; forSpecificScope?: Scope; forSpecificUserGroup?: number; @@ -34,44 +364,55 @@ export default function CreateRateLimitModal({ forSpecificScope, forSpecificUserGroup, }: CreateRateLimitModalProps) { - const [modalUserGroups, setModalUserGroups] = useState([]); - const [shouldFetchUserGroups, setShouldFetchUserGroups] = useState( - forSpecificScope === Scope.USER_GROUP - ); + const [modalUserGroups, setModalUserGroups] = useState< + { name: string; value: number }[] + >([]); + const groupScopeAvailable = + forSpecificScope === undefined || forSpecificScope === Scope.USER_GROUP; useEffect(() => { + if (!isOpen || !groupScopeAvailable || forSpecificUserGroup !== undefined) { + return; + } + let stale = false; const fetchData = async () => { try { - const response = await fetch("/api/manage/admin/user-group"); - const data = await response.json(); - const options = data.map((userGroup: UserGroup) => ({ + const response = await fetch("/api/manage/user-groups/minimal"); + if (!response.ok) { + throw new Error(response.statusText || "Request failed"); + } + const data = (await response.json()) as Array<{ + id: number; + name: string; + }>; + if (stale) return; + const options = data.map((userGroup) => ({ name: userGroup.name, value: userGroup.id, })); setModalUserGroups(options); - setShouldFetchUserGroups(false); } catch (error) { - toast.error(`Failed to fetch user groups: ${error}`); + console.error("Failed to fetch user groups:", error); + if (!stale) toast.error(`Failed to fetch user groups: ${error}`); } }; - - if (shouldFetchUserGroups) { - fetchData(); - } - }, [shouldFetchUserGroups]); + fetchData(); + return () => { + stale = true; + }; + }, [isOpen, groupScopeAvailable, forSpecificUserGroup]); return ( - setIsOpen(false)}> - + + setIsOpen(false)} /> - initialValues={{ enabled: true, - period_days: "", + period_days: "30", token_budget: "", cost_budget_dollars: "", target_scope: forSpecificScope || Scope.GLOBAL, @@ -79,36 +420,51 @@ export default function CreateRateLimitModal({ }} validationSchema={Yup.object().shape({ period_days: Yup.number() - .required("Time Window is a required field") - .integer("Time Window must be a whole number of days") - .min(1, "Time Window must be at least 1 day"), - token_budget: Yup.number() - // Empty (no token budget) is allowed — a cost-only limit. Without - // this, "" coerces to NaN and trips .min(1) even when only a cost - // budget is set. Mirrors the cost_budget_dollars transform below. - .transform((value, original) => - original === "" ? undefined : value - ) - .min(1, "Token Budget must be at least 1") - .test( - "budget-required", - "Set a token budget and/or a cost budget", - (value, context) => - value != null || context.parent.cost_budget_dollars != null - ), + .required("Enter a reset period") + .integer("Enter a whole number of days") + .min(1, "Use at least 1 day"), cost_budget_dollars: Yup.number() - // Empty (no cost budget) is allowed; a 0 would make the gate fire - // on the first request (cost_since >= 0 is always true). .transform((value, original) => original === "" ? undefined : value ) - .moreThan(0, "Cost Budget must be greater than 0"), + .moreThan(0, "Cost budget must be greater than 0") + .max( + MAX_COST_BUDGET_DOLLARS, + `The maximum cost budget is $${new Intl.NumberFormat("en-US").format(MAX_COST_BUDGET_DOLLARS)}` + ) + .test( + "minimum-cents", + "Cost budget must be at least $0.01", + (value) => value == null || Math.round(value * 100) > 0 + ) + .when("token_budget", { + is: (value: string | undefined) => + value === undefined || value === "", + then: (schema) => + schema.required( + "Enter a cost budget, a token budget, or both" + ), + otherwise: (schema) => schema.notRequired(), + }), + token_budget: Yup.number() + .transform((value, original) => + original === "" ? undefined : value + ) + .notRequired() + .integer("Enter a whole number of tokens") + .min(1_000, "Use at least 1,000 tokens") + .max(MAX_TOKEN_BUDGET, "The maximum token budget is 1 trillion") + .test( + "whole-thousands", + "Use increments of 1,000 tokens", + (value) => value == null || value % 1_000 === 0 + ), target_scope: Yup.string().required( "Target Scope is a required field" ), user_group_id: Yup.string().test( "user_group_id", - "User Group is a required field", + "Select a user group", (value, context) => { return ( context.parent.target_scope !== "user_group" || @@ -119,10 +475,10 @@ export default function CreateRateLimitModal({ ), })} onSubmit={async (values) => { - // Empty token field → null (cost-only); the gate skips a null budget. - // Sending 0 would mean "0-token limit" and block every request. const tokenBudget = - values.token_budget === "" ? null : Number(values.token_budget); + values.token_budget === "" + ? null + : Number(values.token_budget) / 1_000; const costBudgetCents = values.cost_budget_dollars === "" ? null @@ -132,66 +488,176 @@ export default function CreateRateLimitModal({ Number(values.period_days) * HOURS_PER_DAY, tokenBudget, costBudgetCents, - Number(values.user_group_id) + values.target_scope === Scope.USER_GROUP + ? Number(values.user_group_id) + : undefined ); }} > - {({ isSubmitting, values, setFieldValue }) => ( -
- - {!forSpecificScope && ( - { - setFieldValue("target_scope", selected); - if (selected === Scope.USER_GROUP) { - setShouldFetchUserGroups(true); - } - }} - /> - )} - {forSpecificUserGroup === undefined && - values.target_scope === Scope.USER_GROUP && ( - - )} - - - - - - - -
- )} + {({ isSubmitting, values, setFieldValue, errors, touched }) => { + const selectedGroupName = modalUserGroups.find( + (group) => String(group.value) === String(values.user_group_id) + )?.name; + const scopeCaption = + values.target_scope === Scope.GLOBAL + ? "Everyone in the workspace shares one budget." + : values.target_scope === Scope.USER + ? "Every user gets their own budget." + : selectedGroupName + ? `Members of ${selectedGroupName} share one budget.` + : "Members of the chosen group share one budget."; + + return ( +
+ +
+ {!forSpecificScope && ( + +
+ {SCOPE_OPTIONS.map((option) => + option.value === Scope.USER_GROUP && + forSpecificUserGroup === undefined ? ( + + void setFieldValue( + "target_scope", + option.value + ) + } + onSelectGroup={(groupId) => + void setFieldValue("user_group_id", groupId) + } + /> + ) : ( + + void setFieldValue( + "target_scope", + option.value + ) + } + /> + ) + )} +
+ {touched.user_group_id && errors.user_group_id && ( + + {errors.user_group_id} + + )} +
+ )} + + {forSpecificScope === Scope.USER_GROUP && + forSpecificUserGroup === undefined && ( + + + void setFieldValue("user_group_id", groupId) + } + /> + {touched.user_group_id && errors.user_group_id && ( + + {errors.user_group_id} + + )} + + )} + + + + + USD + + + } + /> + + + + + + + + + + days + + + } + /> + + + +
+
+ + + + +
+ ); + }}