mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
0ace6e7ed5
commit
4dd93ae8aa
12 changed files with 758 additions and 41 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }),
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
227
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
227
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue