diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.test.tsx index 2571eb344f5..f4a80de7d5f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.test.tsx @@ -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(); + + 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(); + + 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(); + + 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([]); + }); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx index b7a3486c78e..35dfc235a1b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx @@ -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; + 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 && ( + + {({ value, onChange }) => } + + )} {!isBulkEdit && ( { 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", () => { diff --git a/ui/litellm-dashboard/src/components/Teams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx index 7214d16f665..e254256ea10 100644 --- a/ui/litellm-dashboard/src/components/Teams.tsx +++ b/ui/litellm-dashboard/src/components/Teams.tsx @@ -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; @@ -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 = ({ 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 = ({ accessToken, userID, userRole, premiumUser /> )} + + {({ value, onChange }) => } + { }); }); + 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) => { + 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; + }; + + 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(); + + 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(); + + 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(); + + 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 }); diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index df7b06661c2..437200fc0da 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -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 | 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 = ({ ...(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 = ({ )} + + {({ value, onChange }) => } + +