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 }) => }
+
+