feat(ui): clear user settings from the Admin UI and edit TPM/RPM there

The user edit form now saves through PATCH /management/v1/users/{user_id}
instead of /user/update, so emptying a control actually clears the setting
rather than saving as a no-op. TPM and RPM limits are editable and shown on
the details panel, and /v2/user/info returns them so the form seeds correctly.
This commit is contained in:
ryan-crabbe-berri 2026-08-27 18:14:46 -07:00
parent 0ace6e7ed5
commit 4dd93ae8aa
12 changed files with 758 additions and 41 deletions

View file

@ -3017,6 +3017,9 @@ class UserInfoV2Response(LiteLLMPydanticObjectBase):
models: list[str] = []
budget_duration: str | None = None
budget_reset_at: datetime | None = None
tpm_limit: int | None = None
rpm_limit: int | None = None
max_parallel_requests: int | None = None
metadata: dict | None = None
created_at: datetime | None = None
updated_at: datetime | None = None

View file

@ -1058,6 +1058,9 @@ async def user_info_v2(
models=user_data.get("models") or [],
budget_duration=user_data.get("budget_duration"),
budget_reset_at=user_data.get("budget_reset_at"),
tpm_limit=user_data.get("tpm_limit"),
rpm_limit=user_data.get("rpm_limit"),
max_parallel_requests=user_data.get("max_parallel_requests"),
metadata=_redact_scim_enterprise_metadata(user_data.get("metadata")),
created_at=user_data.get("created_at"),
updated_at=user_data.get("updated_at"),

View file

@ -2655,6 +2655,45 @@ async def test_user_info_v2_proxy_admin_can_query_any_user(mocker):
assert response.metadata == {"team": "engineering"}
@pytest.mark.asyncio
async def test_user_info_v2_returns_rate_limits(mocker):
"""
The Admin UI seeds its edit form from this response, so a limit missing here reads
to the operator as "not set" and a save silently wipes it.
"""
from fastapi import Request
from litellm.proxy.management_endpoints.internal_user_endpoints import user_info_v2
mock_prisma_client = mocker.MagicMock()
mock_user_row = mocker.MagicMock()
mock_user_row.model_dump.return_value = {
"user_id": "limited-user",
"tpm_limit": 12000,
"rpm_limit": 60,
"max_parallel_requests": 3,
"teams": [],
}
mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(
return_value=mock_user_row
)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
response = await user_info_v2(
request=mocker.MagicMock(spec=Request),
user_id="limited-user",
user_api_key_dict=UserAPIKeyAuth(
user_id="admin-user", user_role=LitellmUserRoles.PROXY_ADMIN
),
)
assert response.tpm_limit == 12000
assert response.rpm_limit == 60
assert response.max_parallel_requests == 3
@pytest.mark.asyncio
async def test_user_info_v2_redacts_scim_enterprise_metadata(mocker):
"""
@ -3008,6 +3047,9 @@ async def test_user_info_v2_response_shape(mocker):
"models",
"budget_duration",
"budget_reset_at",
"tpm_limit",
"rpm_limit",
"max_parallel_requests",
"metadata",
"created_at",
"updated_at",

View file

@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import { toUserPatch } from "./userPatchPayload";
describe("toUserPatch", () => {
it("sends null for every control the operator emptied", () => {
const emptied = {
user_email: "",
user_alias: "",
budget_duration: undefined,
max_budget: null,
tpm_limit: "",
rpm_limit: "",
metadata: "",
};
expect(toUserPatch(emptied)).toEqual({
user_email: null,
user_alias: null,
budget_duration: null,
max_budget: null,
tpm_limit: null,
rpm_limit: null,
metadata: null,
});
});
it("keeps the values the operator did set", () => {
const filled = {
user_email: "someone@example.com",
user_alias: "someone",
user_role: "internal_user" as const,
models: ["gpt-4o"],
max_budget: "12.5",
budget_duration: "30d",
tpm_limit: "1000",
rpm_limit: "60",
metadata: { team: "core" },
model_max_budget: { "gpt-4o": { budget_limit: 1, time_period: "1d" } },
};
expect(toUserPatch(filled)).toEqual({
user_email: "someone@example.com",
user_alias: "someone",
user_role: "internal_user",
models: ["gpt-4o"],
max_budget: 12.5,
budget_duration: "30d",
tpm_limit: 1000,
rpm_limit: 60,
metadata: { team: "core" },
model_max_budget: { "gpt-4o": { budget_limit: 1, time_period: "1d" } },
});
});
it("converts numeric strings so the proxy is not handed a string limit", () => {
const patch = toUserPatch({ tpm_limit: "1000", rpm_limit: "60", max_budget: "12.5" });
expect(patch.tpm_limit).toBe(1000);
expect(patch.rpm_limit).toBe(60);
expect(patch.max_budget).toBe(12.5);
});
it("omits fields the form never rendered rather than clearing them", () => {
expect(toUserPatch({ user_alias: "someone" })).toEqual({ user_alias: "someone" });
});
it("omits an empty role, since the dropdown offers no way to clear one", () => {
expect(toUserPatch({ user_role: null })).toEqual({});
expect(toUserPatch({ user_role: undefined })).toEqual({});
});
it("sends an empty model list, which is how personal models get revoked", () => {
expect(toUserPatch({ models: [] })).toEqual({ models: [] });
});
it("omits model_max_budget when the editor reported no change", () => {
expect(toUserPatch({ model_max_budget: undefined })).toEqual({});
});
it("drops the form-only keys the endpoint refuses with a 422", () => {
const patch = toUserPatch({
user_id: "u-1",
mcp_servers_and_groups: { servers: [], accessGroups: [], toolsets: [] },
mcp_tool_permissions: {},
user_alias: "someone",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
expect(patch).toEqual({ user_alias: "someone" });
});
it("keeps a zero limit, which is a real setting and not an empty field", () => {
expect(toUserPatch({ tpm_limit: 0, rpm_limit: "0", max_budget: 0 })).toEqual({
tpm_limit: 0,
rpm_limit: 0,
max_budget: 0,
});
});
});

View file

@ -0,0 +1,47 @@
import type { ModelMaxBudget } from "@/components/key_team_helpers/ModelMaxBudgetEditor";
import type { UserPatchRequest } from "@/components/networking";
/** The subset of `UserEditView`'s submitted values that `PATCH /management/v1/users/{id}` accepts. */
export interface UserEditFormValues {
user_email?: string | null;
user_alias?: string | null;
user_role?: UserPatchRequest["user_role"];
models?: string[];
max_budget?: string | number | null;
budget_duration?: string | null;
tpm_limit?: string | number | null;
rpm_limit?: string | number | null;
// The editor hands back parsed JSON, or the raw text when it was left empty.
metadata?: Record<string, unknown> | string | null;
model_max_budget?: ModelMaxBudget;
}
const emptyToNull = (value: string | null | undefined): string | null => (value ? value : null);
const toNumberOrNull = (value: string | number | null | undefined): number | null =>
value === "" || value === null || value === undefined ? null : Number(value);
/**
* Build the merge-patch body for one internal user.
*
* The endpoint reads an omitted key as "leave alone" and an explicit null as "clear", so an emptied
* control has to become a null rather than disappear, or the save is a silent no-op. It also refuses
* unknown keys with a 422, which is why this picks fields out rather than forwarding the form store:
* `user_id` and the two MCP controls are form state, not columns.
*
* A field the form did not render is left out entirely, since only the caller knows whether the
* operator declined to set it or never saw it. `user_role` is the one rendered field treated that
* way: its dropdown offers no way to clear a role, so an empty one means the user never had one.
*/
export const toUserPatch = (values: UserEditFormValues): UserPatchRequest => ({
...("user_email" in values && { user_email: emptyToNull(values.user_email) }),
...("user_alias" in values && { user_alias: emptyToNull(values.user_alias) }),
...("budget_duration" in values && { budget_duration: emptyToNull(values.budget_duration) }),
...("max_budget" in values && { max_budget: toNumberOrNull(values.max_budget) }),
...("tpm_limit" in values && { tpm_limit: toNumberOrNull(values.tpm_limit) }),
...("rpm_limit" in values && { rpm_limit: toNumberOrNull(values.rpm_limit) }),
...(values.user_role && { user_role: values.user_role }),
...("models" in values && { models: values.models ?? [] }),
...("metadata" in values && { metadata: typeof values.metadata === "object" ? values.metadata : null }),
...(values.model_max_budget !== undefined && { model_max_budget: values.model_max_budget }),
});

View file

@ -246,6 +246,88 @@ describe("UserEditView", () => {
});
});
describe("rate limits", () => {
const seededWithLimits = (limits: { tpm_limit?: number | null; rpm_limit?: number | null }) => ({
...MOCK_USER_DATA,
user_info: { ...MOCK_USER_DATA.user_info, ...limits },
});
it("should seed both limits from the loaded user", async () => {
renderWithProviders(
<UserEditView {...defaultProps} userData={seededWithLimits({ tpm_limit: 5000, rpm_limit: 60 })} />,
);
expect(await screen.findByRole("spinbutton", { name: /tpm limit/i })).toHaveValue(5000);
expect(screen.getByRole("spinbutton", { name: /rpm limit/i })).toHaveValue(60);
});
it("should leave both blank when the user has no limits", async () => {
renderWithProviders(<UserEditView {...defaultProps} userData={seededWithLimits({})} />);
expect(await screen.findByRole("spinbutton", { name: /tpm limit/i })).toHaveValue(null);
expect(screen.getByRole("spinbutton", { name: /rpm limit/i })).toHaveValue(null);
});
it("should submit an emptied limit as an empty string so the caller can clear it", async () => {
const onSubmit = vi.fn();
renderWithProviders(
<UserEditView
{...defaultProps}
onSubmit={onSubmit}
userData={seededWithLimits({ tpm_limit: 5000, rpm_limit: 60 })}
/>,
);
fireEvent.change(await screen.findByRole("spinbutton", { name: /tpm limit/i }), { target: { value: "" } });
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalled();
});
expect(onSubmit.mock.calls[0][0]).toMatchObject({ tpm_limit: "", rpm_limit: 60 });
});
// The proxy types both limits as ints, so a fractional or negative entry comes back as a 422
// the operator can only read in the network tab. These constraints stop the submit instead.
it("should not offer either limit in bulk edit, where the value would be discarded", async () => {
renderWithProviders(<UserEditView {...defaultProps} isBulkEdit />);
await screen.findByRole("button", { name: /save changes/i });
expect(screen.queryByRole("spinbutton", { name: /tpm limit/i })).not.toBeInTheDocument();
expect(screen.queryByRole("spinbutton", { name: /rpm limit/i })).not.toBeInTheDocument();
});
it("should keep both limits constrained to whole, non-negative numbers", async () => {
renderWithProviders(<UserEditView {...defaultProps} />);
for (const name of [/tpm limit/i, /rpm limit/i]) {
const input = await screen.findByRole("spinbutton", { name });
expect(input).toHaveAttribute("step", "1");
expect(input).toHaveAttribute("min", "0");
}
});
it("should not submit a fractional limit", async () => {
const onSubmit = vi.fn();
renderWithProviders(<UserEditView {...defaultProps} onSubmit={onSubmit} />);
fireEvent.change(await screen.findByRole("spinbutton", { name: /tpm limit/i }), { target: { value: "1.5" } });
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
expect(onSubmit).not.toHaveBeenCalled();
});
it("should not submit a negative limit", async () => {
const onSubmit = vi.fn();
renderWithProviders(<UserEditView {...defaultProps} onSubmit={onSubmit} />);
fireEvent.change(await screen.findByRole("spinbutton", { name: /rpm limit/i }), { target: { value: "-1" } });
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
expect(onSubmit).not.toHaveBeenCalled();
});
});
it("should display metadata textarea with formatted JSON", async () => {
renderWithProviders(<UserEditView {...defaultProps} />);
@ -461,7 +543,7 @@ describe("UserEditView", () => {
return onSubmit.mock.calls[0][0];
};
it("should send exactly the ten keys an admin edit produces, with seeded types preserved", async () => {
it("should send exactly the twelve keys an admin edit produces, with seeded types preserved", async () => {
const payload = await submittedPayload();
expect(Object.keys(payload).sort()).toEqual([
@ -471,6 +553,8 @@ describe("UserEditView", () => {
"mcp_tool_permissions",
"metadata",
"models",
"rpm_limit",
"tpm_limit",
"user_alias",
"user_email",
"user_id",
@ -484,6 +568,8 @@ describe("UserEditView", () => {
models: ["gpt-4", "gpt-3.5-turbo"],
max_budget: 100.5,
budget_duration: "30d",
tpm_limit: "",
rpm_limit: "",
metadata: { key1: "value1", key2: "value2" },
mcp_servers_and_groups: { servers: [], accessGroups: [], toolsets: [] },
mcp_tool_permissions: {},
@ -512,6 +598,8 @@ describe("UserEditView", () => {
"max_budget",
"metadata",
"models",
"rpm_limit",
"tpm_limit",
"user_alias",
"user_email",
"user_id",

View file

@ -52,6 +52,8 @@ const userEditShape = {
user_role: z.string().nullish(),
models: z.array(z.string()),
budget_duration: z.string().nullish(),
tpm_limit: z.union([z.string(), z.number()]).nullish(),
rpm_limit: z.union([z.string(), z.number()]).nullish(),
metadata: z.string().nullish(),
mcp_servers_and_groups: MCP_SELECTION_SHAPE.optional(),
mcp_tool_permissions: z.record(z.string(), z.array(z.string())).optional(),
@ -92,7 +94,14 @@ const toFormValues = (
const maxBudget = userData.user_info?.max_budget;
const isUnlimited = maxBudget === null || maxBudget === undefined;
return {
...(isBulkEdit ? {} : { user_id: userData.user_id, user_email: userData.user_info?.user_email }),
...(isBulkEdit
? {}
: {
user_id: userData.user_id,
user_email: userData.user_info?.user_email,
tpm_limit: userData.user_info?.tpm_limit ?? "",
rpm_limit: userData.user_info?.rpm_limit ?? "",
}),
user_alias: userData.user_info?.user_alias,
user_role: userData.user_info?.user_role,
models: userData.user_info?.models || [],
@ -293,6 +302,58 @@ export function UserEditView({
{({ id, value, onChange }) => <BudgetDurationDropdown id={id} value={value} onChange={onChange} />}
</FormField>
{/* Bulk edit posts a fixed field list to /user/bulk_update, which does not carry
either limit, so the controls would look like they saved and do nothing. */}
{!isBulkEdit && (
<>
<FormField
control={form.control}
name="tpm_limit"
label={labelWithHint(
"TPM Limit",
"Tokens per minute this user may spend across every key they hold. Leave blank for no limit.",
)}
>
{({ ref, value, onChange, ...control }) => (
<Input
{...control}
ref={ref}
type="number"
min={0}
step={1}
value={value ?? ""}
onChange={(event) => onChange(event.target.value)}
onWheel={(event) => event.currentTarget.blur()}
placeholder="Unlimited"
/>
)}
</FormField>
<FormField
control={form.control}
name="rpm_limit"
label={labelWithHint(
"RPM Limit",
"Requests per minute this user may make across every key they hold. Leave blank for no limit.",
)}
>
{({ ref, value, onChange, ...control }) => (
<Input
{...control}
ref={ref}
type="number"
min={0}
step={1}
value={value ?? ""}
onChange={(event) => onChange(event.target.value)}
onWheel={(event) => event.currentTarget.blur()}
placeholder="Unlimited"
/>
)}
</FormField>
</>
)}
{/* 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 && (

View file

@ -8,7 +8,7 @@ const mockTeamMemberDeleteCall = vi.fn();
const mockTeamListCall = vi.fn();
const mockUserGetInfoV2 = vi.fn();
const mockTeamInfoCall = vi.fn();
const mockUserUpdateUserCall = vi.fn();
const mockUserPatchCall = vi.fn();
const mockFetchMCPServers = vi.fn();
const mockListMCPTools = vi.fn();
@ -52,7 +52,7 @@ vi.mock("@/components/networking", () => {
serverRootPath: "/",
userGetInfoV2: (...args: unknown[]) => mockUserGetInfoV2(...args),
userDeleteCall: vi.fn(),
userUpdateUserCall: (...args: unknown[]) => mockUserUpdateUserCall(...args),
userPatchCall: (...args: unknown[]) => mockUserPatchCall(...args),
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
invitationCreateCall: vi.fn(),
teamInfoCall: (...args: unknown[]) => mockTeamInfoCall(...args),
@ -144,7 +144,11 @@ describe("UserInfoView add-to-team form", () => {
...MOCK_USER_DATA,
model_max_budget: { "gpt-4": { budget_limit: 5, time_period: "30d" } },
});
mockUserUpdateUserCall.mockResolvedValue({});
// The endpoint answers with the row it just wrote, which is what the view re-seeds from.
mockUserPatchCall.mockImplementation(async (_token: string, _userId: string, patch: object) => ({
...MOCK_USER_DATA,
...patch,
}));
});
it("shows the saved cap, not the pre-save one, when the form is reopened", async () => {
@ -155,9 +159,9 @@ describe("UserInfoView add-to-team form", () => {
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(mockUserUpdateUserCall).toHaveBeenCalled();
expect(mockUserPatchCall).toHaveBeenCalled();
});
expect(mockUserUpdateUserCall.mock.calls[0][1].model_max_budget).toEqual({
expect(mockUserPatchCall.mock.calls[0][2].model_max_budget).toEqual({
"gpt-4": { budget_limit: 42, time_period: "30d" },
});

View file

@ -1,4 +1,4 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi, beforeEach } from "vitest";
import UserInfoView from "./user_info_view";
@ -9,7 +9,7 @@ const mockTeamMemberDeleteCall = vi.fn();
const mockTeamListCall = vi.fn();
const mockUserGetInfoV2 = vi.fn();
const mockTeamInfoCall = vi.fn();
const mockUserUpdateUserCall = vi.fn();
const mockUserPatchCall = vi.fn();
const mockFetchMCPServers = vi.fn();
const mockListMCPTools = vi.fn();
@ -54,7 +54,7 @@ vi.mock("@/components/networking", () => {
serverRootPath: "/",
userGetInfoV2: (...args: any[]) => mockUserGetInfoV2(...args),
userDeleteCall: vi.fn(),
userUpdateUserCall: (...args: unknown[]) => mockUserUpdateUserCall(...args),
userPatchCall: (...args: unknown[]) => mockUserPatchCall(...args),
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
invitationCreateCall: vi.fn(),
teamInfoCall: (...args: any[]) => mockTeamInfoCall(...args),
@ -105,7 +105,7 @@ describe("UserInfoView", () => {
]);
mockTeamMemberAddCall.mockResolvedValue({});
mockTeamMemberDeleteCall.mockResolvedValue({});
mockUserUpdateUserCall.mockResolvedValue({});
mockUserPatchCall.mockResolvedValue(MOCK_USER_DATA);
mockFetchMCPServers.mockResolvedValue([MCP_SERVER]);
mockListMCPTools.mockResolvedValue({ tools: [{ name: "list_issues", description: "List issues" }] });
});
@ -293,6 +293,110 @@ describe("UserInfoView", () => {
expect(screen.getByText("list_issues")).toBeVisible();
});
describe("rate limits", () => {
it("should show both limits on the details panel", async () => {
mockUserGetInfoV2.mockResolvedValue({ ...MOCK_USER_DATA, tpm_limit: 12000, rpm_limit: 60 });
render(<UserInfoView {...defaultProps} initialTab={1} />);
expect(await screen.findByText("12,000")).toBeInTheDocument();
expect(screen.getByText("60")).toBeInTheDocument();
});
it("should read a user with no limits as unlimited rather than blank", async () => {
render(<UserInfoView {...defaultProps} initialTab={1} />);
await screen.findByText("TPM Limit");
expect(screen.getAllByText("Unlimited")).toHaveLength(2);
});
it("should seed the edit form from the stored limits", async () => {
const user = userEvent.setup();
mockUserGetInfoV2.mockResolvedValue({ ...MOCK_USER_DATA, tpm_limit: 12000, rpm_limit: 60 });
render(<UserInfoView {...defaultProps} userRole="proxy_admin" initialTab={1} />);
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
expect(await screen.findByRole("spinbutton", { name: /tpm limit/i })).toHaveValue(12000);
expect(screen.getByRole("spinbutton", { name: /rpm limit/i })).toHaveValue(60);
});
});
// /user/update drops nulls, so an emptied control used to save as a silent no-op. The whole
// point of moving to PATCH /management/v1/users is that a cleared field reaches the proxy.
describe("clearing a setting", () => {
const saveWith = async (
edit: (user: ReturnType<typeof userEvent.setup>) => Promise<void>,
stored: Record<string, unknown> = {},
) => {
const user = userEvent.setup();
mockUserGetInfoV2.mockResolvedValue({ ...MOCK_USER_DATA, ...stored });
render(<UserInfoView {...defaultProps} userRole="proxy_admin" initialTab={1} startInEditMode />);
await screen.findByText("Save Changes");
await edit(user);
await user.click(screen.getByText("Save Changes"));
await waitFor(() => {
expect(mockUserPatchCall).toHaveBeenCalledTimes(1);
});
return mockUserPatchCall.mock.calls[0][2];
};
it("should send null for an emptied TPM limit", async () => {
const patch = await saveWith(
async () => {
fireEvent.change(screen.getByRole("spinbutton", { name: /tpm limit/i }), { target: { value: "" } });
},
{ tpm_limit: 12000, rpm_limit: 60 },
);
expect(patch.tpm_limit).toBeNull();
expect(patch.rpm_limit).toBe(60);
});
it("should send null for a budget switched to unlimited", async () => {
const patch = await saveWith(async (user) => {
await user.click(screen.getByRole("checkbox", { name: "Unlimited Budget" }));
});
expect(patch.max_budget).toBeNull();
});
it("should send null for a budget reset window set back to n/a", async () => {
const patch = await saveWith(async (user) => {
await user.click(screen.getByRole("combobox", { name: /reset budget/i }));
await user.click(await screen.findByRole("option", { name: "n/a" }));
});
expect(patch.budget_duration).toBeNull();
});
it("should keep the form-only keys the endpoint would reject out of the body", async () => {
const patch = await saveWith(async () => {});
expect(patch).not.toHaveProperty("user_id");
expect(patch).not.toHaveProperty("mcp_servers_and_groups");
expect(patch).not.toHaveProperty("mcp_tool_permissions");
});
it("should re-seed the view from the row the proxy wrote, not the values typed", async () => {
const user = userEvent.setup();
mockUserGetInfoV2.mockResolvedValue({ ...MOCK_USER_DATA, tpm_limit: 12000 });
mockUserPatchCall.mockResolvedValue({ ...MOCK_USER_DATA, tpm_limit: null });
render(<UserInfoView {...defaultProps} userRole="proxy_admin" initialTab={1} startInEditMode />);
await screen.findByText("Save Changes");
fireEvent.change(screen.getByRole("spinbutton", { name: /tpm limit/i }), { target: { value: "" } });
await user.click(screen.getByText("Save Changes"));
await waitFor(() => {
expect(mockUserPatchCall).toHaveBeenCalledTimes(1);
});
expect(await screen.findByText("TPM Limit")).toBeInTheDocument();
expect(screen.queryByText("12,000")).not.toBeInTheDocument();
});
});
describe("MCP permissions", () => {
it("should render the user's MCP entitlements in read mode", async () => {
const user = userEvent.setup();
@ -319,13 +423,13 @@ describe("UserInfoView", () => {
await user.click(saveButton);
await waitFor(() => {
expect(mockUserUpdateUserCall).toHaveBeenCalledTimes(1);
expect(mockUserPatchCall).toHaveBeenCalledTimes(1);
});
const [token, payload, roleArg] = mockUserUpdateUserCall.mock.calls[0];
const [token, userId, payload] = mockUserPatchCall.mock.calls[0];
expect(token).toBe("test-token");
expect(roleArg).toBeNull();
expect(payload.user_id).toBe("user-123");
expect(userId).toBe("user-123");
expect(payload).not.toHaveProperty("user_id");
const expectedObjectPermission = {
mcp_servers: ["srv-1"],
mcp_access_groups: ["dev-group"],
@ -354,10 +458,10 @@ describe("UserInfoView", () => {
await user.click(screen.getByText("Save Changes"));
await waitFor(() => {
expect(mockUserUpdateUserCall).toHaveBeenCalledTimes(1);
expect(mockUserPatchCall).toHaveBeenCalledTimes(1);
});
const [, payload] = mockUserUpdateUserCall.mock.calls[0];
const [, , payload] = mockUserPatchCall.mock.calls[0];
expect(payload.object_permission.mcp_tool_permissions).toEqual({ "srv-1": [] });
});
@ -377,10 +481,10 @@ describe("UserInfoView", () => {
await user.click(saveButton);
await waitFor(() => {
expect(mockUserUpdateUserCall).toHaveBeenCalledTimes(1);
expect(mockUserPatchCall).toHaveBeenCalledTimes(1);
});
const [, payload] = mockUserUpdateUserCall.mock.calls[0];
const [, , payload] = mockUserPatchCall.mock.calls[0];
expect(payload.object_permission.mcp_tool_permissions).toEqual({
"srv-1": ["list_issues"],
"srv-via-group": ["read_only"],
@ -395,10 +499,10 @@ describe("UserInfoView", () => {
await user.click(saveButton);
await waitFor(() => {
expect(mockUserUpdateUserCall).toHaveBeenCalledTimes(1);
expect(mockUserPatchCall).toHaveBeenCalledTimes(1);
});
const [, payload] = mockUserUpdateUserCall.mock.calls[0];
const [, , payload] = mockUserPatchCall.mock.calls[0];
expect(payload).not.toHaveProperty("object_permission");
expect(screen.queryByText("MCP Servers / Access Groups")).not.toBeInTheDocument();
});

View file

@ -7,7 +7,7 @@ import {
userGetInfoV2,
UserInfoV2Response,
userDeleteCall,
userUpdateUserCall,
userPatchCall,
modelAvailableCall,
invitationCreateCall,
getProxyBaseUrl,
@ -32,6 +32,7 @@ import { rolesWithWriteAccess } from "@/utils/roles";
import { teamDetailHref } from "@/utils/entityLinks";
import { BadgeLink } from "@/components/shared/BadgeLink";
import { UserEditView } from "../user_edit_view";
import { toUserPatch } from "../userPatchPayload";
import OnboardingModal, { InvitationLink } from "@/components/onboarding_link";
import { formatNumberWithCommas, copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
import { ArrowLeft, CheckIcon, CopyIcon, Plus, RefreshCw, Trash2 } from "lucide-react";
@ -314,28 +315,17 @@ export default function UserInfoView({
if (!accessToken || !userData) return;
const mcpEntitlement = extractMcpEntitlement(formValues, allMcpServers, allMcpToolsets);
const userFields = Object.fromEntries(
Object.entries(formValues).filter(
([field]) => field !== "mcp_servers_and_groups" && field !== "mcp_tool_permissions",
),
);
const updated = await userPatchCall(accessToken, userData.user_id, {
...toUserPatch(formValues),
...(mcpEntitlement ? { object_permission: mcpEntitlement } : {}),
});
await userUpdateUserCall(
accessToken,
mcpEntitlement ? { ...userFields, object_permission: mcpEntitlement } : userFields,
null,
);
// Update local state with new values
// The response is the row as it now stands, so a field the operator cleared reads back
// as cleared instead of keeping the value the form was seeded with. Entitlements live in
// their own table and are not part of that row, so they still merge from the request.
setUserData({
...userData,
user_email: formValues.user_email ?? userData.user_email,
user_alias: formValues.user_alias ?? userData.user_alias,
models: formValues.models ?? userData.models,
max_budget: formValues.max_budget ?? userData.max_budget,
budget_duration: formValues.budget_duration ?? userData.budget_duration,
metadata: formValues.metadata ?? userData.metadata,
model_max_budget: formValues.model_max_budget ?? userData.model_max_budget,
...updated,
object_permission: mcpEntitlement
? { ...userData.object_permission, ...mcpEntitlement }
: userData.object_permission,
@ -393,6 +383,8 @@ export default function UserInfoView({
models: userData.models,
max_budget: userData.max_budget,
budget_duration: userData.budget_duration,
tpm_limit: userData.tpm_limit,
rpm_limit: userData.rpm_limit,
metadata: userData.metadata,
// Without these the per-model budget editor mounts empty and a save
// replaces the user's existing budgets with whatever was typed.
@ -663,6 +655,24 @@ export default function UserInfoView({
<p>{getBudgetDurationLabel(userData.budget_duration ?? null)}</p>
</div>
<div>
<p className="font-medium">TPM Limit</p>
<p>
{userData.tpm_limit !== null && userData.tpm_limit !== undefined
? formatNumberWithCommas(userData.tpm_limit)
: "Unlimited"}
</p>
</div>
<div>
<p className="font-medium">RPM Limit</p>
<p>
{userData.rpm_limit !== null && userData.rpm_limit !== undefined
? formatNumberWithCommas(userData.rpm_limit)
: "Unlimited"}
</p>
</div>
<div>
<p className="font-medium">Metadata</p>
<pre className="bg-muted p-2 rounded-sm text-xs overflow-auto mt-1">

View file

@ -78,6 +78,7 @@ import {
unwrapProxyErrorMessage,
} from "@/lib/http/client";
import { resolveApiBase } from "@/lib/http/resolveApiBase";
import type { components } from "@/lib/http/schema";
import {
registerAuthHeaderNameGetter,
registerAuthTokenGetter,
@ -1040,6 +1041,9 @@ export interface UserInfoV2Response {
models: string[];
budget_duration: string | null;
budget_reset_at: string | null;
tpm_limit: number | null;
rpm_limit: number | null;
max_parallel_requests: number | null;
metadata: Record<string, any> | null;
created_at: string | null;
updated_at: string | null;
@ -1050,6 +1054,31 @@ export interface UserInfoV2Response {
model_max_budget_usage?: Record<string, ModelBudgetUsage> | null;
}
/**
* Body of PATCH /management/v1/users/{user_id}: an omitted key is left alone, an explicit null
* clears the setting. Unknown keys come back as a 422, so this is taken from the generated spec
* rather than hand-written, which is what keeps a renamed field from turning into a silent no-op.
*/
export type UserPatchRequest = components["schemas"]["UserPatchRequest"];
/** The user row as the patch wrote it. Narrower than UserInfoV2Response: no keys, teams or usage. */
export type UserPatchResponse = components["schemas"]["UserItem"];
/**
* Partially update one internal user. Unlike userUpdateUserCall, a null here actually clears.
*/
export const userPatchCall = async (
accessToken: string,
userId: string,
patch: UserPatchRequest,
): Promise<UserPatchResponse> => {
const data = (await apiClient.patch(`/management/v1/users/${encodeURIComponent(userId)}`, {
accessToken,
body: patch,
})) as { data: UserPatchResponse };
return data.data;
};
/**
* Lightweight user info fetch from /v2/user/info.
* Returns only the user object no keys, no teams objects.

View file

@ -8111,6 +8111,42 @@ export interface paths {
patch?: never;
trace?: never;
};
"/management/v1/users/{user_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/**
* Patch User
* @description Partially update one internal user, as an RFC 7396 JSON merge patch.
*
* An omitted field is left alone and an explicit `null` clears the setting, which is the whole
* reason this route exists: `POST /user/update` drops nulls, so it answers `200` to a clear it
* silently discarded, and only `max_budget` was ever made clearable. Unknown body keys are
* refused with a `422` rather than ignored. `null` on `models`, `metadata` or `model_max_budget`
* resets the column to empty, since the schema declares those NOT NULL.
*
* Requires a proxy admin: the route is in no non-admin allowlist, so everyone else is refused at
* the route gate, and the shared write path's self-service guards stand behind that as defense in
* depth. Unlike `/user/update`, a user id that does not exist is a `404` rather than a silent
* create, since the underlying write is an upsert.
*
* Example curl, clearing a rate limit and setting another:
* ```
* curl --location --request PATCH 'http://0.0.0.0:4000/management/v1/users/user123' --header 'Authorization: Bearer sk-1234' --header 'Content-Type: application/json' --data '{"tpm_limit": null, "rpm_limit": 60}'
* ```
*/
patch: operations["patch_user_management_v1_users__user_id__patch"];
trace?: never;
};
"/mcp": {
parameters: {
query?: never;
@ -27151,6 +27187,10 @@ export interface components {
/** Is Accepted */
is_accepted: boolean;
};
/** ItemResponse[UserItem] */
ItemResponse_UserItem_: {
data: components["schemas"]["UserItem"];
};
/** JWTKeyMappingResponse */
JWTKeyMappingResponse: {
/**
@ -27178,6 +27218,7 @@ export interface components {
/** Updated By */
updated_by?: string | null;
};
JsonValue: unknown;
/** KeyHealthResponse */
KeyHealthResponse: {
/**
@ -32960,6 +33001,25 @@ export interface components {
*/
version_status: string;
};
/**
* ProblemDetail
* @description RFC 9457 problem details, served as `application/problem+json`.
*/
ProblemDetail: {
/**
* Allowed
* @default null
*/
allowed: string[] | null;
/** Detail */
detail: string;
/** Status */
status: number;
/** Title */
title: string;
/** Type */
type: string;
};
/** Prompt */
Prompt: {
litellm_params: components["schemas"]["PromptLiteLLMParams"];
@ -37497,6 +37557,8 @@ export interface components {
created_at?: string | null;
/** Max Budget */
max_budget?: number | null;
/** Max Parallel Requests */
max_parallel_requests?: number | null;
/** Metadata */
metadata?: {
[key: string]: unknown;
@ -37517,6 +37579,8 @@ export interface components {
*/
models: string[];
object_permission?: components["schemas"]["LiteLLM_ObjectPermissionTable"] | null;
/** Rpm Limit */
rpm_limit?: number | null;
/**
* Spend
* @default 0
@ -37529,6 +37593,60 @@ export interface components {
* @default []
*/
teams: string[];
/** Tpm Limit */
tpm_limit?: number | null;
/** Updated At */
updated_at?: string | null;
/** User Alias */
user_alias?: string | null;
/** User Email */
user_email?: string | null;
/** User Id */
user_id: string;
/** User Role */
user_role?: string | null;
};
/**
* UserItem
* @description One internal user as the control plane returns it, read back off the row the write produced.
*
* Re-reading rather than echoing the request is the point of the endpoint: a caller can tell a
* clear that landed from one that was dropped by looking at the response.
*/
UserItem: {
/** Budget Duration */
budget_duration?: string | null;
/** Budget Reset At */
budget_reset_at?: string | null;
/** Created At */
created_at?: string | null;
/** Max Budget */
max_budget?: number | null;
/** Max Parallel Requests */
max_parallel_requests?: number | null;
/** Metadata */
metadata?: {
[key: string]: components["schemas"]["JsonValue"];
};
/** Model Max Budget */
model_max_budget?: {
[key: string]: components["schemas"]["JsonValue"];
};
/** Models */
models?: string[];
/** Object Permission Id */
object_permission_id?: string | null;
/** Rpm Limit */
rpm_limit?: number | null;
/**
* Spend
* @default 0
*/
spend: number;
/** Teams */
teams?: string[];
/** Tpm Limit */
tpm_limit?: number | null;
/** Updated At */
updated_at?: string | null;
/** User Alias */
@ -37556,6 +37674,43 @@ export interface components {
/** Users */
users: components["schemas"]["LiteLLM_UserTableWithKeyCount"][];
};
/**
* UserPatchRequest
* @description Body of `PATCH /management/v1/users/{user_id}`, read as an RFC 7396 JSON merge patch.
*
* Every field is optional and nullable, and the two are not the same thing: an omitted field is
* left alone, an explicit `null` clears the setting. `extra="forbid"` is what makes that promise
* keepable, since a misspelled key would otherwise read as "omitted" and silently do nothing.
*/
UserPatchRequest: {
/** Budget Duration */
budget_duration?: string | null;
/** Max Budget */
max_budget?: number | null;
/** Max Parallel Requests */
max_parallel_requests?: number | null;
/** Metadata */
metadata?: {
[key: string]: components["schemas"]["JsonValue"];
} | null;
/** Model Max Budget */
model_max_budget?: {
[key: string]: components["schemas"]["JsonValue"];
} | null;
/** Models */
models?: string[] | null;
object_permission?: components["schemas"]["LiteLLM_ObjectPermissionBase"] | null;
/** Rpm Limit */
rpm_limit?: number | null;
/** Tpm Limit */
tpm_limit?: number | null;
/** User Alias */
user_alias?: string | null;
/** User Email */
user_email?: string | null;
/** User Role */
user_role?: ("proxy_admin" | "proxy_admin_viewer" | "internal_user" | "internal_user_viewer") | null;
};
/**
* UserUpdateResult
* @description Result of a single user update operation
@ -48588,6 +48743,78 @@ export interface operations {
};
};
};
patch_user_management_v1_users__user_id__patch: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The id of the user to update. */
user_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UserPatchRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ItemResponse_UserItem_"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetail"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetail"];
};
};
/** @description Invalid request body */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetail"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetail"];
};
};
/** @description Database not connected */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetail"];
};
};
};
};
aggregate_mcp_route_mcp_get: {
parameters: {
query?: never;