mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-26 01:12:21 +00:00
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:
parent
432e24a11f
commit
7022288b48
8 changed files with 240 additions and 1 deletions
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
6
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
6
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue