From 7080d51c39b5ede04dfbeeb7fbba8efe93993e48 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 27 Aug 2026 18:14:46 -0700 Subject: [PATCH 1/2] 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. --- litellm/proxy/_types.py | 3 + .../internal_user_endpoints.py | 3 + .../test_internal_user_endpoints.py | 42 ++++++ .../_components/userPatchPayload.test.ts | 99 +++++++++++++ .../users/_components/userPatchPayload.ts | 47 +++++++ .../users/_components/user_edit_view.test.tsx | 90 +++++++++++- .../users/_components/user_edit_view.tsx | 63 ++++++++- .../user_info_view.integration.test.tsx | 14 +- .../view_users/user_info_view.test.tsx | 132 ++++++++++++++++-- .../_components/view_users/user_info_view.tsx | 50 ++++--- .../src/components/networking.tsx | 29 ++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 58 ++++++++ 12 files changed, 589 insertions(+), 41 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/users/_components/userPatchPayload.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/users/_components/userPatchPayload.ts diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index f40b0632398..c7509e4485c 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -3025,6 +3025,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 diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 9e5fc6e7aff..559770e8eda 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -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"), diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index d8aea3268c8..d6aadf9c6b9 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -2669,6 +2669,45 @@ async def test_user_info_v2_proxy_admin_can_query_any_user(mocker, user_rows): 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): """ @@ -3012,6 +3051,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", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userPatchPayload.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userPatchPayload.test.ts new file mode 100644 index 00000000000..ec3de8b826d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userPatchPayload.test.ts @@ -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, + }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userPatchPayload.ts b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userPatchPayload.ts new file mode 100644 index 00000000000..1ce812febff --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/userPatchPayload.ts @@ -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 | 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 }), +}); 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 8fb94ce477e..6b61e83cbe6 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 @@ -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( + , + ); + + 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(); + + 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( + , + ); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); @@ -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", 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 96771dc6dc4..c369277c67f 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 @@ -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 }) => } + {/* 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 && ( + <> + + {({ ref, value, onChange, ...control }) => ( + onChange(event.target.value)} + onWheel={(event) => event.currentTarget.blur()} + placeholder="Unlimited" + /> + )} + + + + {({ ref, value, onChange, ...control }) => ( + onChange(event.target.value)} + onWheel={(event) => event.currentTarget.blur()} + placeholder="Unlimited" + /> + )} + + + )} + {/* 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 && ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx index 69254b1ffe4..497a16e85e4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx @@ -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" }, }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx index c2b2be5b063..573597bd543 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx @@ -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(); + + 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(); + + 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(); + + 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) => Promise, + stored: Record = {}, + ) => { + const user = userEvent.setup(); + mockUserGetInfoV2.mockResolvedValue({ ...MOCK_USER_DATA, ...stored }); + render(); + + 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(); + + 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(); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx index eed39c8e585..f161eb82570 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx @@ -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({

{getBudgetDurationLabel(userData.budget_duration ?? null)}

+
+

TPM Limit

+

+ {userData.tpm_limit !== null && userData.tpm_limit !== undefined + ? formatNumberWithCommas(userData.tpm_limit) + : "Unlimited"} +

+
+ +
+

RPM Limit

+

+ {userData.rpm_limit !== null && userData.rpm_limit !== undefined + ? formatNumberWithCommas(userData.rpm_limit) + : "Unlimited"} +

+
+

Metadata

diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx
index d1688822dea..7ea3924fcfe 100644
--- a/ui/litellm-dashboard/src/components/networking.tsx
+++ b/ui/litellm-dashboard/src/components/networking.tsx
@@ -99,6 +99,7 @@ import {
   unwrapProxyErrorMessage,
 } from "@/lib/http/client";
 import { resolveApiBase } from "@/lib/http/resolveApiBase";
+import type { components } from "@/lib/http/schema";
 import {
   registerAuthHeaderNameGetter,
   registerAuthTokenGetter,
@@ -1061,6 +1062,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 | null;
   created_at: string | null;
   updated_at: string | null;
@@ -1071,6 +1075,31 @@ export interface UserInfoV2Response {
   model_max_budget_usage?: Record | 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 => {
+  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.
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index c7c6e6bdc1d..a218b6de007 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -37708,6 +37708,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;
@@ -37728,6 +37730,8 @@ export interface components {
              */
             models: string[];
             object_permission?: components["schemas"]["LiteLLM_ObjectPermissionTable"] | null;
+            /** Rpm Limit */
+            rpm_limit?: number | null;
             /**
              * Spend
              * @default 0
@@ -37740,6 +37744,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 */

From ae263fdcfccbae2f218c795be8e604ebe3e60093 Mon Sep 17 00:00:00 2001
From: ryan-crabbe-berri 
Date: Fri, 28 Aug 2026 12:46:58 -0700
Subject: [PATCH 2/2] fix(ui): type the patch response's JSON columns as the
 shapes the details panel reads

The generated UserItem calls metadata and model_max_budget free-form objects, which is true
of the columns and not assignable to what the details panel reads, so the response type now
restates them as the shapes /v2/user/info already claims.
---
 .../test_internal_user_endpoints.py           | 26 ++++------
 .../src/components/networking.tsx             | 13 ++++-
 ui/litellm-dashboard/src/lib/http/schema.d.ts | 52 -------------------
 3 files changed, 22 insertions(+), 69 deletions(-)

diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
index d6aadf9c6b9..5b47654474c 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
@@ -2670,7 +2670,7 @@ async def test_user_info_v2_proxy_admin_can_query_any_user(mocker, user_rows):
 
 
 @pytest.mark.asyncio
-async def test_user_info_v2_returns_rate_limits(mocker):
+async def test_user_info_v2_returns_rate_limits(mocker, user_rows):
     """
     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.
@@ -2679,21 +2679,17 @@ async def test_user_info_v2_returns_rate_limits(mocker):
 
     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
+    user_rows(
+        **{
+            "limited-user": {
+                "user_id": "limited-user",
+                "tpm_limit": 12000,
+                "rpm_limit": 60,
+                "max_parallel_requests": 3,
+                "teams": [],
+            }
+        }
     )
-    mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
 
     response = await user_info_v2(
         request=mocker.MagicMock(spec=Request),
diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx
index 7ea3924fcfe..393c185ee85 100644
--- a/ui/litellm-dashboard/src/components/networking.tsx
+++ b/ui/litellm-dashboard/src/components/networking.tsx
@@ -1082,8 +1082,17 @@ export interface UserInfoV2Response {
  */
 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"];
+/**
+ * The user row as the patch wrote it. Narrower than UserInfoV2Response: no keys, teams or usage.
+ *
+ * The two JSON columns are re-stated as the shapes the rest of the dashboard reads them as. The
+ * generated spec calls them free-form objects, which is true of the column and useless to a caller,
+ * and it is the same claim /v2/user/info already makes about the same two columns.
+ */
+export type UserPatchResponse = Omit & {
+  metadata?: Record;
+  model_max_budget?: ModelMaxBudget;
+};
 
 /**
  * Partially update one internal user. Unlike userUpdateUserCall, a null here actually clears.
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index a218b6de007..876ebe1c563 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -37809,58 +37809,6 @@ export interface components {
             /** 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 */
-            user_alias?: string | null;
-            /** User Email */
-            user_email?: string | null;
-            /** User Id */
-            user_id: string;
-            /** User Role */
-            user_role?: string | null;
-        };
         /**
          * UserListResponse
          * @description Response model for the user list endpoint