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.
This commit is contained in:
ryan-crabbe-berri 2026-09-16 17:25:36 -07:00
parent a36d2de5c9
commit 37c56df054
10 changed files with 184 additions and 32 deletions

View file

@ -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])

View file

@ -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"}

View file

@ -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}"
)

View file

@ -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)

View file

@ -10,7 +10,7 @@ const renderForm = (editableFields: ReadonlySet<string>, overrides: { isSaving?:
const onCancel = vi.fn();
renderWithProviders(
<TeamAdminSettingsForm
initialValues={{ tpm_limit: 1000 }}
initialValues={{ tpm_limit: 1000, rpm_limit: 50, max_budget: 20 }}
editableFields={editableFields}
isSaving={overrides.isSaving ?? false}
onCancel={onCancel}
@ -21,16 +21,20 @@ const renderForm = (editableFields: ReadonlySet<string>, 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"]));

View file

@ -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<Record<TeamAdminSettingsField, number>> = { tpm_limit: 1, rpm_limit: 1, max_budget: 0.01 };
interface TeamAdminSettingsFormProps {
initialValues: TeamAdminSettingsValues;
editableFields: ReadonlySet<string>;
@ -48,11 +56,13 @@ export default function TeamAdminSettingsForm({
<p className="text-sm text-muted-foreground">
A proxy admin chose which settings team admins can change. Ask a proxy admin to change anything else.
</p>
{editableFields.has("tpm_limit") && (
<FormField control={form.control} name="tpm_limit" label={teamAdminFieldLabel("tpm_limit")}>
{({ ref, value, ...field }) => <NumericalInput {...field} ref={ref} value={value ?? ""} step={1} />}
{TEAM_ADMIN_SETTINGS_FIELDS.filter((name) => editableFields.has(name)).map((name) => (
<FormField key={name} control={form.control} name={name} label={teamAdminFieldLabel(name)}>
{({ ref, value, ...field }) => (
<NumericalInput {...field} ref={ref} value={value ?? ""} step={INPUT_STEP[name]} />
)}
</FormField>
)}
))}
</FieldGroup>
<div className="mt-6 flex items-center justify-end gap-2">

View file

@ -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(<TeamInfoView {...teamAdminProps} />);
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(

View file

@ -1156,7 +1156,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const teamAdminSettingsEditor =
teamEditAccess.kind === "team_admin" ? (
<TeamAdminSettingsForm
initialValues={{ tpm_limit: info.tpm_limit }}
initialValues={{ tpm_limit: info.tpm_limit, rpm_limit: info.rpm_limit, max_budget: info.max_budget }}
editableFields={teamEditAccess.editableFields}
isSaving={isTeamSaving}
onCancel={() => setIsEditing(false)}

View file

@ -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", () => {

View file

@ -39,17 +39,21 @@ export const parseSupportedTeamAdminEditableFields = (uiSettingsFieldSchema: unk
return items.success ? fieldListSchema.parse(items.data.enum) : [];
};
const TEAM_ADMIN_FIELD_LABELS: ReadonlyMap<string, string> = 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<string, string> = 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<string>,
): 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);