feat(proxy): let team admins edit tpm_limit when a proxy admin enables it

tpm_limit is the first field in the team-admin allow-list registry. The team
settings tab gives a team admin a form with only the enabled fields and sends
only those on save, and UI Settings labels the checkbox the same way
This commit is contained in:
ryan-crabbe-berri 2026-09-16 11:09:41 -07:00
parent d233043b05
commit a44a58e91a
13 changed files with 291 additions and 28 deletions

View file

@ -19,8 +19,8 @@ from litellm.proxy._types import (
TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING: Final = "team_admin_editable_team_fields"
# TODO(LIT-5722): stays empty until each field's value-diff and dashboard wiring lands, one field per PR
SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS: Final[frozenset[str]] = frozenset()
# 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"})
_FIELD_LIST: Final = TypeAdapter(list[str])
_JSON_OBJECT: Final = TypeAdapter(dict[str, object])

View file

@ -13,7 +13,7 @@ pytestmark = pytest.mark.asyncio(loop_scope="session")
# update_team and denials are the handler's 403, never the route gate's 401.
# Only PROXY_ADMIN and an ORG_ADMIN of the team's org pass: a team admin is
# admitted by _resolve_team_access but then refused because no team field is
# enabled for team admins (team_admin_editable_team_fields ships empty).
# enabled for team admins (team_admin_editable_team_fields defaults to empty).
MARKER_ALIAS = "behavior-pin-update-marker-alias"
_MATRIX = [

View file

@ -80,8 +80,8 @@ client = TestClient(app)
def _team_admin_may_edit(*fields: str):
"""Let team admins change ``fields`` on /team/update for the duration of the block.
The registry ships empty (LIT-5722 adds fields one PR at a time), so tests that exercise the
gates layered underneath the allow-list widen it here instead of asserting the early 403."""
The registry only lists the fields shipped so far (LIT-5722 adds them one PR at a time), so tests that
exercise the gates layered underneath the allow-list widen it here instead of asserting the early 403."""
with (
patch( # test-quality-ok: the registry is a module constant update_team reads directly; no seam to inject
"litellm.proxy.management_endpoints.team_endpoints.SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS",
@ -15048,6 +15048,36 @@ async def test_update_team_team_admin_echoing_unpermitted_fields_unchanged_is_al
assert prisma.db.litellm_teamtable.update.called
@pytest.mark.asyncio
async def test_update_team_team_admin_changes_tpm_limit_once_a_proxy_admin_enables_it(
disable_audit_logging_for_mocked_team,
):
"""tpm_limit is the first field a proxy admin can open to team admins; every other field stays admin-only."""
import contextlib
with contextlib.ExitStack() as stack:
prisma = _wire_update_team(stack, {})
stack.enter_context(
patch("litellm.proxy.proxy_server.general_settings", {"team_admin_editable_team_fields": ["tpm_limit"]}) # test-quality-ok: update_team reads general_settings as a proxy_server module global
)
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", tpm_limit=5000),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
with pytest.raises(ProxyException) as refused:
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", tpm_limit=6000, rpm_limit=10),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
assert prisma.db.litellm_teamtable.update.await_count == 1
assert prisma.db.litellm_teamtable.update.call_args.kwargs["data"]["tpm_limit"] == 5000
assert str(refused.value.code) == "403"
assert "'rpm_limit'" in str(refused.value.message)
@pytest.mark.asyncio
async def test_update_team_org_admin_is_not_filtered_by_the_team_admin_field_list(
disable_audit_logging_for_mocked_team,

View file

@ -3321,11 +3321,7 @@ class TestTeamAdminEditableTeamFieldsSetting:
def test_patch_persists_and_syncs_the_list_to_general_settings(self, monkeypatch):
mock_prisma = self._as_proxy_admin(monkeypatch)
monkeypatch.setattr(
"litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS",
frozenset({"tpm_limit", "rpm_limit"}),
)
general_settings: dict = {"team_admin_editable_team_fields": ["rpm_limit"]}
general_settings: dict = {"team_admin_editable_team_fields": []}
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings)
try:
@ -3373,7 +3369,7 @@ class TestTeamAdminEditableTeamFieldsSetting:
field_schema = data["field_schema"]["properties"]["team_admin_editable_team_fields"]
assert field_schema["type"] == "array"
assert field_schema["items"]["type"] == "string"
assert isinstance(field_schema["items"]["enum"], list)
assert "tpm_limit" in field_schema["items"]["enum"]
class TestSyncUiSettingsToGeneralSettings:

View file

@ -21,7 +21,7 @@ describe("TeamAdminEditableFieldsSettings", () => {
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
});
it("renders one checkbox per supported field, checked for the enabled ones", () => {
it("renders one checkbox per supported field, checked for the enabled ones and named by the field's form label when the dashboard has one", () => {
renderWithProviders(
<TeamAdminEditableFieldsSettings
editableFields={["tpm_limit"]}
@ -35,7 +35,7 @@ describe("TeamAdminEditableFieldsSettings", () => {
expect(screen.getByText("1 field enabled")).toBeInTheDocument();
expect(screen.getByText("Fields a team admin may change")).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: "max_budget" })).not.toBeChecked();
expect(screen.getByRole("checkbox", { name: "tpm_limit" })).toBeChecked();
expect(screen.getByRole("checkbox", { name: "Tokens per minute Limit (TPM)" })).toBeChecked();
});
it("saves the list with the field added when an unchecked field is ticked", async () => {
@ -67,7 +67,7 @@ describe("TeamAdminEditableFieldsSettings", () => {
/>,
);
await user.click(screen.getByRole("checkbox", { name: "tpm_limit" }));
await user.click(screen.getByRole("checkbox", { name: "Tokens per minute Limit (TPM)" }));
expect(onUpdate).toHaveBeenCalledWith({ team_admin_editable_team_fields: ["max_budget"] });
});
@ -84,7 +84,7 @@ describe("TeamAdminEditableFieldsSettings", () => {
/>,
);
await user.click(screen.getByRole("checkbox", { name: "tpm_limit" }));
await user.click(screen.getByRole("checkbox", { name: "Tokens per minute Limit (TPM)" }));
expect(onUpdate).not.toHaveBeenCalled();
});

View file

@ -2,6 +2,7 @@
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { teamAdminFieldLabel } from "@/components/team/teamAdminEditAccess";
interface TeamAdminEditableFieldsSettingsProps {
editableFields: readonly string[];
@ -53,7 +54,7 @@ export default function TeamAdminEditableFieldsSettings({
disabled={isUpdating}
onCheckedChange={(checked) => toggleField(field, checked === true)}
/>
<span className="text-sm text-foreground">{field}</span>
<span className="text-sm text-foreground">{teamAdminFieldLabel(field)}</span>
</label>
);
})}

View file

@ -187,7 +187,7 @@ describe("UISettings", () => {
expect(screen.getByText("Team settings fields a team admin may change")).toBeInTheDocument();
act(() => {
fireEvent.click(screen.getByRole("checkbox", { name: "tpm_limit" }));
fireEvent.click(screen.getByRole("checkbox", { name: "Tokens per minute Limit (TPM)" }));
});
expect(mutateMock).toHaveBeenCalledWith(

View file

@ -0,0 +1,72 @@
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { fireEvent, renderWithProviders, screen, waitFor } from "@/../tests/test-utils";
import TeamAdminSettingsForm from "./TeamAdminSettingsForm";
const renderForm = (editableFields: ReadonlySet<string>, overrides: { isSaving?: boolean } = {}) => {
const onSave = vi.fn().mockResolvedValue(undefined);
const onCancel = vi.fn();
renderWithProviders(
<TeamAdminSettingsForm
initialValues={{ tpm_limit: 1000 }}
editableFields={editableFields}
isSaving={overrides.isSaving ?? false}
onCancel={onCancel}
onSave={onSave}
/>,
);
return { onSave, onCancel };
};
describe("TeamAdminSettingsForm", () => {
it("shows the team's current TPM limit when the proxy lets team admins edit it", () => {
renderForm(new Set(["tpm_limit"]));
expect(screen.getByLabelText("Tokens per minute Limit (TPM)")).toHaveValue(1000);
});
it("hides the TPM limit when the proxy has not enabled it for team admins", () => {
renderForm(new Set(["max_budget"]));
expect(screen.queryByLabelText("Tokens per minute Limit (TPM)")).not.toBeInTheDocument();
});
it("saves the new TPM limit and nothing else", async () => {
const user = userEvent.setup();
const { onSave } = renderForm(new Set(["tpm_limit"]));
fireEvent.change(screen.getByLabelText("Tokens per minute Limit (TPM)"), { target: { value: "5000" } });
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ tpm_limit: 5000 }));
});
it("saves a cleared TPM limit as no limit", async () => {
const user = userEvent.setup();
const { onSave } = renderForm(new Set(["tpm_limit"]));
fireEvent.change(screen.getByLabelText("Tokens per minute Limit (TPM)"), { target: { value: "" } });
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ tpm_limit: null }));
});
it("closes without saving on cancel", async () => {
const user = userEvent.setup();
const { onSave, onCancel } = renderForm(new Set(["tpm_limit"]));
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onSave).not.toHaveBeenCalled();
});
it("locks both buttons while a save is in flight", () => {
renderForm(new Set(["tpm_limit"]), { isSaving: true });
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
expect(screen.getByRole("button", { name: /save changes/i })).toBeDisabled();
});
});

View file

@ -0,0 +1,66 @@
"use client";
import { Save } from "lucide-react";
import { z } from "zod/v4";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { FieldGroup } from "@/components/ui/field";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { useZodForm } from "@/lib/forms/useZodForm";
import NumericalInput from "../shared/numerical_input";
import {
teamAdminFieldLabel,
teamAdminSettingsChanges,
type TeamAdminSettingsChanges,
type TeamAdminSettingsValues,
} from "./teamAdminEditAccess";
const teamAdminSettingsSchema = z.object({
tpm_limit: z.union([z.string(), z.number()]).nullish(),
});
interface TeamAdminSettingsFormProps {
initialValues: TeamAdminSettingsValues;
editableFields: ReadonlySet<string>;
isSaving: boolean;
onCancel: () => void;
onSave: (changes: TeamAdminSettingsChanges) => Promise<void>;
}
export default function TeamAdminSettingsForm({
initialValues,
editableFields,
isSaving,
onCancel,
onSave,
}: TeamAdminSettingsFormProps) {
const form = useZodForm(teamAdminSettingsSchema, { defaultValues: initialValues });
const submit = form.handleSubmit((values) => onSave(teamAdminSettingsChanges(values, editableFields)));
return (
<form onSubmit={(event) => void submit(event)}>
<FieldGroup>
<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} />}
</FormField>
)}
</FieldGroup>
<div className="mt-6 flex items-center justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSaving}>
Cancel
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? <UiLoadingSpinner className="size-4" /> : <Save className="size-4" />}
Save Changes
</Button>
</div>
</form>
);
}

View file

@ -1889,18 +1889,32 @@ describe("TeamInfoView", () => {
expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument();
});
it("opens the form for a team admin once the proxy reports an enabled field", async () => {
it("gives a team admin only the fields the proxy enabled and sends only those on save", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({ caller_edit_access: { kind: "team_admin", editable_fields: ["tpm_limit"] } }),
createMockTeamData({
tpm_limit: 1000,
caller_edit_access: { kind: "team_admin", editable_fields: ["tpm_limit"] },
}),
);
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
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("Team Name")).toBeInTheDocument();
const tpmInput = await screen.findByLabelText("Tokens per minute Limit (TPM)");
expect(tpmInput).toHaveValue(1000);
expect(screen.queryByLabelText("Team Name")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Requests per minute Limit (RPM)")).not.toBeInTheDocument();
fireEvent.change(tpmInput, { target: { value: "5000" } });
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalledTimes(1));
expect(vi.mocked(networking.teamUpdateCall).mock.calls[0][1]).toStrictEqual({ team_id: "123", tpm_limit: 5000 });
expect(toast.success).toHaveBeenCalledWith("Team settings updated successfully");
expect(toast.error).not.toHaveBeenCalled();
});

View file

@ -53,7 +53,9 @@ import {
parseTeamEditAccess,
TEAM_ADMIN_EDITING_DISABLED_DESCRIPTION,
TEAM_ADMIN_EDITING_DISABLED_TITLE,
type TeamAdminSettingsChanges,
} from "./teamAdminEditAccess";
import TeamAdminSettingsForm from "./TeamAdminSettingsForm";
import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils";
import AccessGroupSelector from "../common_components/AccessGroupSelector";
import BudgetDurationDropdown, { NEVER_RESETS_BUDGET_DURATION } from "../common_components/budget_duration_dropdown";
@ -862,6 +864,27 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
setMemberToDelete(null);
};
const persistTeamUpdate = async (token: string, updateData: Record<string, unknown>) => {
await teamUpdateCall(token, updateData);
queryClient.invalidateQueries({ queryKey: organizationKeys.all });
toast.success("Team settings updated successfully");
setIsEditing(false);
fetchTeamInfo();
};
const saveTeamAdminSettings = async (changes: TeamAdminSettingsChanges) => {
if (!accessToken) return;
setIsTeamSaving(true);
try {
await persistTeamUpdate(accessToken, { team_id: teamId, ...changes });
} catch (error) {
console.error("Error updating team:", error);
} finally {
setIsTeamSaving(false);
}
};
const handleTeamUpdate = async (values: any) => {
try {
if (!accessToken) return;
@ -1112,12 +1135,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
}
}
await teamUpdateCall(accessToken, updateData);
queryClient.invalidateQueries({ queryKey: organizationKeys.all });
toast.success("Team settings updated successfully");
setIsEditing(false);
fetchTeamInfo();
await persistTeamUpdate(accessToken, updateData);
} catch (error) {
console.error("Error updating team:", error);
} finally {
@ -1135,6 +1153,17 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const { team_info: info } = teamData;
const teamAdminSettingsEditor =
teamEditAccess.kind === "team_admin" ? (
<TeamAdminSettingsForm
initialValues={{ tpm_limit: info.tpm_limit }}
editableFields={teamEditAccess.editableFields}
isSaving={isTeamSaving}
onCancel={() => setIsEditing(false)}
onSave={saveTeamAdminSettings}
/>
) : null;
const inheritedMcpServers = computeInheritedGrants(
info.access_group_mcp_server_ids,
info.access_group_details,
@ -1347,8 +1376,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
)}
</div>
{isEditing && isGuardrailsLoading ? (
<div className="p-4">Loading...</div>
{isEditing && (teamAdminSettingsEditor !== null || isGuardrailsLoading) ? (
teamAdminSettingsEditor ?? <div className="p-4">Loading...</div>
) : isEditing ? (
<TooltipProvider>
<form onSubmit={(event) => void form.handleSubmit(onTeamUpdateSubmit)(event)}>

View file

@ -4,8 +4,40 @@ import {
parseSupportedTeamAdminEditableFields,
parseTeamAdminEditableFields,
parseTeamEditAccess,
teamAdminFieldLabel,
teamAdminSettingsChanges,
} 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("falls back to the raw field name for a field the dashboard has no label for", () => {
expect(teamAdminFieldLabel("max_budget")).toBe("max_budget");
});
});
describe("teamAdminSettingsChanges", () => {
const tpmEnabled = new Set(["tpm_limit"]);
it.each([
["a typed number string", "5000", 5000],
["a stored number", 1200, 1200],
["zero", "0", 0],
["an emptied input", "", null],
["whitespace", " ", null],
["no stored limit", null, null],
["an unset value", undefined, null],
])("sends tpm_limit for %s", (_label, tpm_limit, expected) => {
expect(teamAdminSettingsChanges({ tpm_limit }, tpmEnabled)).toStrictEqual({ tpm_limit: expected });
});
it("leaves tpm_limit out when the proxy did not enable it for team admins", () => {
expect(teamAdminSettingsChanges({ tpm_limit: "5000" }, new Set(["max_budget"]))).toStrictEqual({});
});
});
describe("parseTeamAdminEditableFields", () => {
it("returns the configured list", () => {
expect(parseTeamAdminEditableFields({ team_admin_editable_team_fields: ["tpm_limit", "rpm_limit"] })).toEqual([

View file

@ -39,6 +39,29 @@ 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 teamAdminFieldLabel = (field: string): string => TEAM_ADMIN_FIELD_LABELS.get(field) ?? field;
export interface TeamAdminSettingsValues {
readonly tpm_limit?: string | number | null;
}
export interface TeamAdminSettingsChanges {
readonly tpm_limit?: number | null;
}
const numberOrNull = (value: string | number | null | undefined): number | null => {
if (value === null || value === undefined || String(value).trim() === "") return null;
const parsed = Number(value);
return Number.isNaN(parsed) ? null : parsed;
};
export const teamAdminSettingsChanges = (
values: TeamAdminSettingsValues,
editableFields: ReadonlySet<string>,
): TeamAdminSettingsChanges => (editableFields.has("tpm_limit") ? { tpm_limit: numberOrNull(values.tpm_limit) } : {});
export const parseTeamEditAccess = (callerEditAccess: unknown): TeamEditAccess => {
const parsed = callerEditAccessSchema.safeParse(callerEditAccess);
if (!parsed.success) return { kind: "none" };