diff --git a/ui/litellm-dashboard/src/components/team/MemberBudgetWindows.integration.test.tsx b/ui/litellm-dashboard/src/components/team/MemberBudgetWindows.integration.test.tsx
new file mode 100644
index 00000000000..3582894f16b
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/team/MemberBudgetWindows.integration.test.tsx
@@ -0,0 +1,88 @@
+import { act, fireEvent, screen, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { renderWithProviders } from "../../../tests/test-utils";
+import MemberModal from "./EditMembership";
+
+const windows = [
+ { budget_duration: "24h", max_budget: 20 },
+ { budget_duration: "30d", max_budget: 200 },
+];
+
+function renderMember(budget_limits = windows, defaultBudgetWindows = windows) {
+ const onSubmit = vi.fn();
+ renderWithProviders(
+
,
+ );
+ return onSubmit;
+}
+
+describe("member budget windows form", () => {
+ it("should render both configured windows", () => {
+ renderMember();
+ expect(screen.getByRole("spinbutton", { name: "Window 1 budget (USD)" })).toHaveValue(20);
+ expect(screen.getByRole("spinbutton", { name: "Window 2 budget (USD)" })).toHaveValue(200);
+ expect(screen.getByRole("radio", { name: "Custom budget windows" })).toBeChecked();
+ });
+
+ it("should inherit team defaults, then save a custom zero-dollar cap without changing the single budget", async () => {
+ const onSubmit = renderMember([]);
+ expect(screen.getByText("$20 daily, $200 monthly")).toBeInTheDocument();
+ expect(screen.queryByRole("spinbutton", { name: "Window 1 budget (USD)" })).not.toBeInTheDocument();
+ await act(async () => {
+ fireEvent.click(screen.getByRole("radio", { name: "Custom budget windows" }));
+ });
+ await act(async () => {
+ fireEvent.change(screen.getByRole("spinbutton", { name: "Window 1 budget (USD)" }), { target: { value: "0" } });
+ fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
+ });
+ const expected = {
+ user_id: "member",
+ role: "user",
+ max_budget_in_team: 50,
+ budget_limits: [{ ...windows[0], max_budget: 0 }, windows[1]],
+ };
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expected));
+ });
+
+ it("should send an empty override to restore inheritance", async () => {
+ const onSubmit = renderMember();
+ await act(async () => {
+ fireEvent.click(screen.getByRole("radio", { name: "Use team defaults" }));
+ fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
+ });
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ budget_limits: [] })));
+ });
+
+ it("should explain an incomplete custom window and prevent saving it", async () => {
+ const onSubmit = renderMember([], []);
+ await act(async () => {
+ fireEvent.click(screen.getByRole("radio", { name: "Custom budget windows" }));
+ fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
+ });
+ expect(await screen.findByText("Enter a budget")).toBeInTheDocument();
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it("should explain duplicate durations and prevent saving them", async () => {
+ const onSubmit = renderMember([windows[0], windows[0]]);
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
+ });
+ expect(await screen.findByText("Use each budget duration only once")).toBeInTheDocument();
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/team/MemberBudgetWindows.tsx b/ui/litellm-dashboard/src/components/team/MemberBudgetWindows.tsx
new file mode 100644
index 00000000000..ffa8d5fb0fa
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/team/MemberBudgetWindows.tsx
@@ -0,0 +1,64 @@
+import { useId } from "react";
+import { BudgetWindowsEditor, type BudgetWindowEntry } from "../key_team_helpers/BudgetWindowsEditor";
+import { getBudgetDurationLabel } from "../common_components/budget_duration_dropdown";
+import { Label } from "@/components/ui/label";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+
+interface MemberBudgetWindowsProps {
+ value: BudgetWindowEntry[];
+ onChange: (windows: BudgetWindowEntry[]) => void;
+ defaultWindows?: BudgetWindowEntry[];
+}
+
+export function MemberBudgetWindows({ value, onChange, defaultWindows }: MemberBudgetWindowsProps) {
+ const id = useId();
+ const inherits = defaultWindows !== undefined && value.length === 0;
+ const selectMode = (selection: unknown) => {
+ if (selection === "default") {
+ onChange([]);
+ return;
+ }
+ if (defaultWindows?.length) {
+ onChange(defaultWindows.map((window) => ({ ...window })));
+ return;
+ }
+ onChange([{ budget_duration: "24h", max_budget: null }]);
+ };
+
+ return (
+
+ {defaultWindows !== undefined && (
+
+
+
+
+
+
+
+
+
+
+ )}
+ {inherits ? (
+
+ {defaultWindows.length
+ ? defaultWindows
+ .map((window) => `$${window.max_budget} ${getBudgetDurationLabel(window.budget_duration)}`)
+ .join(", ")
+ : "No default budget windows set"}
+
+ ) : (
+
+ )}
+
+ {defaultWindows === undefined
+ ? "Each window limits one member's spend in this team. All windows apply together."
+ : "Custom windows replace the team defaults for this member. Existing single-budget limits also apply."}
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.integration.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.integration.test.tsx
index 545c504281b..8f7630e1fdb 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.integration.test.tsx
@@ -415,6 +415,28 @@ describe("TeamInfoView - member budget reset prompt", () => {
return await screen.findByLabelText("Default Budget (USD)");
};
+ it("should submit cleared default member windows after their section is collapsed", async () => {
+ const user = userEvent.setup({ delay: null });
+ const data = createMockTeamData({
+ team_member_budget_limits: [
+ { budget_duration: "24h", max_budget: 20 },
+ { budget_duration: "30d", max_budget: 200 },
+ ],
+ });
+ vi.mocked(networking.teamInfoCall).mockResolvedValue(data);
+ vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: savedTeam, team_id: "123" });
+ renderWithProviders(
);
+ await user.click(await screen.findByRole("tab", { name: "Settings" }));
+ await user.click(await screen.findByRole("button", { name: /edit settings/i }));
+ await user.click(screen.getByText("Team Member Settings"));
+ await user.click(screen.getByRole("button", { name: "Remove budget window 2" }));
+ await user.click(screen.getByRole("button", { name: "Remove budget window 1" }));
+ await user.click(screen.getByText("Team Member Settings"));
+ await user.click(screen.getByRole("button", { name: /save changes/i }));
+ await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalled());
+ expect(vi.mocked(networking.teamUpdateCall).mock.calls[0][1].team_member_budget_limits).toEqual([]);
+ });
+
const submitNewDefault = async (user: ReturnType
, input: HTMLElement, value: string) => {
fireEvent.change(input, { target: { value } });
await user.click(screen.getByRole("button", { name: /save changes/i }));
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
index 329552d735a..1bce042f2d5 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
@@ -1,3 +1,7 @@
+import type { BudgetWindowEntry } from "../key_team_helpers/BudgetWindowsEditor";
+import { MemberBudgetWindows } from "./MemberBudgetWindows";
+import { memberBudgetWindowsSchema, type MemberBudgetWindow } from "./memberBudgetWindowSchema";
+import { getBudgetDurationLabel } from "../common_components/budget_duration_dropdown";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import type { components } from "@/lib/http/schema";
import useCan from "@/app/(dashboard)/hooks/useCan";
@@ -277,6 +281,7 @@ export const mcpUnresolvableSaveError = (reason: string): string =>
export type TeamMemberBudgetSource = components["schemas"]["TeamMemberResetBudgetResponse"]["budget_source"];
export interface TeamMembership {
+ budget_limits?: BudgetWindowEntry[] | null;
user_id: string;
team_id: string;
budget_id: string | null;
@@ -302,6 +307,7 @@ export interface TeamMembership {
export interface TeamData {
team_id: string;
team_info: {
+ team_member_budget_limits?: MemberBudgetWindow[] | null;
team_alias: string;
team_id: string;
organization_id: string | null;
@@ -376,6 +382,7 @@ const teamUpdateFieldsSchema = z.object({
soft_budget_alerting_emails: z.union([z.string(), z.array(z.string())]).optional(),
default_team_member_models: z.array(z.string()).optional(),
team_member_budget: numericInputSchema,
+ team_member_budget_limits: memberBudgetWindowsSchema.optional(),
team_member_budget_duration: z.string().nullish(),
team_member_key_duration: z.string().optional(),
team_member_tpm_limit: numericInputSchema,
@@ -472,6 +479,7 @@ const EMPTY_TEAM_UPDATE_VALUES: TeamUpdateFormValues = {
soft_budget_alerting_emails: "",
default_team_member_models: [],
team_member_budget: undefined,
+ team_member_budget_limits: [],
team_member_budget_duration: undefined,
team_member_key_duration: undefined,
team_member_tpm_limit: undefined,
@@ -524,6 +532,7 @@ const toTeamFormValues = (info: TeamInfoRecord, effectiveGuardrails: string[]):
: "",
default_team_member_models: info.default_team_member_models || [],
team_member_budget: info.team_member_budget_table?.max_budget,
+ team_member_budget_limits: info.team_member_budget_limits ?? [],
team_member_budget_duration: info.team_member_budget_table?.budget_duration,
team_member_key_duration: info.metadata?.team_member_key_duration,
team_member_tpm_limit: info.team_member_budget_table?.tpm_limit,
@@ -828,6 +837,7 @@ const TeamInfoView: React.FC = ({
user_email: values.user_email,
user_id: values.user_id,
role: values.role,
+ budget_limits: values.budget_limits ?? [],
};
await teamMemberAddCall(accessToken, teamId, member);
@@ -867,6 +877,7 @@ const TeamInfoView: React.FC = ({
user_email: values.user_email,
user_id: values.user_id,
role: values.role,
+ budget_limits: values.budget_limits ?? [],
max_budget_in_team: values.max_budget_in_team,
tpm_limit: values.tpm_limit,
rpm_limit: values.rpm_limit,
@@ -1101,6 +1112,12 @@ const TeamInfoView: React.FC = ({
};
updateData.team_member_budget_duration = values.team_member_budget_duration;
+ if (
+ values.team_member_budget_limits !== undefined &&
+ (form.getFieldState("team_member_budget_limits").isDirty || info.team_member_budget_limits != null)
+ ) {
+ updateData.team_member_budget_limits = values.team_member_budget_limits;
+ }
const newTeamMemberBudget =
values.team_member_budget !== undefined ? numberOrNull(values.team_member_budget) : undefined;
@@ -1689,6 +1706,13 @@ const TeamInfoView: React.FC = ({
/>
)}
+
+ {({ value, onChange }) => }
+
= ({
+
+ Default member budget windows:{" "}
+ {(info.team_member_budget_limits ?? [])
+ .map((window) => `$${window.max_budget} ${getBudgetDurationLabel(window.budget_duration)}`)
+ .join(", ") || "Not set"}
+
Max Budget: {info.team_member_budget_table?.max_budget ?? "No Limit"}
Budget Duration: {info.team_member_budget_table?.budget_duration || "No Limit"}
{modelMaxBudgetToEntries(
@@ -2571,6 +2601,8 @@ const TeamInfoView: React.FC = ({
mode="edit"
config={{
title: "Edit Member",
+ showBudgetWindows: true,
+ defaultBudgetWindows: info.team_member_budget_limits ?? [],
showEmail: true,
showUserId: true,
roleOptions: [
@@ -2700,6 +2732,7 @@ const TeamInfoView: React.FC = ({
onSubmit={handleMemberCreate}
accessToken={accessToken}
teamId={teamId}
+ defaultBudgetWindows={info.team_member_budget_limits ?? []}
/>
{/* Delete Member Confirmation Modal */}
diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx
index 9350de8a8fc..3bd94dd60a5 100644
--- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx
@@ -158,6 +158,37 @@ describe("TeamMembersComponent", () => {
expect(screen.getByRole("columnheader", { name: /actions/i })).toBeInTheDocument();
});
+ it("should show member overrides separately from inherited team windows", () => {
+ const original = createMockTeamData();
+ const teamData: TeamData = {
+ ...original,
+ team_info: {
+ ...original.team_info,
+ team_member_budget_limits: [{ budget_duration: "30d", max_budget: 200 }],
+ },
+ team_memberships: original.team_memberships.map((membership) => ({
+ ...membership,
+ budget_limits: [{ budget_duration: "24h", max_budget: 5 }],
+ })),
+ };
+ renderWithProviders(
+ ,
+ );
+ expect(screen.getByText("$5.00 daily")).toBeInTheDocument();
+ expect(screen.getByText("Member override")).toBeInTheDocument();
+ expect(screen.getByText("$200.00 monthly")).toBeInTheDocument();
+ expect(screen.getByText("Team default")).toBeInTheDocument();
+ });
+
it("should render team members data", () => {
renderWithProviders(
, string> = {
team_default: "Team default",
@@ -168,6 +170,25 @@ export default function TeamMemberTab({
};
const extraColumns: NonNullable["extraColumns"]> = [
+ {
+ title: "Budget windows",
+ key: "budget_windows",
+ render: (record: Member) => {
+ const override = findMembership(record.user_id)?.budget_limits;
+ const windows = effectiveMemberBudgetWindows(teamData.team_info.team_member_budget_limits, override);
+ if (!windows.length) return Not set;
+ return (
+
+ {windows.map((window) => (
+
+ ${formatNumberWithCommas(window.max_budget ?? 0, 2)} {getBudgetDurationLabel(window.budget_duration)}
+
+ ))}
+
{override?.length ? "Member override" : "Team default"}
+
+ );
+ },
+ },
{
title: (
@@ -334,7 +355,7 @@ export default function TeamMemberTab({
const membership = findMembership(record.user_id);
setSelectedEditMember(
seedMemberBudgetFields(
- record,
+ { ...record, budget_limits: membership?.budget_limits ?? [] },
membership?.litellm_budget_table,
teamData.team_info.team_member_budget_table?.model_max_budget,
),
diff --git a/ui/litellm-dashboard/src/components/team/memberBudgetWindowSchema.test.ts b/ui/litellm-dashboard/src/components/team/memberBudgetWindowSchema.test.ts
new file mode 100644
index 00000000000..dffbb725e04
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/team/memberBudgetWindowSchema.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+import { effectiveMemberBudgetWindows, memberBudgetWindowsSchema } from "./memberBudgetWindowSchema";
+
+const windows = [
+ { budget_duration: "24h", max_budget: 20 },
+ { budget_duration: "30d", max_budget: 200 },
+];
+
+describe("member budget windows", () => {
+ it("should accept simultaneous daily and monthly limits and preserve zero-dollar caps", () => {
+ expect(memberBudgetWindowsSchema.parse(windows)).toEqual(windows);
+ expect(memberBudgetWindowsSchema.parse([{ ...windows[0], max_budget: 0 }])).toEqual([
+ { ...windows[0], max_budget: 0 },
+ ]);
+ });
+
+ it.each([null, -1, Infinity, NaN])("should reject an invalid budget of %s", (max_budget) => {
+ expect(memberBudgetWindowsSchema.safeParse([{ ...windows[0], max_budget }]).success).toBe(false);
+ });
+
+ it("should reject duplicate and unsupported durations", () => {
+ expect(memberBudgetWindowsSchema.safeParse([windows[0], windows[0]]).success).toBe(false);
+ expect(memberBudgetWindowsSchema.safeParse([{ budget_duration: "tomorrow", max_budget: 20 }]).success).toBe(false);
+ });
+
+ it("should inherit defaults when no override is configured or an override is cleared", () => {
+ expect(effectiveMemberBudgetWindows(windows, undefined)).toEqual(windows);
+ expect(effectiveMemberBudgetWindows(windows, [])).toEqual(windows);
+ expect(effectiveMemberBudgetWindows(undefined, undefined)).toEqual([]);
+ });
+
+ it("should replace all default windows with the member override", () => {
+ const override = [{ budget_duration: "24h", max_budget: 5 }];
+ expect(effectiveMemberBudgetWindows(windows, override)).toEqual(override);
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/team/memberBudgetWindowSchema.ts b/ui/litellm-dashboard/src/components/team/memberBudgetWindowSchema.ts
new file mode 100644
index 00000000000..81b519dd23f
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/team/memberBudgetWindowSchema.ts
@@ -0,0 +1,35 @@
+import { z } from "zod";
+import type { BudgetWindowEntry } from "../key_team_helpers/BudgetWindowsEditor";
+
+const budgetWindowEntriesSchema = z.array(
+ z.object({
+ budget_duration: z.enum(["1h", "24h", "7d", "30d"]),
+ max_budget: z.number().nullable(),
+ }),
+);
+
+export const memberBudgetWindowsSchema = budgetWindowEntriesSchema.superRefine((windows, context) => {
+ windows.forEach((window, index) => {
+ if (window.max_budget === null) {
+ context.addIssue({ code: "custom", message: "Enter a budget" });
+ } else if (window.max_budget < 0) {
+ context.addIssue({ code: "custom", message: "Enter a non-negative budget" });
+ }
+ if (windows.slice(0, index).some((other) => other.budget_duration === window.budget_duration)) {
+ context.addIssue({
+ code: "custom",
+ message: "Use each budget duration only once",
+ });
+ }
+ });
+});
+
+export const isMemberBudgetWindows = (value: unknown): value is BudgetWindowEntry[] =>
+ budgetWindowEntriesSchema.safeParse(value).success;
+
+export type MemberBudgetWindow = z.infer[number];
+
+export const effectiveMemberBudgetWindows = (
+ defaults: BudgetWindowEntry[] | null | undefined,
+ override: BudgetWindowEntry[] | null | undefined,
+): BudgetWindowEntry[] => (override?.length ? override : defaults ?? []);
diff --git a/ui/litellm-dashboard/src/components/team/memberFormValues.ts b/ui/litellm-dashboard/src/components/team/memberFormValues.ts
index df80e2dd9ac..b432e094f89 100644
--- a/ui/litellm-dashboard/src/components/team/memberFormValues.ts
+++ b/ui/litellm-dashboard/src/components/team/memberFormValues.ts
@@ -3,8 +3,10 @@ import {
modelMaxBudgetToEntries,
type ModelMaxBudget,
} from "../key_team_helpers/ModelMaxBudgetEditor";
+import type { BudgetWindowEntry } from "../key_team_helpers/BudgetWindowsEditor";
+import { isMemberBudgetWindows } from "./memberBudgetWindowSchema";
-export type MemberFieldValue = string | number | null | undefined | string[] | ModelMaxBudget;
+export type MemberFieldValue = string | number | null | undefined | string[] | ModelMaxBudget | BudgetWindowEntry[];
export type MemberFormValues = Record;
@@ -30,6 +32,8 @@ export interface MemberAdditionalField {
}
export interface MemberFieldsConfig {
+ showBudgetWindows?: boolean;
+ defaultBudgetWindows?: BudgetWindowEntry[];
roleOptions: Array<{ label: string; value: string }>;
defaultRole?: string;
showEmail?: boolean;
@@ -62,6 +66,7 @@ export const memberFieldNames = (config: MemberFieldsConfig): string[] => [
...(config.showEmail ? ["user_email"] : []),
...(config.showUserId ? ["user_id"] : []),
"role",
+ ...(config.showBudgetWindows ? ["budget_limits"] : []),
...(config.additionalFields ?? []).map((field) => field.name),
];
@@ -76,6 +81,7 @@ export const buildMemberFormValues = (
if (mode === "edit" && initialData) {
const seeded: MemberFormValues = {
...initialData,
+ budget_limits: isMemberBudgetWindows(initialData.budget_limits) ? initialData.budget_limits : [],
role: (initialData.role as string) || config.defaultRole,
max_budget_in_team: initialData.max_budget_in_team ?? null,
tpm_limit: initialData.tpm_limit ?? null,
@@ -92,7 +98,7 @@ export const buildMemberFormValues = (
return pickFieldNames(config, seeded);
}
- return pickFieldNames(config, { role: config.defaultRole || config.roleOptions[0]?.value });
+ return pickFieldNames(config, { role: config.defaultRole || config.roleOptions[0]?.value, budget_limits: [] });
};
const emptyValueForType = (type: MemberFieldType | undefined): MemberFieldValue => {
@@ -112,7 +118,12 @@ const emptyValueForType = (type: MemberFieldType | undefined): MemberFieldValue
export const emptyMemberFormValues = (config: MemberFieldsConfig): MemberFormValues => {
const typeByName = new Map((config.additionalFields ?? []).map((field) => [field.name, field.type]));
- return Object.fromEntries(memberFieldNames(config).map((name) => [name, emptyValueForType(typeByName.get(name))]));
+ return Object.fromEntries(
+ memberFieldNames(config).map((name) => [
+ name,
+ name === "budget_limits" ? [] : emptyValueForType(typeByName.get(name)),
+ ]),
+ );
};
export const buildMemberFormData = (values: MemberFormValues): MemberFormValues =>