feat(ui): budget windows editor for teams and users

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
jesus 2026-09-18 01:01:34 +00:00
parent 432e24a11f
commit 7022288b48
8 changed files with 240 additions and 1 deletions

View file

@ -789,4 +789,54 @@ describe("UserEditView", () => {
expect(onSubmit.mock.calls[0][0].metadata).toBe("");
});
});
describe("budget windows", () => {
const userDataWithWindows = {
...MOCK_USER_DATA,
user_info: {
...MOCK_USER_DATA.user_info,
budget_limits: [{ budget_duration: "1d", max_budget: 10, reset_at: "2026-10-01T00:00:00Z" }],
},
};
it("should seed the editor from the stored windows and omit budget_limits from an untouched save", async () => {
const onSubmit = vi.fn();
renderWithProviders(<UserEditView {...defaultProps} userData={userDataWithWindows} onSubmit={onSubmit} />);
expect(await screen.findByPlaceholderText("Max spend ($)")).toHaveValue(10);
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalled();
});
expect(onSubmit.mock.calls[0][0]).not.toHaveProperty("budget_limits");
});
it("should send the edited window as budget_limits", async () => {
const onSubmit = vi.fn();
renderWithProviders(<UserEditView {...defaultProps} userData={userDataWithWindows} onSubmit={onSubmit} />);
fireEvent.change(await screen.findByPlaceholderText("Max spend ($)"), { target: { value: "42" } });
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalled();
});
expect(onSubmit.mock.calls[0][0].budget_limits).toEqual([{ budget_duration: "1d", max_budget: 42 }]);
});
it("should send an empty budget_limits when the last window is removed, so stored windows are cleared", async () => {
const onSubmit = vi.fn();
renderWithProviders(<UserEditView {...defaultProps} userData={userDataWithWindows} onSubmit={onSubmit} />);
await userEvent.click(await screen.findByRole("button", { name: "✕" }));
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalled();
});
expect(onSubmit.mock.calls[0][0].budget_limits).toEqual([]);
});
});
});

View file

@ -2,6 +2,7 @@ import React, { useMemo, useState } from "react";
import { z } from "zod/v4";
import { all_admin_roles } from "@/utils/roles";
import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown";
import { BudgetWindowsEditor } from "@/components/key_team_helpers/BudgetWindowsEditor";
import { ModelMaxBudget, ModelMaxBudgetField } from "@/components/key_team_helpers/ModelMaxBudgetEditor";
import { modelMaxBudgetUpdate } from "@/components/key_team_helpers/modelMaxBudgetPayload";
import { useSeededState } from "@/components/key_team_helpers/useSeededState";
@ -52,6 +53,7 @@ const userEditShape = {
user_role: z.string().nullish(),
models: z.array(z.string()),
budget_duration: z.string().nullish(),
budget_limits: z.array(z.object({ budget_duration: z.string(), max_budget: z.number().nullable() })).optional(),
metadata: z.string().nullish(),
mcp_servers_and_groups: MCP_SELECTION_SHAPE.optional(),
mcp_tool_permissions: z.record(z.string(), z.array(z.string())).optional(),
@ -98,6 +100,16 @@ const toFormValues = (
models: userData.user_info?.models || [],
max_budget: isUnlimited ? "" : maxBudget,
budget_duration: userData.user_info?.budget_duration,
...(!isBulkEdit
? {
budget_limits: (userData.user_info?.budget_limits ?? []).map(
(window: { budget_duration: string; max_budget: number | null }) => ({
budget_duration: window.budget_duration,
max_budget: window.max_budget,
}),
),
}
: {}),
metadata: userData.user_info?.metadata ? JSON.stringify(userData.user_info.metadata, null, 2) : undefined,
...(canEditMcpPermissions ? buildMcpFieldValues(objectPermission) : {}),
};
@ -105,6 +117,15 @@ const toFormValues = (
type ParsedMetadata = { ok: true; value: unknown } | { ok: false };
const budgetWindowSignature = (
windows: Array<{ budget_duration: string; max_budget: number | null }> | null | undefined,
) =>
(windows ?? [])
.filter((w) => w.budget_duration && w.max_budget !== null && w.max_budget !== undefined)
.map((w) => `${w.budget_duration}:${w.max_budget}`)
.sort()
.join("|");
const parseMetadata = (metadata: string | null | undefined): ParsedMetadata => {
if (!metadata) {
return { ok: true, value: metadata };
@ -172,8 +193,20 @@ export function UserEditView({
}
const modelBudgets = modelMaxBudgetUpdate(modelMaxBudget, userData.user_info?.model_max_budget);
const validWindows = (values.budget_limits ?? []).filter(
(w) => w.budget_duration && w.max_budget !== null && w.max_budget !== undefined,
);
const budgetLimitsChanged =
"budget_limits" in values &&
budgetWindowSignature(userData.user_info?.budget_limits) !== budgetWindowSignature(validWindows);
const submitted = { ...values } as Record<string, unknown>;
if (!budgetLimitsChanged) {
delete submitted.budget_limits;
} else {
submitted.budget_limits = validWindows;
}
onSubmit({
...values,
...submitted,
...("metadata" in values ? { metadata: metadata.value } : {}),
...(modelBudgets !== undefined && { model_max_budget: modelBudgets }),
max_budget:
@ -295,6 +328,16 @@ export function UserEditView({
{/* Bulk edit forwards a fixed field list and has no single stored budget to
diff against, so the editor would silently discard whatever was typed. */}
{!isBulkEdit && (
<FormField
control={form.control}
name="budget_limits"
label="Budget Windows"
description="Concurrent spend caps per time window for this user. Each window resets on its own schedule."
>
{({ value, onChange }) => <BudgetWindowsEditor value={value ?? []} onChange={onChange} />}
</FormField>
)}
{!isBulkEdit && (
<ModelMaxBudgetField
key={userData.user_id}

View file

@ -337,6 +337,7 @@ export default function UserInfoView({
formValues.budget_duration === undefined ? userData.budget_duration : formValues.budget_duration,
metadata: formValues.metadata ?? userData.metadata,
model_max_budget: formValues.model_max_budget ?? userData.model_max_budget,
budget_limits: "budget_limits" in formValues ? formValues.budget_limits : userData.budget_limits,
object_permission: mcpEntitlement
? { ...userData.object_permission, ...mcpEntitlement }
: userData.object_permission,
@ -399,6 +400,7 @@ export default function UserInfoView({
// replaces the user's existing budgets with whatever was typed.
model_max_budget: userData.model_max_budget,
model_max_budget_usage: userData.model_max_budget_usage,
budget_limits: userData.budget_limits,
},
};

View file

@ -777,6 +777,25 @@ describe("Teams - Reset Budget in team create", () => {
expect(screen.getByText("n/a")).toBeInTheDocument();
});
});
it("should send a filled budget window as budget_limits", async () => {
await openCreateModal();
await userEvent.click(screen.getByRole("button", { name: /add budget window/i }));
fireEvent.change(screen.getByPlaceholderText("Max spend ($)"), { target: { value: "25" } });
const payload = await submitCreateModal();
expect(payload.budget_limits).toEqual([{ budget_duration: "24h", max_budget: 25 }]);
});
it("should omit budget_limits when no window is filled in", async () => {
await openCreateModal();
const payload = await submitCreateModal();
expect(payload).not.toHaveProperty("budget_limits");
});
});
describe("Teams - metadata key-value pairs in team create", () => {

View file

@ -38,6 +38,7 @@ import PremiumLoggingSettings from "./common_components/PremiumLoggingSettings";
import RouterSettingsAccordion, { RouterSettingsAccordionValue } from "./common_components/RouterSettingsAccordion";
import { fetchAvailableModelsForTeamOrKey } from "./key_team_helpers/fetch_available_models_team_key";
import type { Team } from "./key_team_helpers/key_list";
import { BudgetWindowsEditor } from "./key_team_helpers/BudgetWindowsEditor";
import MCPServerSelector from "./mcp_server_management/MCPServerSelector";
import MCPToolPermissions from "./mcp_server_management/MCPToolPermissions";
import { toast } from "@/lib/toast";
@ -103,6 +104,7 @@ const teamCreateFieldsSchema = z.object({
allowed_agents_and_groups: z.object({ agents: z.array(z.string()), accessGroups: z.array(z.string()) }).optional(),
object_permission_search_tools: z.array(z.string()).optional(),
object_permission_skills: z.array(z.string()).optional(),
budget_limits: z.array(z.object({ budget_duration: z.string(), max_budget: z.number().nullable() })).optional(),
});
type TeamCreateFormValues = z.infer<typeof teamCreateFieldsSchema>;
@ -134,6 +136,7 @@ const EMPTY_TEAM_CREATE_VALUES: TeamCreateFormValues = {
allowed_agents_and_groups: undefined,
object_permission_search_tools: undefined,
object_permission_skills: undefined,
budget_limits: undefined,
};
const ADDITIONAL_SETTINGS_FIELDS = [
@ -532,6 +535,15 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
formValues.model_max_budget = modelMaxBudget;
}
const validWindows = (formValues.budget_limits ?? []).filter(
(window) => window.budget_duration && window.max_budget !== null && window.max_budget !== undefined,
);
if (validWindows.length > 0) {
formValues.budget_limits = validWindows;
} else {
delete formValues.budget_limits;
}
// Add router_settings if any are defined
if (routerSettings?.router_settings) {
// Only include router_settings if it has at least one non-null value
@ -820,6 +832,15 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
/>
)}
</FormField>
<FormField
control={form.control}
name="budget_limits"
className="mt-6"
label="Budget Windows"
description="Concurrent spend caps per time window for this team. Each window resets on its own schedule."
>
{({ value, onChange }) => <BudgetWindowsEditor value={value ?? []} onChange={onChange} />}
</FormField>
<ModelMaxBudgetField
key={`model-max-budget-${routerSettingsKey}`}
premiumUser={premiumUser}

View file

@ -1702,6 +1702,72 @@ describe("TeamInfoView", () => {
});
});
describe("budget windows", () => {
const teamWithWindows = () =>
createMockTeamData({
budget_limits: [{ budget_duration: "1d", max_budget: 10, reset_at: "2026-10-01T00:00:00Z" }],
});
const openWindowsEditor = async (user: ReturnType<typeof userEvent.setup>) => {
await waitFor(() => {
expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0);
});
await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
await screen.findByLabelText("Team Name");
};
const savedPayload = async () => {
await waitFor(() => {
expect(networking.teamUpdateCall).toHaveBeenCalled();
});
return vi.mocked(networking.teamUpdateCall).mock.calls[0][1] as Record<string, unknown>;
};
it("seeds the editor from the stored windows and leaves budget_limits out of an untouched save", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(teamWithWindows());
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
renderWithProviders(<TeamInfoView {...defaultProps} />);
await openWindowsEditor(user);
expect(screen.getByPlaceholderText("Max spend ($)")).toHaveValue(10);
await user.click(screen.getByRole("button", { name: /save changes/i }));
expect(await savedPayload()).not.toHaveProperty("budget_limits");
});
it("sends the edited window as budget_limits", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(teamWithWindows());
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
renderWithProviders(<TeamInfoView {...defaultProps} />);
await openWindowsEditor(user);
fireEvent.change(screen.getByPlaceholderText("Max spend ($)"), { target: { value: "42" } });
await user.click(screen.getByRole("button", { name: /save changes/i }));
expect((await savedPayload()).budget_limits).toEqual([{ budget_duration: "1d", max_budget: 42 }]);
});
it("sends an empty budget_limits when the last window is removed, so stored windows are cleared", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(teamWithWindows());
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
renderWithProviders(<TeamInfoView {...defaultProps} />);
await openWindowsEditor(user);
await user.click(screen.getByRole("button", { name: "✕" }));
await user.click(screen.getByRole("button", { name: /save changes/i }));
expect((await savedPayload()).budget_limits).toEqual([]);
});
});
describe("team member settings", () => {
it("should populate Default Key Duration from the team's stored metadata", async () => {
const user = userEvent.setup({ delay: null });

View file

@ -59,6 +59,7 @@ import TeamAdminSettingsForm from "./TeamAdminSettingsForm";
import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils";
import AccessGroupSelector from "../common_components/AccessGroupSelector";
import BudgetDurationDropdown, { NEVER_RESETS_BUDGET_DURATION } from "../common_components/budget_duration_dropdown";
import { BudgetWindowsEditor } from "../key_team_helpers/BudgetWindowsEditor";
import {
ModelBudgetUsage,
ModelMaxBudget,
@ -283,6 +284,7 @@ export interface TeamData {
max_budget: number | null;
soft_budget?: number | null;
budget_duration: string | null;
budget_limits?: Array<{ budget_duration: string; max_budget: number; reset_at?: string }> | null;
model_max_budget?: StoredModelMaxBudget | null;
model_max_budget_usage?: Record<string, ModelBudgetUsage> | null;
models: string[];
@ -346,6 +348,7 @@ const teamUpdateFieldsSchema = z.object({
team_member_tpm_limit: numericInputSchema,
team_member_rpm_limit: numericInputSchema,
budget_duration: z.string().nullish(),
budget_limits: z.array(z.object({ budget_duration: z.string(), max_budget: z.number().nullable() })).optional(),
tpm_limit: numericInputSchema,
rpm_limit: numericInputSchema,
tpd_limit: numericInputSchema,
@ -428,6 +431,7 @@ const EMPTY_TEAM_UPDATE_VALUES: TeamUpdateFormValues = {
team_member_tpm_limit: undefined,
team_member_rpm_limit: undefined,
budget_duration: undefined,
budget_limits: undefined,
tpm_limit: undefined,
rpm_limit: undefined,
tpd_limit: undefined,
@ -478,6 +482,10 @@ const toTeamFormValues = (info: TeamInfoRecord, effectiveGuardrails: string[]):
team_member_tpm_limit: info.team_member_budget_table?.tpm_limit,
team_member_rpm_limit: info.team_member_budget_table?.rpm_limit,
budget_duration: info.budget_duration,
budget_limits: (info.budget_limits ?? []).map((window) => ({
budget_duration: window.budget_duration,
max_budget: window.max_budget,
})),
tpm_limit: info.tpm_limit,
rpm_limit: info.rpm_limit,
tpd_limit: info.tpd_limit,
@ -985,6 +993,21 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
...(values.organization_id !== info.organization_id ? { organization_id: values.organization_id ?? null } : {}),
};
const windowSignature = (
windows: Array<{ budget_duration: string; max_budget: number | null }> | null | undefined,
) =>
(windows ?? [])
.filter((w) => w.budget_duration && w.max_budget !== null && w.max_budget !== undefined)
.map((w) => `${w.budget_duration}:${w.max_budget}`)
.sort()
.join("|");
const validWindows = (values.budget_limits ?? []).filter(
(w) => w.budget_duration && w.max_budget !== null && w.max_budget !== undefined,
);
if (windowSignature(info.budget_limits) !== windowSignature(validWindows)) {
updateData.budget_limits = validWindows;
}
updateData.max_budget = mapEmptyStringToNull(updateData.max_budget);
updateData.team_member_budget_duration = values.team_member_budget_duration;
@ -1577,6 +1600,15 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
)}
</FormField>
<FormField
control={form.control}
name="budget_limits"
label="Budget Windows"
description="Concurrent spend caps per time window for this team. Each window resets on its own schedule."
>
{({ value, onChange }) => <BudgetWindowsEditor value={value ?? []} onChange={onChange} />}
</FormField>
<ModelMaxBudgetField
premiumUser={premiumUser}
value={teamModelMaxBudget}

View file

@ -31076,6 +31076,8 @@ export interface components {
allowed_cache_controls: string[];
/** Budget Duration */
budget_duration?: string | null;
/** Budget Limits */
budget_limits?: components["schemas"]["BudgetLimitEntry"][] | null;
/** Budget Reset At */
budget_reset_at?: string | null;
/** Created At */
@ -31164,6 +31166,8 @@ export interface components {
allowed_cache_controls: string[];
/** Budget Duration */
budget_duration?: string | null;
/** Budget Limits */
budget_limits?: components["schemas"]["BudgetLimitEntry"][] | null;
/** Budget Reset At */
budget_reset_at?: string | null;
/** Created At */
@ -40305,6 +40309,8 @@ export interface components {
updated_by?: string | null;
/** User */
user?: unknown | null;
/** User Budget Limits */
user_budget_limits?: components["schemas"]["BudgetLimitEntry"][] | null;
/** User Email */
user_email?: string | null;
/** User Id */