From 26ca42639393846e7b03d5e009f478697d3573b5 Mon Sep 17 00:00:00 2001 From: ishaan-j-berri Date: Sat, 31 Oct 2026 11:40:23 -0700 Subject: [PATCH] feat(ui): add team member budget windows (#49091) --- .../src/components/Teams.integration.test.tsx | 31 ++++++- ui/litellm-dashboard/src/components/Teams.tsx | 18 +++- .../user_search_modal.integration.test.tsx | 31 ++++++- .../common_components/user_search_modal.tsx | 32 ++++++- .../key_team_helpers/BudgetWindowsEditor.tsx | 6 +- .../src/components/networking.test.ts | 43 +++++++++ .../src/components/networking.tsx | 9 +- .../src/components/team/EditMembership.tsx | 16 +++- .../MemberBudgetWindows.integration.test.tsx | 88 +++++++++++++++++++ .../components/team/MemberBudgetWindows.tsx | 64 ++++++++++++++ .../team/TeamInfo.integration.test.tsx | 22 +++++ .../src/components/team/TeamInfo.tsx | 33 +++++++ .../components/team/TeamMemberTab.test.tsx | 31 +++++++ .../src/components/team/TeamMemberTab.tsx | 23 ++++- .../team/memberBudgetWindowSchema.test.ts | 36 ++++++++ .../team/memberBudgetWindowSchema.ts | 35 ++++++++ .../src/components/team/memberFormValues.ts | 17 +++- 17 files changed, 523 insertions(+), 12 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/team/MemberBudgetWindows.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/team/MemberBudgetWindows.tsx create mode 100644 ui/litellm-dashboard/src/components/team/memberBudgetWindowSchema.test.ts create mode 100644 ui/litellm-dashboard/src/components/team/memberBudgetWindowSchema.ts diff --git a/ui/litellm-dashboard/src/components/Teams.integration.test.tsx b/ui/litellm-dashboard/src/components/Teams.integration.test.tsx index a6801e3c996..93a6c2335d1 100644 --- a/ui/litellm-dashboard/src/components/Teams.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/Teams.integration.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders, testQueryClient } from "../../tests/test-utils"; @@ -47,6 +47,35 @@ afterEach(() => { }); describe("Teams creation integration", () => { + it("should preserve default member budget windows when additional settings are collapsed", async () => { + const user = userEvent.setup({ delay: null }); + renderWithProviders(); + await user.click(screen.getAllByRole("button", { name: /create team/i })[0]); + await screen.findByLabelText(/team name/i); + await user.click(screen.getByText("Additional Settings")); + await user.click(screen.getByRole("button", { name: /Add Budget Window/i })); + await act(async () => { + fireEvent.change(screen.getByRole("spinbutton", { name: "Window 1 budget (USD)" }), { target: { value: "20" } }); + }); + await user.click(screen.getByRole("button", { name: /Add Budget Window/i })); + await user.click(screen.getByRole("combobox", { name: "Window 2 duration" })); + await user.click(await screen.findByRole("option", { name: "Monthly" })); + await act(async () => { + fireEvent.change(screen.getByRole("spinbutton", { name: "Window 2 budget (USD)" }), { target: { value: "200" } }); + fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Budget Team" } }); + }); + await user.click(screen.getByText("Additional Settings")); + await user.click(screen.getAllByRole("button", { name: /create team/i }).at(-1)!); + await waitFor(() => + expect(vi.mocked(fetch).mock.calls.some(([input]) => String(input).includes("/team/new"))).toBe(true), + ); + const request = vi.mocked(fetch).mock.calls.find(([input]) => String(input).includes("/team/new")); + expect(JSON.parse(String(request?.[1]?.body)).team_member_budget_limits).toEqual([ + { budget_duration: "24h", max_budget: 20 }, + { budget_duration: "30d", max_budget: 200 }, + ]); + }); + it("sends a configured default member per-model budget", async () => { const user = userEvent.setup({ delay: null }); renderWithProviders(); diff --git a/ui/litellm-dashboard/src/components/Teams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx index 3a04340d3c4..f434479e440 100644 --- a/ui/litellm-dashboard/src/components/Teams.tsx +++ b/ui/litellm-dashboard/src/components/Teams.tsx @@ -1,3 +1,5 @@ +import { MemberBudgetWindows } from "./team/MemberBudgetWindows"; +import { memberBudgetWindowsSchema } from "./team/memberBudgetWindowSchema"; import { Page, PageTabs, PageTabsList, PageTabsTrigger } from "@/components/shared/Page"; import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import useCan from "@/app/(dashboard)/hooks/useCan"; @@ -86,6 +88,7 @@ const teamCreateFieldsSchema = z.object({ metadata: metadataPairsSchema.optional(), team_id: z.string().optional(), team_member_budget: z.number().optional(), + team_member_budget_limits: memberBudgetWindowsSchema.optional(), team_member_key_duration: z.string().optional(), team_member_rpm_limit: numericInputSchema, team_member_tpm_limit: numericInputSchema, @@ -124,6 +127,7 @@ const EMPTY_TEAM_CREATE_VALUES: TeamCreateFormValues = { metadata: [], team_id: undefined, team_member_budget: undefined, + team_member_budget_limits: [], team_member_key_duration: undefined, team_member_rpm_limit: undefined, team_member_tpm_limit: undefined, @@ -553,10 +557,15 @@ const Teams: React.FC = ({ accessToken, userID, userRole, isViewOnly } } - const { require_trace_id: requireTraceId, ...teamCreateValues } = formValues; + const { + require_trace_id: requireTraceId, + team_member_budget_limits: memberBudgetWindows, + ...teamCreateValues + } = formValues; const teamMemberModelBudgets = modelMaxBudgetUpdate(teamMemberModelMaxBudget, {}); await teamCreateCall(accessToken, { ...teamCreateValues, + ...(memberBudgetWindows?.length ? { team_member_budget_limits: memberBudgetWindows } : {}), ...(requireTraceId === undefined ? {} : { require_trace_id: requireTraceId }), ...(teamMemberModelBudgets !== undefined ? { team_member_model_max_budget: teamMemberModelBudgets } : {}), models: normalizeTeamModelSelection(formValues.models), @@ -895,6 +904,13 @@ const Teams: React.FC = ({ accessToken, userID, userRole, isViewOnly > {({ ref, value, ...field }) => } + + {({ value, onChange }) => } + { vi.mocked(userFilterUICall).mockResolvedValue([{ user_id: "u-1", user_email: "picked@example.com" }] as never); }); - const setup = () => { + const setup = (defaultBudgetWindows?: Array<{ budget_duration: string; max_budget: number }>) => { const user = userEvent.setup(); const onSubmit = vi.fn(); - render(); + render( + , + ); return { user, onSubmit }; }; @@ -130,6 +138,25 @@ describe("UserSearchModal submit payload", () => { }); }); + it("should submit custom windows and reset to team defaults for the next member", async () => { + const windows = [ + { budget_duration: "24h", max_budget: 20 }, + { budget_duration: "30d", max_budget: 200 }, + ]; + const { user, onSubmit } = setup(windows); + await user.click(screen.getByRole("radio", { name: "Custom budget windows" })); + await act(async () => { + fireEvent.change(screen.getByRole("spinbutton", { name: "Window 1 budget (USD)" }), { target: { value: "5" } }); + }); + await searchByEmail(user, "pick"); + await user.click(save()); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit.mock.calls[0][0].budget_limits).toEqual([{ ...windows[0], max_budget: 5 }, windows[1]]); + expect(screen.getByRole("radio", { name: "Use team defaults" })).toBeChecked(); + expect(screen.getByText("$20 daily, $200 monthly")).toBeInTheDocument(); + expect(save()).toBeDisabled(); + }); + it("carries a role changed off its default into the payload", async () => { const { onSubmit } = setup(); const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); diff --git a/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx b/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx index 04ac412397f..20157f00563 100644 --- a/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx +++ b/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx @@ -11,6 +11,9 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import type { BudgetWindowEntry } from "../key_team_helpers/BudgetWindowsEditor"; +import { MemberBudgetWindows } from "../team/MemberBudgetWindows"; +import { memberBudgetWindowsSchema } from "../team/memberBudgetWindowSchema"; interface User { user_id: string; @@ -31,12 +34,14 @@ interface Role { } interface FormValues { + budget_limits?: BudgetWindowEntry[]; user_email: string | null | undefined; user_id: string | null | undefined; role: string; } interface UserSearchModalProps { + defaultBudgetWindows?: BudgetWindowEntry[]; isVisible: boolean; onCancel: () => void; onSubmit: (values: FormValues) => void | Promise; @@ -63,8 +68,14 @@ const UserSearchModal: React.FC = ({ ], defaultRole = "user", teamId, + defaultBudgetWindows, }) => { - const emptyValues: FormValues = { user_email: undefined, user_id: undefined, role: defaultRole }; + const emptyValues: FormValues = { + user_email: undefined, + user_id: undefined, + role: defaultRole, + ...(defaultBudgetWindows !== undefined ? { budget_limits: [] } : {}), + }; const form = useForm({ defaultValues: emptyValues }); const selectedUserId = form.watch("user_id"); const selectedUserEmail = form.watch("user_email"); @@ -124,9 +135,17 @@ const UserSearchModal: React.FC = ({ }; const handleSubmit = async (values: FormValues): Promise => { + if (values.budget_limits !== undefined) { + const validation = memberBudgetWindowsSchema.safeParse(values.budget_limits); + if (!validation.success) { + form.setError("budget_limits", { type: "validate", message: validation.error.issues[0]?.message }); + return; + } + } setIsSubmitting(true); try { await onSubmit(values); + form.reset(emptyValues); } finally { setIsSubmitting(false); } @@ -235,6 +254,17 @@ const UserSearchModal: React.FC = ({ )} + {defaultBudgetWindows !== undefined && ( + + {({ value, onChange }) => ( + + )} + + )}
diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/BudgetWindowsEditor.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/BudgetWindowsEditor.tsx index 3f8b2484c7b..87aa05fde8d 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/BudgetWindowsEditor.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/BudgetWindowsEditor.tsx @@ -46,7 +46,7 @@ export function BudgetWindowsEditor({ value, onChange }: BudgetWindowsEditorProp value={window.budget_duration} onValueChange={(v: string | null) => v && updateWindow(idx, "budget_duration", v)} > - + @@ -62,6 +62,7 @@ export function BudgetWindowsEditor({ value, onChange }: BudgetWindowsEditorProp $