Merge pull request #41525 from BerriAI/litellm_team_admin_rpm_budget_fields

feat(proxy): let team admins edit rpm_limit and max_budget when enabled
This commit is contained in:
ryan-crabbe-berri 2026-09-16 20:39:05 -07:00 committed by GitHub
commit 8b64f1ef03
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 470 additions and 125 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])
@ -148,7 +148,7 @@ def _only_changes(data: UpdateTeamRequest, changed: frozenset[str]) -> UpdateTea
"""The request without the values it resends unchanged, which would otherwise still trigger derived writes
such as a resent budget_duration pushing budget_reset_at back."""
sent: Final = frozenset(data.model_fields_set)
via_metadata: Final = frozenset({"metadata"}) if changed - sent else frozenset()
via_metadata: Final = frozenset({"metadata"}) if changed - sent else frozenset[str]()
kept: Final = frozenset({"team_id"}) | (changed & sent) | via_metadata
return UpdateTeamRequest.model_validate(data.model_dump(include=MappingProxyType({field: True for field in kept})))
@ -169,8 +169,8 @@ def team_admin_edit_verdict(
def team_admin_request_or_raise(verdict: TeamAdminEditVerdict) -> UpdateTeamRequest:
match verdict:
case TeamAdminEditAllowed(request=request):
return request
case TeamAdminEditAllowed():
return verdict.request
case TeamAdminEditingDisabled():
raise HTTPException(
status_code=403,

View file

@ -16,6 +16,7 @@ import math
import traceback
from collections.abc import Iterable, Mapping, Sequence
from collections.abc import Set as AbstractSet
from dataclasses import dataclass
from datetime import datetime, timezone
from types import MappingProxyType
from typing import (
@ -340,6 +341,14 @@ class _ErrorDetail(TypedDict):
error: ReadOnly[str]
class _TeamIdWhere(TypedDict):
team_id: ReadOnly[str]
class _TeamIdAndBudgetWhere(_TeamIdWhere):
max_budget: ReadOnly[float | None]
class _TeamCreateTx(AccessGroupSyncTx, Protocol):
@property
def litellm_teamtable(self) -> "TableActions[prisma_models.LiteLLM_TeamTable]": ...
@ -1200,26 +1209,39 @@ async def _check_user_team_limits(
)
@dataclass(frozen=True, slots=True)
class _MaxBudgetGuard:
"""The team write only lands while the stored max_budget still equals `expected`."""
expected: float | None
def _check_team_budget_update_authority(
data: UpdateTeamRequest,
user_api_key_dict: UserAPIKeyAuth,
existing_team_max_budget: float | None,
) -> None:
) -> _MaxBudgetGuard | None:
"""
Restrict who can grow a standalone team's spend ceiling on /team/update.
Restrict who can grow a team's spend ceiling on /team/update.
A team admin (already authorized via _verify_team_access) may keep or lower
the team budget, but only a proxy admin may grow it - by raising max_budget
above the team's current value or by removing the cap (setting it to None).
Setting a finite budget on a team that has no cap is a restriction and is
allowed. Org-scoped teams are governed by _check_org_team_limits().
A team admin may keep or lower the team budget, but only a proxy admin may
grow it - by raising max_budget above the team's current value or by
removing the cap (setting it to None). Setting a finite budget on a team
that has no cap is a restriction and is allowed. Org admins editing
org-scoped teams are governed by _check_org_team_limits() instead.
The verdict holds only for the budget it was checked against, so a restricted
caller's budget write gets a guard; without it, a concurrent budget cut could
be overwritten with a higher value.
"""
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN:
return
if existing_team_max_budget is None:
return
return None
budget_explicitly_set: Final = "max_budget" in (getattr(data, "model_fields_set", None) or set())
guard: Final = _MaxBudgetGuard(expected=existing_team_max_budget) if budget_explicitly_set else None
if existing_team_max_budget is None:
return guard
if budget_explicitly_set and data.max_budget is None:
raise HTTPException(
status_code=403,
@ -1235,6 +1257,37 @@ def _check_team_budget_update_authority(
"error": f"Only a proxy admin can raise a team's max_budget. Team's current max_budget={existing_team_max_budget}, requested={data.max_budget}."
},
)
return guard
_TEAM_UPDATE_INCLUDE: Final = MappingProxyType(
{
"litellm_model_table": True,
# `object_permission` is included so `_refresh_cached_team`
# doesn't write a cached team with the relation nulled out.
# See team_model_add for the full rationale.
"object_permission": True,
}
)
async def _write_team_update(
prisma_client: PrismaClient | None,
team_id: str,
team_update_data: Mapping[str, object],
max_budget_guard: _MaxBudgetGuard | None,
) -> "prisma_models.LiteLLM_TeamTable | None":
by_id: Final[_TeamIdWhere] = {"team_id": team_id}
if max_budget_guard is None:
return await _team_db(prisma_client).update(where=by_id, data=team_update_data, include=_TEAM_UPDATE_INCLUDE)
by_id_and_budget: Final[_TeamIdAndBudgetWhere] = {"team_id": team_id, "max_budget": max_budget_guard.expected}
written: Final = await _team_db(prisma_client).update_many(where=by_id_and_budget, data=team_update_data)
if written == 0:
conflict: Final[_ErrorDetail] = {
"error": "The team's max_budget changed during this update. Reload the team and try again."
}
raise HTTPException(status_code=409, detail=conflict)
return await _team_db(prisma_client).find_unique(where=by_id, include=_TEAM_UPDATE_INCLUDE)
def _existing_model_cap(raw_budget_config: object) -> BudgetConfig | None:
@ -2339,14 +2392,17 @@ async def update_team(
prisma_client=prisma_client,
)
# Only a proxy admin may grow a standalone team's spend ceiling.
# Org-scoped teams are validated by _check_org_team_limits() above.
if org_id_to_check is None:
# A team admin never grows its own team's spend ceiling. Org admins grow org-scoped teams
# within the org limits _check_org_team_limits() enforced above.
max_budget_guard: Final = (
_check_team_budget_update_authority(
data=data,
user_api_key_dict=user_api_key_dict,
existing_team_max_budget=existing_team_row.max_budget,
)
if org_id_to_check is None or access_role == "team_admin"
else None
)
_check_team_model_budget_update_authority(
data=data,
user_api_key_dict=user_api_key_dict,
@ -2493,17 +2549,7 @@ async def update_team(
updated_kv = prisma_client.jsonify_team_object(db_data=updated_kv)
team_update_data: Final[Mapping[str, object]] = updated_kv
team_row: Final = await _team_db(prisma_client).update(
where={"team_id": data.team_id},
data=team_update_data,
# `object_permission` is included so `_refresh_cached_team`
# doesn't write a cached team with the relation nulled out.
# See team_model_add for the full rationale.
include={
"litellm_model_table": True,
"object_permission": True,
},
)
team_row: Final = await _write_team_update(prisma_client, data.team_id, team_update_data, max_budget_guard)
if team_row is None or team_row.team_id is None:
raise HTTPException(

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", fail_before_fix: proven, rationale: "With max_budget enabled, a team admin may keep or lower its team's budget; raising or removing it is 403 and writes nothing, also under an organization's larger cap"}
- {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

@ -32,6 +32,7 @@ from lifecycle import ResourceManager
from management_client import ManagementClient
from models import (
KeyGenerateBody,
OrgNewBody,
TeamInfoParams,
TeamMemberAddBody,
TeamMemberDeleteBody,
@ -45,6 +46,8 @@ pytestmark = pytest.mark.e2e
TeamRole = Literal["admin", "user"]
_TEAM_TPM_LIMIT: Final = 1000
_TEAM_MAX_BUDGET: Final = 10.0
_ORG_MAX_BUDGET: Final = 100.0
class TeamBlockBody(BaseModel):
@ -114,9 +117,14 @@ class TeamInfoRead(BaseModel):
class TeamWithAdminNewBody(TeamNewBody):
tpm_limit: int
max_budget: float | None = None
members_with_roles: list[TeamMemberEntry]
class OrgWithBudgetNewBody(OrgNewBody):
max_budget: float
class TeamSettingsChange(PartialBody, TeamSettings):
pass
@ -414,13 +422,26 @@ 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,
organization_id: str | 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,
organization_id=organization_id,
members_with_roles=[TeamMemberEntry(role="admin", user_id=admin_id)],
)
)
@ -580,3 +601,93 @@ 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")
@pytest.mark.parametrize(
"current_budget",
[pytest.param(_TEAM_MAX_BUDGET, id="lower"), pytest.param(None, id="first-budget")],
)
def test_team_admin_saves_a_new_rpm_limit_and_a_tighter_budget(
self, client: ManagementClient, resources: ResourceManager, current_budget: float | None
) -> None:
team_id, admin_key = _team_with_admin(client, resources, max_budget=current_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 tightening the budget from {current_budget} must succeed, "
f"got {outcome.status_code}: {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}"
)
@pytest.mark.covers("mgmt.team.update.team_admin_cannot_grow_budget")
def test_team_admin_cannot_raise_an_org_team_budget_under_the_org_cap(
self, client: ManagementClient, resources: ResourceManager
) -> None:
org_id = client.create_org(
OrgWithBudgetNewBody(organization_alias=f"e2e-team-admin-org-{unique_marker()}", max_budget=_ORG_MAX_BUDGET)
)
resources.defer(lambda: client.delete_org(org_id))
team_id, admin_key = _team_with_admin(client, resources, max_budget=_TEAM_MAX_BUDGET, organization_id=org_id)
before = _read_team(client, team_id).team_info
outcome = _update_team_as(
client, admin_key, TeamSettingsUpdate(team_id=team_id, max_budget=_ORG_MAX_BUDGET / 2)
)
assert outcome.status_code == 403, (
f"a team admin raising an org team's max_budget from {_TEAM_MAX_BUDGET} to {_ORG_MAX_BUDGET / 2}, "
f"under the org's {_ORG_MAX_BUDGET}, must be 403, got {outcome.status_code}: {outcome.body[:300]}"
)
assert "Only a proxy admin can raise" in outcome.body, f"403 body should say why, got: {outcome.body[:300]}"
after = _read_team(client, team_id).team_info
assert after == before, f"the refused update still wrote to the team: before {before}, after {after}"

View file

@ -6653,40 +6653,18 @@ async def test_update_team_standalone_uncapped_team_admin_sets_finite_allowed(
"litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock()
),
):
mock_existing_team = MagicMock()
mock_existing_team.team_id = "standalone-uncapped-123"
mock_existing_team.organization_id = None
mock_existing_team.max_budget = None # team has no cap
mock_existing_team.model_id = None
mock_existing_team.model_dump.return_value = {
"team_id": "standalone-uncapped-123",
"organization_id": None,
"max_budget": None,
"members_with_roles": [
{"user_id": "uncapped-team-admin", "role": "admin"}
],
}
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(
return_value=mock_existing_team
_TeamRowStore(
mock_prisma.db.litellm_teamtable,
{
"team_id": "standalone-uncapped-123",
"max_budget": None,
"members_with_roles": [{"user_id": "uncapped-team-admin", "role": "admin"}],
},
)
mock_prisma.jsonify_team_object = lambda db_data: db_data
mock_cache.async_get_cache = AsyncMock(return_value=None)
mock_cache.async_set_cache = AsyncMock()
mock_updated_team = MagicMock()
mock_updated_team.team_id = "standalone-uncapped-123"
mock_updated_team.organization_id = None
mock_updated_team.max_budget = 1000.0
mock_updated_team.litellm_model_table = None
mock_updated_team.model_dump.return_value = {
"team_id": "standalone-uncapped-123",
"organization_id": None,
"max_budget": 1000.0,
}
mock_prisma.db.litellm_teamtable.update = AsyncMock(
return_value=mock_updated_team
)
result = await update_team(
data=update_request,
http_request=dummy_request,
@ -6847,21 +6825,13 @@ async def test_update_team_standalone_lower_budget_allowed(
"litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock()
) as mock_audit,
):
mock_existing_team = MagicMock()
mock_existing_team.team_id = "standalone-lower-budget-123"
mock_existing_team.organization_id = None
mock_existing_team.max_budget = 500.0
mock_existing_team.model_id = None
mock_existing_team.model_dump.return_value = {
"team_id": "standalone-lower-budget-123",
"organization_id": None,
"max_budget": 500.0,
"members_with_roles": [
{"user_id": "standalone-lower-budget-admin", "role": "admin"}
],
}
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(
return_value=mock_existing_team
_TeamRowStore(
mock_prisma.db.litellm_teamtable,
{
"team_id": "standalone-lower-budget-123",
"max_budget": 500.0,
"members_with_roles": [{"user_id": "standalone-lower-budget-admin", "role": "admin"}],
},
)
mock_prisma.jsonify_team_object = lambda db_data: db_data
@ -6872,20 +6842,6 @@ async def test_update_team_standalone_lower_budget_allowed(
mock_cache.async_get_cache = AsyncMock(return_value=mock_user_obj)
mock_cache.async_set_cache = AsyncMock()
mock_updated_team = MagicMock()
mock_updated_team.team_id = "standalone-lower-budget-123"
mock_updated_team.organization_id = None
mock_updated_team.max_budget = 300.0
mock_updated_team.litellm_model_table = None
mock_updated_team.model_dump.return_value = {
"team_id": "standalone-lower-budget-123",
"organization_id": None,
"max_budget": 300.0,
}
mock_prisma.db.litellm_teamtable.update = AsyncMock(
return_value=mock_updated_team
)
result = await update_team(
data=update_request,
http_request=dummy_request,
@ -7124,8 +7080,10 @@ async def test_update_team_org_scoped_budget_bypasses_user_limit(
mock_org.litellm_budget_table = mock_budget_table
with (
_team_admin_may_edit("max_budget"),
_not_org_admin(),
patch( # test-quality-ok: the org-admin lookup needs a real prisma client this file's MagicMock cannot provide
"litellm.proxy.management_endpoints.team_endpoints._is_user_org_admin_for_team",
AsyncMock(return_value=True),
),
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
@ -7147,9 +7105,7 @@ async def test_update_team_org_scoped_budget_bypasses_user_limit(
"team_id": "org-team-update-budget-123",
"organization_id": "test-org-update-budget",
"max_budget": 30.0,
"members_with_roles": [
{"user_id": "org-admin-update-budget-test", "role": "admin"}
],
"members_with_roles": [],
}
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(
return_value=mock_existing_team
@ -14968,6 +14924,49 @@ def _update_request_stub():
return Mock(spec=Request)
class _TeamRowStore:
"""One team row whose writes honor their where clause, as Postgres does.
`budget_set_after_read` is a proxy admin's budget change that commits after update_team read the row."""
def __init__(self, table: MagicMock, row: dict[str, object], budget_set_after_read: float | None = None) -> None:
self.row: Final = {
"organization_id": None,
"soft_budget": None,
"model_id": None,
"model_max_budget": None,
"litellm_model_table": None,
"metadata": {},
**row,
}
self._budget_set_after_read = budget_set_after_read
table.find_unique = self.find_unique
table.update = self.update
table.update_many = self.update_many
def _snapshot(self) -> MagicMock:
snapshot: Final = MagicMock(**self.row)
snapshot.model_dump.return_value = dict(self.row)
return snapshot
async def find_unique(self, where, include=None):
snapshot: Final = self._snapshot()
if self._budget_set_after_read is not None:
self.row["max_budget"] = self._budget_set_after_read
self._budget_set_after_read = None
return snapshot
async def update(self, where, data, include=None):
self.row.update(data)
return self._snapshot()
async def update_many(self, where, data):
if any(self.row.get(column) != value for column, value in where.items()):
return 0
self.row.update(data)
return 1
@pytest.mark.asyncio
async def test_update_team_team_admin_is_refused_before_any_write_when_no_fields_are_enabled():
import contextlib
@ -15177,6 +15176,116 @@ async def test_update_team_holds_a_team_admin_to_the_org_tpm_limit(disable_audit
assert prisma.db.litellm_teamtable.update.call_args.kwargs["data"]["tpm_limit"] == 8000
@pytest.mark.asyncio
async def test_update_team_stops_a_team_admin_raising_an_org_team_budget_under_the_org_cap(
disable_audit_logging_for_mocked_team,
):
"""The org cap alone would let a team admin with max_budget enabled grow its own team's budget up to the org's."""
import contextlib
budgeted_org = LiteLLM_OrganizationTable(
organization_id="budgeted-org",
budget_id="budgeted-org-budget",
created_by="admin",
updated_by="admin",
litellm_budget_table=LiteLLM_BudgetTable(max_budget=100.0),
)
with contextlib.ExitStack() as stack:
prisma = _wire_update_team(stack, {})
store = _TeamRowStore(
prisma.db.litellm_teamtable,
{
"team_id": "test_team_id",
"team_alias": "test_team",
"organization_id": "budgeted-org",
"max_budget": 10.0,
"members_with_roles": [{"user_id": "team-admin", "role": "admin"}],
},
)
stack.enter_context(_team_admin_may_edit("max_budget"))
stack.enter_context(_not_org_admin())
stack.enter_context(
patch( # test-quality-ok: update_team reads orgs through this module-level import; no seam to inject
"litellm.proxy.management_endpoints.team_endpoints.get_org_object",
AsyncMock(return_value=budgeted_org),
)
)
with pytest.raises(ProxyException) as raised:
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", max_budget=50.0),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
budget_after_raise = store.row["max_budget"]
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", max_budget=5.0),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
assert str(raised.value.code) == "403"
assert "Only a proxy admin can raise a team's max_budget" in str(raised.value.message)
assert budget_after_raise == 10.0
assert store.row["max_budget"] == 5.0
@pytest.mark.asyncio
@pytest.mark.parametrize(
("organization_id", "budget_read", "requested"),
[
pytest.param(None, 100.0, 90.0, id="lowering"),
pytest.param(None, None, 90.0, id="first-budget"),
pytest.param("budgeted-org", 100.0, 90.0, id="org-team"),
],
)
async def test_update_team_keeps_a_budget_cut_that_lands_while_a_team_admin_update_runs(
disable_audit_logging_for_mocked_team, organization_id, budget_read, requested
):
"""The team admin's check passed against the budget it read, which no longer holds once a proxy admin
cut it to 20, so writing 90 would grow the team's live ceiling."""
import contextlib
with contextlib.ExitStack() as stack:
prisma = _wire_update_team(stack, {})
store = _TeamRowStore(
prisma.db.litellm_teamtable,
{
"team_id": "test_team_id",
"team_alias": "test_team",
"organization_id": organization_id,
"max_budget": budget_read,
"members_with_roles": [{"user_id": "team-admin", "role": "admin"}],
},
budget_set_after_read=20.0,
)
stack.enter_context(_team_admin_may_edit("max_budget"))
stack.enter_context(_not_org_admin())
stack.enter_context(
patch( # test-quality-ok: update_team reads orgs through this module-level import; no seam to inject
"litellm.proxy.management_endpoints.team_endpoints.get_org_object",
AsyncMock(
return_value=LiteLLM_OrganizationTable(
organization_id="budgeted-org",
budget_id="budgeted-org-budget",
created_by="admin",
updated_by="admin",
litellm_budget_table=LiteLLM_BudgetTable(max_budget=1000.0),
)
),
)
)
with pytest.raises(ProxyException) as raised:
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", max_budget=requested),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
assert str(raised.value.code) == "409"
assert "max_budget changed" in str(raised.value.message)
assert store.row["max_budget"] == 20.0
@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

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

@ -21,6 +21,7 @@ vi.mock("@/app/(dashboard)/hooks/uiSettings/useUpdateUISettings", () => ({
}));
const TPM_LABEL = "Tokens per minute Limit (TPM)";
const MAX_BUDGET_LABEL = "Max Budget (USD)";
const mockSettings = (supported: readonly string[], enabled: readonly string[]) =>
mockUseUISettings.mockReturnValue({
@ -80,7 +81,7 @@ describe("TeamAdminEditableFieldsSettings", () => {
expect(screen.getByText("Team admin editable fields")).toBeInTheDocument();
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: MAX_BUDGET_LABEL })).not.toBeChecked();
expect(screen.getByRole("checkbox", { name: TPM_LABEL })).toBeChecked();
expect(saveButton()).toBeDisabled();
});
@ -90,9 +91,9 @@ describe("TeamAdminEditableFieldsSettings", () => {
const mutate = mockSave({});
renderWithProviders(<TeamAdminEditableFieldsSettings />);
fireEvent.click(screen.getByRole("checkbox", { name: "max_budget" }));
fireEvent.click(screen.getByRole("checkbox", { name: MAX_BUDGET_LABEL }));
expect(screen.getByRole("checkbox", { name: "max_budget" })).toBeChecked();
expect(screen.getByRole("checkbox", { name: MAX_BUDGET_LABEL })).toBeChecked();
expect(mutate).not.toHaveBeenCalled();
fireEvent.click(saveButton());

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