From 37c56df054510190da8c42ace4404e943034e8ad Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 16 Sep 2026 17:25:36 -0700 Subject: [PATCH] feat(proxy): let team admins edit rpm_limit and max_budget when enabled Adds both fields to the team admin editable allow-list and the dashboard's team admin form. The existing budget authority check still stops a team admin from raising or removing a standalone team's budget. --- .../team_admin_field_permissions.py | 2 +- tests/e2e/coverage_registry/mgmt.yaml | 1 + .../management/test_team_management_e2e.py | 76 ++++++++++++++++++- .../test_proxy_setting_endpoints.py | 8 +- .../team/TeamAdminSettingsForm.test.tsx | 25 ++++-- .../components/team/TeamAdminSettingsForm.tsx | 20 +++-- .../src/components/team/TeamInfo.test.tsx | 20 +++++ .../src/components/team/TeamInfo.tsx | 2 +- .../team/teamAdminEditAccess.test.ts | 31 +++++++- .../components/team/teamAdminEditAccess.ts | 31 ++++---- 10 files changed, 184 insertions(+), 32 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_admin_field_permissions.py b/litellm/proxy/management_endpoints/team_admin_field_permissions.py index 77e3768b702..56d455494c6 100644 --- a/litellm/proxy/management_endpoints/team_admin_field_permissions.py +++ b/litellm/proxy/management_endpoints/team_admin_field_permissions.py @@ -20,7 +20,7 @@ from litellm.proxy._types import ( TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING: Final = "team_admin_editable_team_fields" # TODO(LIT-5722): add the remaining team settings one per PR, each with its value-diff tests and dashboard field -SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS: Final[frozenset[str]] = frozenset({"tpm_limit"}) +SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS: Final[frozenset[str]] = frozenset({"tpm_limit", "rpm_limit", "max_budget"}) _FIELD_LIST: Final = TypeAdapter(list[str]) _JSON_OBJECT: Final = TypeAdapter(dict[str, object]) diff --git a/tests/e2e/coverage_registry/mgmt.yaml b/tests/e2e/coverage_registry/mgmt.yaml index d93d2b2cc67..0b7987c6ffb 100644 --- a/tests/e2e/coverage_registry/mgmt.yaml +++ b/tests/e2e/coverage_registry/mgmt.yaml @@ -32,6 +32,7 @@ - {id: mgmt.team.update.persists, module: mgmt, tier: P1, surface: api, assertions: [persists], source: "team_endpoints.py:1582", rationale: "Metadata/budget updates persist"} - {id: mgmt.team.update.team_admin_forbidden_until_enabled, module: mgmt, tier: P0, surface: api, assertions: [team_admin_forbidden_until_enabled], source: "team_admin_field_permissions.py:156", rationale: "With no team admin editable fields enabled, a team admin's /team/update is 403 and /team/info reports editing disabled"} - {id: mgmt.team.update.team_admin_limited_to_enabled_fields, module: mgmt, tier: P0, surface: api, assertions: [team_admin_limited_to_enabled_fields], source: "team_admin_field_permissions.py:156", rationale: "A team admin may change only the enabled fields; a request that also changes any other field is 403 and writes nothing"} +- {id: mgmt.team.update.team_admin_cannot_grow_budget, module: mgmt, tier: P0, surface: api, assertions: [team_admin_cannot_grow_budget], source: "team_endpoints.py:1203", rationale: "With max_budget enabled, a team admin may keep or lower a standalone team's budget; raising or removing it is 403 and writes nothing"} - {id: mgmt.team.update.team_admin_resend_keeps_budget_reset, module: mgmt, tier: P1, surface: api, assertions: [team_admin_resend_keeps_budget_reset], source: "team_admin_field_permissions.py:147", fail_before_fix: proven, rationale: "A team admin resending unchanged budget settings with an enabled field must not push the team's budget reset times back"} - {id: mgmt.team.delete.persists, module: mgmt, tier: P1, surface: api, assertions: [persists], source: "team_endpoints.py:1750", rationale: "Deletion prevents key access"} - {id: mgmt.team.block.persists, module: mgmt, tier: P1, surface: api, assertions: [persists], source: "team_endpoints.py", rationale: "Block suspends all members"} diff --git a/tests/e2e/management/test_team_management_e2e.py b/tests/e2e/management/test_team_management_e2e.py index f21931b6ff1..60e0047015c 100644 --- a/tests/e2e/management/test_team_management_e2e.py +++ b/tests/e2e/management/test_team_management_e2e.py @@ -45,6 +45,7 @@ pytestmark = pytest.mark.e2e TeamRole = Literal["admin", "user"] _TEAM_TPM_LIMIT: Final = 1000 +_TEAM_MAX_BUDGET: Final = 10.0 class TeamBlockBody(BaseModel): @@ -114,6 +115,7 @@ class TeamInfoRead(BaseModel): class TeamWithAdminNewBody(TeamNewBody): tpm_limit: int + max_budget: float | None = None members_with_roles: list[TeamMemberEntry] @@ -414,13 +416,22 @@ def tpm_limit_editable_by_team_admins(client: ManagementClient) -> Generator[Non yield -def _team_with_admin(client: ManagementClient, resources: ResourceManager) -> tuple[str, str]: +@pytest.fixture(scope="class") +def rpm_limit_and_max_budget_editable_by_team_admins(client: ManagementClient) -> Generator[None]: + with _team_admins_may_edit(client, ["rpm_limit", "max_budget"]): + yield + + +def _team_with_admin( + client: ManagementClient, resources: ResourceManager, max_budget: float | None = None +) -> tuple[str, str]: """A team with a tpm_limit, and the key of a user who is an admin of that team.""" admin_id = _create_user(client, resources, f"e2e-team-admin-{unique_marker()}@example.com") team_id = client.create_team( TeamWithAdminNewBody( team_alias=f"e2e-team-admin-{unique_marker()}", tpm_limit=_TEAM_TPM_LIMIT, + max_budget=max_budget, members_with_roles=[TeamMemberEntry(role="admin", user_id=admin_id)], ) ) @@ -580,3 +591,66 @@ class TestTeamAdminWithTpmLimitEnabled: assert after.budget_limits == budgeted.budget_limits, ( f"the team admin pushed the budget window resets from {budgeted.budget_limits} to {after.budget_limits}" ) + + +@pytest.mark.usefixtures("rpm_limit_and_max_budget_editable_by_team_admins") +class TestTeamAdminWithRpmLimitAndMaxBudgetEnabled: + """A proxy admin has enabled rpm_limit and max_budget, so a team admin may change the RPM limit and keep or + lower the team's budget. Raising or removing the budget stays with the proxy admin.""" + + @pytest.mark.covers("mgmt.team.update.team_admin_limited_to_enabled_fields") + def test_team_admin_saves_a_new_rpm_limit_and_a_lower_budget( + self, client: ManagementClient, resources: ResourceManager + ) -> None: + team_id, admin_key = _team_with_admin(client, resources, max_budget=_TEAM_MAX_BUDGET) + access = _read_team(client, team_id, admin_key).team_info.caller_edit_access + assert access == CallerEditAccess(kind="team_admin", editable_fields=["max_budget", "rpm_limit"]), ( + f"/team/info should list max_budget and rpm_limit as the team admin's editable fields, got {access}" + ) + before = _read_team(client, team_id).team_info + + outcome = _update_team_as( + client, admin_key, TeamSettingsUpdate(team_id=team_id, rpm_limit=50, max_budget=_TEAM_MAX_BUDGET / 2) + ) + + assert outcome.status_code == 200, ( + f"a team admin setting an RPM limit and lowering the budget must succeed, got {outcome.status_code}: " + f"{outcome.body[:300]}" + ) + after = _poll_team( + client, + team_id, + lambda info: info.rpm_limit == 50 and info.max_budget == _TEAM_MAX_BUDGET / 2, + f"/team/info never reflected rpm_limit=50 and max_budget={_TEAM_MAX_BUDGET / 2}", + ) + assert after.model_copy(update={"rpm_limit": before.rpm_limit, "max_budget": before.max_budget}) == before, ( + f"the update changed more than rpm_limit and max_budget: before {before}, after {after}" + ) + + @pytest.mark.covers("mgmt.team.update.team_admin_cannot_grow_budget") + @pytest.mark.parametrize( + ("max_budget", "refusal"), + [ + pytest.param(_TEAM_MAX_BUDGET * 2, "Only a proxy admin can raise", id="raise"), + pytest.param(None, "Only a proxy admin can remove", id="remove"), + ], + ) + def test_team_admin_cannot_raise_or_remove_the_budget( + self, client: ManagementClient, resources: ResourceManager, max_budget: float | None, refusal: str + ) -> None: + team_id, admin_key = _team_with_admin(client, resources, max_budget=_TEAM_MAX_BUDGET) + before = _read_team(client, team_id).team_info + + outcome = _update_team_as( + client, admin_key, TeamSettingsUpdate(team_id=team_id, rpm_limit=50, max_budget=max_budget) + ) + + assert outcome.status_code == 403, ( + f"a team admin changing max_budget from {_TEAM_MAX_BUDGET} to {max_budget} must be 403, " + f"got {outcome.status_code}: {outcome.body[:300]}" + ) + assert refusal in outcome.body, f"403 body should say {refusal!r}, got: {outcome.body[:300]}" + after = _read_team(client, team_id).team_info + assert after == before, ( + f"the refused update still wrote to the team, the rpm_limit included: before {before}, after {after}" + ) diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index 06df39ede99..8f17a1e45de 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -3324,15 +3324,17 @@ class TestTeamAdminEditableTeamFieldsSetting: general_settings: dict = {"team_admin_editable_team_fields": []} monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings) + enabled = ["tpm_limit", "rpm_limit", "max_budget"] + try: - response = client.patch("/update/ui_settings", json={"team_admin_editable_team_fields": ["tpm_limit"]}) + response = client.patch("/update/ui_settings", json={"team_admin_editable_team_fields": enabled}) finally: app.dependency_overrides.clear() assert response.status_code == 200 stored = json.loads(mock_prisma.db.litellm_uisettings.upsert.call_args.kwargs["data"]["create"]["ui_settings"]) - assert stored["team_admin_editable_team_fields"] == ["tpm_limit"] - assert general_settings["team_admin_editable_team_fields"] == ["tpm_limit"] + assert stored["team_admin_editable_team_fields"] == enabled + assert general_settings["team_admin_editable_team_fields"] == enabled def test_patch_with_an_empty_list_turns_team_admin_editing_off_again(self, monkeypatch): mock_prisma = self._as_proxy_admin(monkeypatch) diff --git a/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.test.tsx b/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.test.tsx index 677c8859eb2..5f3496be2a8 100644 --- a/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.test.tsx @@ -10,7 +10,7 @@ const renderForm = (editableFields: ReadonlySet, overrides: { isSaving?: const onCancel = vi.fn(); renderWithProviders( , overrides: { isSaving?: }; describe("TeamAdminSettingsForm", () => { - it("shows the team's current TPM limit when the proxy lets team admins edit it", () => { - renderForm(new Set(["tpm_limit"])); + it("shows the team's current values for every field the proxy lets team admins edit", () => { + renderForm(new Set(["tpm_limit", "rpm_limit", "max_budget"])); expect(screen.getByLabelText("Tokens per minute Limit (TPM)")).toHaveValue(1000); + expect(screen.getByLabelText("Requests per minute Limit (RPM)")).toHaveValue(50); + expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue(20); }); - it("hides the TPM limit when the proxy has not enabled it for team admins", () => { - renderForm(new Set(["max_budget"])); + it("hides the fields the proxy has not enabled for team admins", () => { + renderForm(new Set(["rpm_limit"])); + expect(screen.getByLabelText("Requests per minute Limit (RPM)")).toBeInTheDocument(); expect(screen.queryByLabelText("Tokens per minute Limit (TPM)")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Max Budget (USD)")).not.toBeInTheDocument(); }); it("saves the new TPM limit and nothing else", async () => { @@ -43,6 +47,17 @@ describe("TeamAdminSettingsForm", () => { await waitFor(() => expect(onSave).toHaveBeenCalledWith({ tpm_limit: 5000 })); }); + it("saves a lowered budget and a new RPM limit without resending the unchanged TPM limit", async () => { + const user = userEvent.setup(); + const { onSave } = renderForm(new Set(["tpm_limit", "rpm_limit", "max_budget"])); + + fireEvent.change(screen.getByLabelText("Requests per minute Limit (RPM)"), { target: { value: "80" } }); + fireEvent.change(screen.getByLabelText("Max Budget (USD)"), { target: { value: "12.5" } }); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => expect(onSave).toHaveBeenCalledWith({ rpm_limit: 80, max_budget: 12.5 })); + }); + it("saves a cleared TPM limit as no limit", async () => { const user = userEvent.setup(); const { onSave } = renderForm(new Set(["tpm_limit"])); diff --git a/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.tsx b/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.tsx index 140533fada5..ebd7a603bde 100644 --- a/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.tsx @@ -12,16 +12,24 @@ import { useZodForm } from "@/lib/forms/useZodForm"; import NumericalInput from "../shared/numerical_input"; import { + TEAM_ADMIN_SETTINGS_FIELDS, teamAdminFieldLabel, teamAdminSettingsChanges, type TeamAdminSettingsChanges, + type TeamAdminSettingsField, type TeamAdminSettingsValues, } from "./teamAdminEditAccess"; +const numericInputSchema = z.union([z.string(), z.number()]).nullish(); + const teamAdminSettingsSchema = z.object({ - tpm_limit: z.union([z.string(), z.number()]).nullish(), + tpm_limit: numericInputSchema, + rpm_limit: numericInputSchema, + max_budget: numericInputSchema, }); +const INPUT_STEP: Readonly> = { tpm_limit: 1, rpm_limit: 1, max_budget: 0.01 }; + interface TeamAdminSettingsFormProps { initialValues: TeamAdminSettingsValues; editableFields: ReadonlySet; @@ -48,11 +56,13 @@ export default function TeamAdminSettingsForm({

A proxy admin chose which settings team admins can change. Ask a proxy admin to change anything else.

- {editableFields.has("tpm_limit") && ( - - {({ ref, value, ...field }) => } + {TEAM_ADMIN_SETTINGS_FIELDS.filter((name) => editableFields.has(name)).map((name) => ( + + {({ ref, value, ...field }) => ( + + )} - )} + ))}
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index e954bc1c581..03553d664ba 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -1918,6 +1918,26 @@ describe("TeamInfoView", () => { expect(toast.error).not.toHaveBeenCalled(); }); + it("prefills the RPM limit and budget a team admin may edit with the team's stored values", async () => { + const user = userEvent.setup({ delay: null }); + vi.mocked(networking.teamInfoCall).mockResolvedValue( + createMockTeamData({ + rpm_limit: 50, + max_budget: 20, + caller_edit_access: { kind: "team_admin", editable_fields: ["rpm_limit", "max_budget"] }, + }), + ); + + renderWithProviders(); + + await user.click(await screen.findByRole("tab", { name: "Settings" })); + await user.click(await screen.findByRole("button", { name: /edit settings/i })); + + expect(await screen.findByLabelText("Requests per minute Limit (RPM)")).toHaveValue(50); + expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue(20); + expect(screen.getByRole("button", { name: /save changes/i })).toBeDisabled(); + }); + it("opens the form when the proxy reports unrestricted access although the props only mark a team admin", async () => { const user = userEvent.setup({ delay: null }); vi.mocked(networking.teamInfoCall).mockResolvedValue( diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 30b648fc53c..df7b06661c2 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -1156,7 +1156,7 @@ const TeamInfoView: React.FC = ({ const teamAdminSettingsEditor = teamEditAccess.kind === "team_admin" ? ( setIsEditing(false)} diff --git a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts index ded6d775ce8..da3f9bf8289 100644 --- a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts +++ b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts @@ -9,12 +9,16 @@ import { } from "./teamAdminEditAccess"; describe("teamAdminFieldLabel", () => { - it("names tpm_limit the way the team settings form does", () => { - expect(teamAdminFieldLabel("tpm_limit")).toBe("Tokens per minute Limit (TPM)"); + it.each([ + ["tpm_limit", "Tokens per minute Limit (TPM)"], + ["rpm_limit", "Requests per minute Limit (RPM)"], + ["max_budget", "Max Budget (USD)"], + ])("names %s the way the team settings form does", (field, label) => { + expect(teamAdminFieldLabel(field)).toBe(label); }); it("falls back to the raw field name for a field the dashboard has no label for", () => { - expect(teamAdminFieldLabel("max_budget")).toBe("max_budget"); + expect(teamAdminFieldLabel("team_alias")).toBe("team_alias"); }); }); @@ -46,6 +50,27 @@ describe("teamAdminSettingsChanges", () => { it("leaves tpm_limit out when the proxy did not enable it for team admins", () => { expect(teamAdminSettingsChanges({ tpm_limit: "5000" }, stored, new Set(["max_budget"]))).toStrictEqual({}); }); + + const allStored = { tpm_limit: 1000, rpm_limit: 10, max_budget: 20 }; + + it("sends every enabled field that changed and skips the ones that did not", () => { + const values = { tpm_limit: "1000", rpm_limit: "50", max_budget: "12.5" }; + const enabled = new Set(["tpm_limit", "rpm_limit", "max_budget"]); + + expect(teamAdminSettingsChanges(values, allStored, enabled)).toStrictEqual({ rpm_limit: 50, max_budget: 12.5 }); + }); + + it("sends a cleared max budget as no budget", () => { + expect(teamAdminSettingsChanges({ max_budget: "" }, allStored, new Set(["max_budget"]))).toStrictEqual({ + max_budget: null, + }); + }); + + it("leaves out changed fields the proxy did not enable", () => { + const values = { tpm_limit: "5000", rpm_limit: "50", max_budget: "5" }; + + expect(teamAdminSettingsChanges(values, allStored, new Set(["rpm_limit"]))).toStrictEqual({ rpm_limit: 50 }); + }); }); describe("parseTeamAdminEditableFields", () => { diff --git a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts index 73129923907..b878af03df6 100644 --- a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts +++ b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts @@ -39,17 +39,21 @@ export const parseSupportedTeamAdminEditableFields = (uiSettingsFieldSchema: unk return items.success ? fieldListSchema.parse(items.data.enum) : []; }; -const TEAM_ADMIN_FIELD_LABELS: ReadonlyMap = new Map([["tpm_limit", "Tokens per minute Limit (TPM)"]]); +export const TEAM_ADMIN_SETTINGS_FIELDS = ["tpm_limit", "rpm_limit", "max_budget"] as const; + +export type TeamAdminSettingsField = (typeof TEAM_ADMIN_SETTINGS_FIELDS)[number]; + +const TEAM_ADMIN_FIELD_LABELS: ReadonlyMap = new Map([ + ["tpm_limit", "Tokens per minute Limit (TPM)"], + ["rpm_limit", "Requests per minute Limit (RPM)"], + ["max_budget", "Max Budget (USD)"], +]); export const teamAdminFieldLabel = (field: string): string => TEAM_ADMIN_FIELD_LABELS.get(field) ?? field; -export interface TeamAdminSettingsValues { - readonly tpm_limit?: string | number | null; -} +export type TeamAdminSettingsValues = { readonly [F in TeamAdminSettingsField]?: string | number | null }; -export interface TeamAdminSettingsChanges { - readonly tpm_limit?: number | null; -} +export type TeamAdminSettingsChanges = { readonly [F in TeamAdminSettingsField]?: number | null }; const numberOrNull = (value: string | number | null | undefined): number | null => { if (value === null || value === undefined || String(value).trim() === "") return null; @@ -61,12 +65,13 @@ export const teamAdminSettingsChanges = ( values: TeamAdminSettingsValues, initialValues: TeamAdminSettingsValues, editableFields: ReadonlySet, -): TeamAdminSettingsChanges => { - const tpmLimit = numberOrNull(values.tpm_limit); - return editableFields.has("tpm_limit") && tpmLimit !== numberOrNull(initialValues.tpm_limit) - ? { tpm_limit: tpmLimit } - : {}; -}; +): TeamAdminSettingsChanges => + Object.fromEntries( + TEAM_ADMIN_SETTINGS_FIELDS.flatMap((field) => { + const value = numberOrNull(values[field]); + return editableFields.has(field) && value !== numberOrNull(initialValues[field]) ? [[field, value]] : []; + }), + ); export const parseTeamEditAccess = (callerEditAccess: unknown): TeamEditAccess => { const parsed = callerEditAccessSchema.safeParse(callerEditAccess);