feat(teams): let proxy admins grant team admins the right to raise their team budget (#45404)

* feat(teams): let proxy admins grant team admins the right to raise their team budget

Adds a raise_max_budget entry to team_admin_editable_team_fields. With max_budget alone a team admin can still only keep or lower the team budget. Adding raise_max_budget lets them raise it, capped by the organization's max_budget when the team belongs to one, while removing the budget stays with proxy admins. The entry is rejected without max_budget, /team/info reports may_raise_max_budget to the caller, and the Admin UI nests the new checkbox under Max Budget with a tooltip and shows the team admin a hint under the budget field.

* fix(ui): shorten the raise_max_budget tooltip and link it to the docs

* fix(teams): pin the organization in the guarded team budget write

A granted raise is checked against the team's organization at read time, so the write now also requires organization_id to be unchanged. A concurrent move into a budgeted org returns 409 instead of landing an uncapped raise. Also types the new test helpers.

* test(teams): type the team row store methods and the touched race test arguments

* test(teams): annotate the last fixture and parametrize arguments in the raise_max_budget tests
This commit is contained in:
ryan-crabbe-berri 2026-10-09 13:39:08 -07:00 • committed by GitHub
parent 3ebc71be2a
commit 4c78023db2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 739 additions and 74 deletions

View file

@ -4929,6 +4929,7 @@ class TeamEditUnrestricted(LiteLLMBaseModel):
class TeamEditAsTeamAdmin(LiteLLMBaseModel):
kind: Literal["team_admin"] = "team_admin"
editable_fields: tuple[str, ...]
may_raise_max_budget: bool = False
class TeamEditAsTeamAdminDisabled(LiteLLMBaseModel):

View file

@ -1,6 +1,8 @@
"""Proxy-wide allow-list of what a team admin may do on the teams they administer: team-settings fields on
/team/update, the ``projects`` permission for /project/new and /project/update, and the
``member_key_budgets`` permission for budget fields on other members' keys via /key/update."""
/team/update, the ``raise_max_budget`` permission that lets a team admin grow the team's ``max_budget`` (never
above the organization's budget, never removing the cap), the ``projects`` permission for /project/new and
/project/update, and the ``member_key_budgets`` permission for budget fields on other members' keys via
/key/update."""
from collections.abc import Mapping
from dataclasses import dataclass
@ -25,9 +27,11 @@ TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING: Final = "team_admin_editable_team_field
# 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", "rpm_limit", "max_budget"})
TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION: Final = "raise_max_budget"
TEAM_ADMIN_PROJECTS_PERMISSION: Final = "projects"
TEAM_ADMIN_MEMBER_KEY_BUDGETS_PERMISSION: Final = "member_key_budgets"
SUPPORTED_TEAM_ADMIN_PERMISSIONS: Final[frozenset[str]] = SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS | {
TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION,
TEAM_ADMIN_PROJECTS_PERMISSION,
TEAM_ADMIN_MEMBER_KEY_BUDGETS_PERMISSION,
}
@ -106,6 +110,14 @@ def team_admin_may_edit_member_key_budgets(general_settings: Mapping[str, object
)
def team_admin_may_raise_max_budget(general_settings: Mapping[str, object]) -> bool:
"""``raise_max_budget`` only takes effect alongside ``max_budget``: a grant to raise a field the team admin
may not edit at all is treated as not granted."""
return resolve_team_admin_editable_fields(
general_settings, frozenset({"max_budget", TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION})
) >= {"max_budget", TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION}
def _as_object(value: object) -> Mapping[str, object]:
try:
return _JSON_OBJECT.validate_json(value) if isinstance(value, str) else _JSON_OBJECT.validate_python(value)

View file

@ -160,6 +160,7 @@ from litellm.proxy.management_endpoints.team_admin_field_permissions import (
SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS,
resolve_team_admin_editable_fields,
team_admin_edit_verdict,
team_admin_may_raise_max_budget,
team_admin_request_or_raise,
)
from litellm.proxy.management_helpers.access_group_team_sync import (
@ -365,8 +366,9 @@ class _TeamIdWhere(TypedDict):
team_id: ReadOnly[str]
class _TeamIdAndBudgetWhere(_TeamIdWhere):
class _TeamBudgetGuardWhere(_TeamIdWhere):
max_budget: ReadOnly[float | None]
organization_id: ReadOnly[str | None]
class _TeamCreateTx(AccessGroupSyncTx, Protocol):
@ -513,7 +515,10 @@ def _caller_edit_access(role: TeamRole | None, general_settings: Mapping[str, ob
)
if not permitted:
return TeamEditAsTeamAdminDisabled()
return TeamEditAsTeamAdmin(editable_fields=tuple(sorted(permitted)))
return TeamEditAsTeamAdmin(
editable_fields=tuple(sorted(permitted)),
may_raise_max_budget=team_admin_may_raise_max_budget(general_settings),
)
case None:
return TeamEditNone()
case _:
@ -1197,34 +1202,44 @@ 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`."""
"""The team write only lands while the stored max_budget and organization_id still match what the check read."""
expected: float | None
max_budget: float | None
organization_id: str | None
def _check_team_budget_update_authority(
data: UpdateTeamRequest,
user_api_key_dict: UserAPIKeyAuth,
existing_team_max_budget: float | None,
existing_team_organization_id: str | None,
may_raise: bool,
) -> _MaxBudgetGuard | None:
"""
Restrict who can grow a team's spend ceiling on /team/update.
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
A team admin may keep or lower the team budget. Raising max_budget above the
team's current value also needs `may_raise`, which a proxy admin grants via
the `raise_max_budget` entry of team_admin_editable_team_fields; the org cap
_check_org_team_limits() enforces before this still applies. Removing the cap
(setting it to None) stays with the proxy admin. 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.
The verdict holds only for the budget and organization 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, and a concurrent move into
a budgeted organization could let the write exceed that organization's cap.
"""
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN:
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
guard: Final = (
_MaxBudgetGuard(max_budget=existing_team_max_budget, organization_id=existing_team_organization_id)
if budget_explicitly_set
else None
)
if existing_team_max_budget is None:
return guard
@ -1236,7 +1251,7 @@ def _check_team_budget_update_authority(
},
)
if data.max_budget is not None and data.max_budget > existing_team_max_budget:
if data.max_budget is not None and data.max_budget > existing_team_max_budget and not may_raise:
raise HTTPException(
status_code=403,
detail={
@ -1266,11 +1281,15 @@ async def _write_team_update(
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)
as_checked: Final[_TeamBudgetGuardWhere] = {
"team_id": team_id,
"max_budget": max_budget_guard.max_budget,
"organization_id": max_budget_guard.organization_id,
}
written: Final = await _team_db(prisma_client).update_many(where=as_checked, 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."
"error": "The team's organization or 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)
@ -2401,13 +2420,15 @@ async def update_team(
prisma_client=prisma_client,
)
# 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.
# A team admin grows its own team's spend ceiling only when granted raise_max_budget, and then
# within the org limits _check_org_team_limits() enforced above. Org admins are governed by those alone.
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,
existing_team_organization_id=existing_team_row.organization_id,
may_raise=access_role == "team_admin" and team_admin_may_raise_max_budget(_general_settings()),
)
if org_id_to_check is None or access_role == "team_admin"
else None

View file

@ -37,6 +37,7 @@ from litellm.proxy.config_resolvers.sso import (
from litellm.proxy.management_endpoints.team_admin_field_permissions import (
SUPPORTED_TEAM_ADMIN_PERMISSIONS,
TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING,
TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION,
)
from litellm.proxy.spend_tracking.ptu_feature_flag import (
PTU_COST_ATTRIBUTION_ENV_VAR,
@ -358,6 +359,10 @@ class UISettings(LiteLLMBaseModel):
default=(),
description=(
"Team settings fields a team admin may change on the teams they administer. "
"With 'max_budget' alone a team admin may keep or lower the team budget; add 'raise_max_budget' to also "
"let them raise it. A raise is capped by the organization's max_budget when the team belongs to one, "
"has no ceiling for teams outside an organization or in an organization without a budget, and never "
"removes the cap. "
"Include 'projects' to let team admins create and update projects for those teams. "
"Include 'member_key_budgets' to let team admins update budget fields on keys owned by other members of those teams. "
"Empty means team admins cannot edit team settings or manage projects at all. "
@ -1923,9 +1928,8 @@ async def update_ui_settings(
except ValidationError as e:
raise HTTPException(status_code=422, detail=public_validation_errors(e.errors()))
unsupported_team_fields: Final = sorted(
frozenset(settings.team_admin_editable_team_fields) - SUPPORTED_TEAM_ADMIN_PERMISSIONS
)
submitted_team_fields: Final = frozenset(settings.team_admin_editable_team_fields)
unsupported_team_fields: Final = sorted(submitted_team_fields - SUPPORTED_TEAM_ADMIN_PERMISSIONS)
if unsupported_team_fields:
raise HTTPException(
status_code=400,
@ -1936,6 +1940,16 @@ async def update_ui_settings(
)
},
)
if TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION in submitted_team_fields and "max_budget" not in submitted_team_fields:
raise HTTPException(
status_code=400,
detail={
"error": (
f"{TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING}: '{TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION}' "
"requires 'max_budget' to be enabled as well."
)
},
)
# Only include fields the caller actually sent (not Pydantic defaults).
settings_dict: Final[Mapping[str, JsonValue]] = settings.model_dump(exclude_unset=True)

View file

@ -33,6 +33,7 @@
- {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_raises_budget_when_granted, module: mgmt, tier: P0, surface: api, assertions: [team_admin_raises_budget_when_granted], source: "team_endpoints.py:1205", rationale: "With max_budget and raise_max_budget enabled, a team admin may raise its team's budget: without a ceiling on a standalone team, up to and never above the organization's max_budget on an org team, and still never remove it"}
- {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.delete.membership_larger_than_db_pool, module: mgmt, tier: P0, surface: api, assertions: [membership_larger_than_db_pool], source: "team_endpoints.py:4362", rationale: "Deleting a team with more members than the Prisma connection pool still completes instead of exhausting the pool and answering 500", fail_before_fix: proven}

View file

@ -80,6 +80,7 @@ class TeamMembership(BaseModel):
class CallerEditAccess(BaseModel):
kind: Literal["unrestricted", "team_admin", "team_admin_disabled", "none"]
editable_fields: list[str] = []
may_raise_max_budget: bool = False
class BudgetWindow(BaseModel):
@ -436,6 +437,12 @@ def rpm_limit_and_max_budget_editable_by_team_admins(client: ManagementClient) -
yield
@pytest.fixture(scope="class")
def max_budget_raisable_by_team_admins(client: ManagementClient) -> Generator[None]:
with _team_admins_may_edit(client, ["max_budget", "raise_max_budget"]):
yield
def _team_with_admin(
client: ManagementClient,
resources: ResourceManager,
@ -706,3 +713,86 @@ class TestTeamAdminWithRpmLimitAndMaxBudgetEnabled:
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}"
@pytest.mark.usefixtures("max_budget_raisable_by_team_admins")
class TestTeamAdminWithRaiseMaxBudgetEnabled:
"""A proxy admin has enabled max_budget and raise_max_budget, so a team admin may raise the team's budget.
The organization's budget still caps it, and removing the budget stays with the proxy admin."""
@pytest.mark.covers("mgmt.team.update.team_admin_raises_budget_when_granted")
@meta(Subject(domain=Domain.PROXY_AUTH, route=Route.TEAM_MANAGEMENT))
def test_team_admin_raises_a_standalone_team_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"], may_raise_max_budget=True), (
f"/team/info should tell the team admin it may raise max_budget, got {access}"
)
before = _read_team(client, team_id).team_info
outcome = _update_team_as(client, admin_key, TeamSettingsUpdate(team_id=team_id, max_budget=_TEAM_MAX_BUDGET * 2))
assert outcome.status_code == 200, (
f"a team admin raising a standalone team's max_budget from {_TEAM_MAX_BUDGET} to {_TEAM_MAX_BUDGET * 2} "
f"must succeed, got {outcome.status_code}: {outcome.body[:300]}"
)
after = _poll_team(
client,
team_id,
lambda info: info.max_budget == _TEAM_MAX_BUDGET * 2,
f"/team/info never reflected max_budget={_TEAM_MAX_BUDGET * 2}",
)
assert after.model_copy(update={"max_budget": before.max_budget}) == before, (
f"the update changed more than max_budget: before {before}, after {after}"
)
@pytest.mark.covers("mgmt.team.update.team_admin_raises_budget_when_granted")
@pytest.mark.parametrize(
("max_budget", "status_code"),
[pytest.param(_ORG_MAX_BUDGET, 200, id="up-to-the-org-cap"), pytest.param(_ORG_MAX_BUDGET * 2, 400, id="above-it")],
)
@meta(Subject(domain=Domain.PROXY_AUTH, route=Route.TEAM_MANAGEMENT))
def test_team_admin_raises_an_org_team_budget_only_up_to_the_org_cap(
self, client: ManagementClient, resources: ResourceManager, max_budget: float, status_code: int
) -> 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)
outcome = _update_team_as(client, admin_key, TeamSettingsUpdate(team_id=team_id, max_budget=max_budget))
assert outcome.status_code == status_code, (
f"a team admin raising an org team's max_budget from {_TEAM_MAX_BUDGET} to {max_budget} under an org "
f"cap of {_ORG_MAX_BUDGET} must be {status_code}, got {outcome.status_code}: {outcome.body[:300]}"
)
if status_code == 400:
assert "exceeds organization's max_budget" in outcome.body, f"400 body should say why: {outcome.body[:300]}"
expected_budget = max_budget if status_code == 200 else _TEAM_MAX_BUDGET
_poll_team(
client,
team_id,
lambda info: info.max_budget == expected_budget,
f"/team/info never settled on max_budget={expected_budget}",
)
@pytest.mark.covers("mgmt.team.update.team_admin_raises_budget_when_granted")
@meta(Subject(domain=Domain.PROXY_AUTH, route=Route.TEAM_MANAGEMENT))
def test_team_admin_still_cannot_remove_the_budget(
self, client: ManagementClient, resources: ResourceManager
) -> 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, max_budget=None))
assert outcome.status_code == 403, (
f"a team admin removing max_budget must be 403 even with raise_max_budget enabled, "
f"got {outcome.status_code}: {outcome.body[:300]}"
)
assert "Only a proxy admin can remove" 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

@ -116,7 +116,7 @@ class Route:
outsider: int | None = None
org_admin: int | None = None
other_org_admin: int | None = None
permission: str = ""
permission: tuple[str, ...] = ()
cleanup: Callable[[TeamScenario, dict[str, JsonValue]], None] | None = None
def expected(self, caller: Caller) -> int | None:
@ -258,7 +258,7 @@ ROUTES: Final[tuple[Route, ...]] = (
team_admin=403, others=403),
Route("key_update_member_key_permitted",
lambda s: Call("POST", "/key/update", {"key": s.member_key(), "max_budget": 5}),
team_admin=200, others=403, permission="member_key_budgets"),
team_admin=200, others=403, permission=("member_key_budgets",)),
Route("team_key_bulk_update",
lambda s: Call("POST", "/team/key/bulk_update",
{"team_id": s.team_id, "all_keys_in_team": True, "update_fields": {"max_budget": 5}}),
@ -325,13 +325,19 @@ ROUTES: Final[tuple[Route, ...]] = (
team_admin=403, others=403, org_admin=200),
Route("team_update_budget_permitted",
lambda s: Call("POST", "/team/update", {"team_id": s.team_id, "max_budget": 4}),
team_admin=200, others=403, org_admin=200, permission="max_budget"),
team_admin=200, others=403, org_admin=200, permission=("max_budget",)),
Route("team_update_budget_raise",
lambda s: Call("POST", "/team/update", {"team_id": s.team_id, "max_budget": 6}),
team_admin=403, others=403, org_admin=200, permission=("max_budget",)),
Route("team_update_budget_raise_permitted",
lambda s: Call("POST", "/team/update", {"team_id": s.team_id, "max_budget": 6}),
team_admin=200, others=403, org_admin=200, permission=("max_budget", "raise_max_budget")),
Route("project_new",
lambda s: Call("POST", "/project/new", {"team_id": s.team_id, "project_alias": f"matrix-{uuid.uuid4().hex}"}),
team_admin=403, others=403, cleanup=_delete_project),
Route("project_new_permitted",
lambda s: Call("POST", "/project/new", {"team_id": s.team_id, "project_alias": f"matrix-{uuid.uuid4().hex}"}),
team_admin=200, others=403, permission="projects", cleanup=_delete_project),
team_admin=200, others=403, permission=("projects",), cleanup=_delete_project),
Route("team_delete",
lambda s: Call("POST", "/team/delete", {"team_ids": [s.team_id]}),
team_admin=401, others=401, proxy_admin=None),
@ -409,23 +415,33 @@ def org_team() -> Iterator[TeamScenario]:
yield _team_scenario(scenario, team_id, keys)
_BUDGET_BEFORE: Final = 5.0
_BUDGET_ROUTES: Final[Mapping[str, float]] = MappingProxyType(
{
"team_update_budget_permitted": 4.0,
"team_update_budget_raise": 6.0,
"team_update_budget_raise_permitted": 6.0,
}
)
@pytest.mark.parametrize(("route", "caller"), CASES, ids=tuple(f"{route.name}[{caller}]" for route, caller in CASES))
def test_status_code(shared: TeamScenario, org_team: TeamScenario, route: Route, caller: Caller) -> None:
team: Final = org_team if caller in ORG_CALLERS else shared
with team.gateway.scenario() as scenario:
s: Final = replace(team, scenario=scenario)
if route.name == "team_update_budget_permitted":
s.gateway.post("/team/update", {"team_id": s.team_id, "max_budget": 5})
if route.name in _BUDGET_ROUTES:
s.gateway.post("/team/update", {"team_id": s.team_id, "max_budget": _BUDGET_BEFORE})
if route.permission:
scenario.cleanups.enter_context(team_admin_permissions(s.gateway, (route.permission,)))
scenario.cleanups.enter_context(team_admin_permissions(s.gateway, route.permission))
call: Final = route.call(s)
response: Final = s.gateway.request(call.method, call.path, call.body, key=s.keys[caller])
assert response.status_code == route.expected(caller), (
f"{caller} {call.method} {call.path}: {response.status_code} {response.text}"
)
if route.name == "team_update_budget_permitted":
if route.name in _BUDGET_ROUTES:
assert read_rows(
'SELECT max_budget FROM "LiteLLM_TeamTable" WHERE team_id = %s', (s.team_id,)
) == [{"max_budget": 4.0 if response.status_code == 200 else 5.0}]
) == [{"max_budget": _BUDGET_ROUTES[route.name] if response.status_code == 200 else _BUDGET_BEFORE}]
if response.status_code == 200 and route.cleanup is not None:
route.cleanup(s, object_value(response.json()))

View file

@ -18,6 +18,7 @@ from litellm.proxy.management_endpoints.team_admin_field_permissions import (
team_admin_key_request_or_raise,
team_admin_may_edit_member_key_budgets,
team_admin_may_manage_projects,
team_admin_may_raise_max_budget,
team_admin_request_or_raise,
)
@ -44,6 +45,10 @@ class TestResolveTeamAdminEditableFields:
configured = {"team_admin_editable_team_fields": ["projects", "tpm_limit"]}
assert resolve_team_admin_editable_fields(configured, _SUPPORTED) == frozenset({"tpm_limit"})
def test_raise_max_budget_permission_is_not_a_team_field(self):
configured = {"team_admin_editable_team_fields": ["raise_max_budget", "max_budget"]}
assert resolve_team_admin_editable_fields(configured, frozenset({"max_budget"})) == frozenset({"max_budget"})
class TestTeamAdminMayManageProjects:
def test_missing_setting_denies(self):
@ -187,6 +192,28 @@ class TestTeamAdminMayEditMemberKeyBudgets:
assert team_admin_may_edit_member_key_budgets({"team_admin_editable_team_fields": raw}) is False
class TestTeamAdminMayRaiseMaxBudget:
def test_missing_setting_denies(self):
assert team_admin_may_raise_max_budget({}) is False
def test_max_budget_alone_denies(self):
configured = {"team_admin_editable_team_fields": ["tpm_limit", "max_budget", "projects"]}
assert team_admin_may_raise_max_budget(configured) is False
def test_raise_without_max_budget_denies(self):
"""A config file can list the entry on its own; it is inert until max_budget is granted too."""
configured = {"team_admin_editable_team_fields": ["raise_max_budget", "tpm_limit"]}
assert team_admin_may_raise_max_budget(configured) is False
def test_max_budget_with_raise_grants(self):
configured = {"team_admin_editable_team_fields": ["raise_max_budget", "max_budget"]}
assert team_admin_may_raise_max_budget(configured) is True
@pytest.mark.parametrize("raw", ["raise_max_budget", 7, [1, 2], {"max_budget": True}])
def test_malformed_setting_denies(self, raw: object):
assert team_admin_may_raise_max_budget({"team_admin_editable_team_fields": raw}) is False
class TestChangedKeyFields:
def test_key_alone_changes_nothing(self):
assert changed_key_fields(UpdateKeyRequest(key="sk-1"), _key()) == frozenset()

View file

@ -1,10 +1,10 @@
import asyncio
import json
from collections.abc import Sequence
from contextlib import AbstractContextManager, asynccontextmanager, contextmanager
from collections.abc import Mapping, Sequence
from contextlib import AbstractContextManager, ExitStack, asynccontextmanager, contextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
from types import SimpleNamespace
from types import MappingProxyType, SimpleNamespace
from typing import Final, Optional, cast
from unittest.mock import AsyncMock, MagicMock, PropertyMock, call, patch
@ -41,6 +41,10 @@ from litellm.proxy._types import (
UserAPIKeyAuth, # Import UserAPIKeyAuth
)
from litellm.proxy.management.teams.authz import TeamAccess
from litellm.proxy.management_endpoints.team_admin_field_permissions import (
SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS,
SUPPORTED_TEAM_ADMIN_PERMISSIONS,
)
from litellm.proxy.management_endpoints.team_endpoints import (
_STRIP_DELETED_TEAM_FROM_USERS_SQL,
GetTeamMemberPermissionsResponse,
@ -95,11 +99,12 @@ def _team_admin_may_edit(*fields: str):
"""Let team admins change ``fields`` on /team/update for the duration of the block.
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."""
exercise the gates layered underneath the allow-list widen it here instead of asserting the early 403.
Permission entries that are not team fields (``raise_max_budget`` and friends) stay out of the registry."""
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",
frozenset(fields),
frozenset(fields) - (SUPPORTED_TEAM_ADMIN_PERMISSIONS - SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS),
),
patch("litellm.proxy.proxy_server.general_settings", {"team_admin_editable_team_fields": list(fields)}), # test-quality-ok: update_team reads general_settings as a proxy_server module global
):
@ -16249,9 +16254,14 @@ def _update_request_stub():
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."""
`committed_after_read` is a proxy admin's change to the row that commits after update_team read it."""
def __init__(self, table: MagicMock, row: dict[str, object], budget_set_after_read: float | None = None) -> None:
def __init__(
self,
table: MagicMock,
row: dict[str, object],
committed_after_read: Mapping[str, object] = MappingProxyType({}),
) -> None:
self.row: Final = {
"organization_id": None,
"soft_budget": None,
@ -16261,7 +16271,7 @@ class _TeamRowStore:
"metadata": {},
**row,
}
self._budget_set_after_read = budget_set_after_read
self._committed_after_read = committed_after_read
table.find_unique = self.find_unique
table.update = self.update
table.update_many = self.update_many
@ -16271,18 +16281,19 @@ class _TeamRowStore:
snapshot.model_dump.return_value = dict(self.row)
return snapshot
async def find_unique(self, where, include=None):
async def find_unique(self, where: Mapping[str, object], include: Mapping[str, object] | None = None) -> MagicMock:
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
self.row.update(self._committed_after_read)
self._committed_after_read = MappingProxyType({})
return snapshot
async def update(self, where, data, include=None):
async def update(
self, where: Mapping[str, object], data: Mapping[str, object], include: Mapping[str, object] | None = None
) -> MagicMock:
self.row.update(data)
return self._snapshot()
async def update_many(self, where, data):
async def update_many(self, where: Mapping[str, object], data: Mapping[str, object]) -> int:
if any(self.row.get(column) != value for column, value in where.items()):
return 0
self.row.update(data)
@ -16495,7 +16506,7 @@ async def test_update_team_holds_a_team_admin_to_the_org_tpm_limit(disable_audit
@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,
disable_audit_logging_for_mocked_team: None,
):
"""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
@ -16546,6 +16557,166 @@ async def test_update_team_stops_a_team_admin_raising_an_org_team_budget_under_t
assert store.row["max_budget"] == 5.0
_UNBUDGETED_ORG = LiteLLM_OrganizationTable(
organization_id="unbudgeted-org", budget_id="unbudgeted-org-budget", created_by="admin", updated_by="admin"
)
def _granted_raise(
stack: ExitStack,
organization_id: str | None,
org_table: LiteLLM_OrganizationTable | None,
committed_after_read: Mapping[str, object] = MappingProxyType({}),
) -> _TeamRowStore:
"""A team admin granted max_budget and raise_max_budget on a team budgeted at 10, standalone or in `org_table`."""
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": 10.0,
"members_with_roles": [{"user_id": "team-admin", "role": "admin"}],
},
committed_after_read=committed_after_read,
)
stack.enter_context(_team_admin_may_edit("max_budget", "raise_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=org_table),
)
)
return store
@pytest.mark.asyncio
@pytest.mark.parametrize(
("organization_id", "org_table"),
[
pytest.param(None, None, id="standalone-team"),
pytest.param("unbudgeted-org", _UNBUDGETED_ORG, id="org-without-a-budget"),
],
)
async def test_update_team_lets_a_granted_team_admin_raise_a_budget_with_no_org_cap(
disable_audit_logging_for_mocked_team: None,
organization_id: str | None,
org_table: LiteLLM_OrganizationTable | None,
):
"""Nothing caps the raise when the team has no organization, or its organization has no max_budget."""
import contextlib
with contextlib.ExitStack() as stack:
store = _granted_raise(stack, organization_id, org_table)
result = await update_team(
data=UpdateTeamRequest(team_id="test_team_id", max_budget=5000.0),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
assert result is not None
assert store.row["max_budget"] == 5000.0
@pytest.mark.asyncio
async def test_update_team_caps_a_granted_team_admin_raise_at_the_org_budget(
disable_audit_logging_for_mocked_team: None,
):
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:
store = _granted_raise(stack, "budgeted-org", budgeted_org)
with pytest.raises(ProxyException) as raised:
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", max_budget=100.01),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
budget_after_refusal = store.row["max_budget"]
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", max_budget=100.0),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
assert str(raised.value.code) == "400"
assert "exceeds organization's max_budget (100.0)" in str(raised.value.message)
assert budget_after_refusal == 10.0
assert store.row["max_budget"] == 100.0
@pytest.mark.asyncio
async def test_update_team_still_stops_a_granted_team_admin_removing_the_budget(
disable_audit_logging_for_mocked_team: None,
):
import contextlib
with contextlib.ExitStack() as stack:
store = _granted_raise(stack, None, None)
with pytest.raises(ProxyException) as raised:
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", max_budget=None),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
assert str(raised.value.code) == "403"
assert "Only a proxy admin can remove a team's max_budget" in str(raised.value.message)
assert store.row["max_budget"] == 10.0
@pytest.mark.asyncio
async def test_update_team_keeps_a_budget_cut_that_lands_while_a_granted_team_admin_raise_runs(
disable_audit_logging_for_mocked_team: None,
):
"""The raise was checked against the budget it read; a proxy admin's cut that commits in between still has
to make the team admin reload rather than be silently overwritten."""
import contextlib
with contextlib.ExitStack() as stack:
store = _granted_raise(stack, None, None, committed_after_read={"max_budget": 2.0})
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,
)
assert str(raised.value.code) == "409"
assert store.row["max_budget"] == 2.0
@pytest.mark.asyncio
async def test_update_team_keeps_an_org_move_that_lands_while_a_granted_team_admin_raise_runs(
disable_audit_logging_for_mocked_team: None,
):
"""The raise was checked against a standalone team, so no org cap applied; a proxy admin moving the team
into a budgeted org in between must not let the uncapped raise land on the now capped team."""
import contextlib
with contextlib.ExitStack() as stack:
store = _granted_raise(stack, None, None, committed_after_read={"organization_id": "budgeted-org"})
with pytest.raises(ProxyException) as raised:
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", max_budget=500.0),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
assert str(raised.value.code) == "409"
assert store.row["max_budget"] == 10.0
assert store.row["organization_id"] == "budgeted-org"
@pytest.mark.asyncio
@pytest.mark.parametrize(
("organization_id", "budget_read", "requested"),
@ -16556,7 +16727,10 @@ async def test_update_team_stops_a_team_admin_raising_an_org_team_budget_under_t
],
)
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
disable_audit_logging_for_mocked_team: None,
organization_id: str | None,
budget_read: float | None,
requested: float,
):
"""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."""
@ -16573,7 +16747,7 @@ async def test_update_team_keeps_a_budget_cut_that_lands_while_a_team_admin_upda
"max_budget": budget_read,
"members_with_roles": [{"user_id": "team-admin", "role": "admin"}],
},
budget_set_after_read=20.0,
committed_after_read={"max_budget": 20.0},
)
stack.enter_context(_team_admin_may_edit("max_budget"))
stack.enter_context(_not_org_admin())
@ -16684,9 +16858,30 @@ _MEMBER_CALLER = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_i
_ROSTER_ADMIN_CALLER,
False,
("tpm_limit",),
{"kind": "team_admin", "editable_fields": ["tpm_limit"]},
{"kind": "team_admin", "editable_fields": ["tpm_limit"], "may_raise_max_budget": False},
id="team-admin-field-enabled",
),
pytest.param(
_ROSTER_ADMIN_CALLER,
False,
("max_budget",),
{"kind": "team_admin", "editable_fields": ["max_budget"], "may_raise_max_budget": False},
id="team-admin-keep-or-lower-budget",
),
pytest.param(
_ROSTER_ADMIN_CALLER,
False,
("max_budget", "raise_max_budget"),
{"kind": "team_admin", "editable_fields": ["max_budget"], "may_raise_max_budget": True},
id="team-admin-may-raise-budget",
),
pytest.param(
_ROSTER_ADMIN_CALLER,
False,
("tpm_limit", "raise_max_budget"),
{"kind": "team_admin", "editable_fields": ["tpm_limit"], "may_raise_max_budget": False},
id="team-admin-raise-without-max-budget-is-inert",
),
pytest.param(_MEMBER_CALLER, False, ("tpm_limit",), {"kind": "none"}, id="plain-member"),
],
)

View file

@ -3940,6 +3940,46 @@ class TestTeamAdminEditableTeamFieldsSetting:
assert stored["team_admin_editable_team_fields"] == enabled
assert general_settings["team_admin_editable_team_fields"] == enabled
def test_patch_rejects_raise_max_budget_without_max_budget(self, monkeypatch: pytest.MonkeyPatch):
mock_prisma = self._as_proxy_admin(monkeypatch)
try:
response = client.patch(
"/update/ui_settings", json={"team_admin_editable_team_fields": ["tpm_limit", "raise_max_budget"]}
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 400
detail = response.json()["detail"]["error"]
assert "'raise_max_budget'" in detail
assert "'max_budget'" in detail
assert not mock_prisma.db.litellm_uisettings.upsert.called
def test_patch_accepts_raise_max_budget_with_max_budget_and_update_team_sees_it(
self, monkeypatch: pytest.MonkeyPatch
):
from litellm.proxy.management_endpoints.team_admin_field_permissions import (
team_admin_may_raise_max_budget,
)
mock_prisma = self._as_proxy_admin(monkeypatch)
general_settings: dict[str, object] = {"team_admin_editable_team_fields": ["max_budget"]}
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings)
assert team_admin_may_raise_max_budget(general_settings) is False
try:
response = client.patch(
"/update/ui_settings", json={"team_admin_editable_team_fields": ["max_budget", "raise_max_budget"]}
)
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"] == ["max_budget", "raise_max_budget"]
assert team_admin_may_raise_max_budget(general_settings) is True
def test_patch_accepts_the_projects_permission_and_project_endpoints_see_it(self, monkeypatch):
from litellm.proxy.management_endpoints.team_admin_field_permissions import (
team_admin_may_manage_projects,

View file

@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { fireEvent, renderWithProviders, screen, waitFor } from "@/../tests/test-utils";
import { toast } from "@/lib/toast";
@ -22,6 +23,12 @@ vi.mock("@/app/(dashboard)/hooks/uiSettings/useUpdateUISettings", () => ({
const TPM_LABEL = "Tokens per minute Limit (TPM)";
const MAX_BUDGET_LABEL = "Max Budget (USD)";
const RAISE_MAX_BUDGET_LABEL = "Raise the team's max budget";
const RAISE_MAX_BUDGET_HELP_LABEL = "About raising the team's max budget";
const RAISE_MAX_BUDGET_TOOLTIP =
"Lets team admins raise the budget too, capped by the organization's budget when the team has one. Only proxy admins can remove it.";
const RAISE_MAX_BUDGET_DOCS_URL =
"https://docs.litellm.ai/docs/proxy/access_control#choosing-what-team-admins-can-edit";
const mockSettings = (supported: readonly string[], enabled: readonly string[]) =>
mockUseUISettings.mockReturnValue({
@ -54,6 +61,8 @@ const mockSave = ({
};
const saveButton = () => screen.getByRole("button", { name: "Save" });
const maxBudgetCheckbox = () => screen.getByRole("checkbox", { name: MAX_BUDGET_LABEL });
const raiseMaxBudgetCheckbox = () => screen.getByRole("checkbox", { name: RAISE_MAX_BUDGET_LABEL });
describe("TeamAdminEditableFieldsSettings", () => {
beforeEach(() => {
@ -173,4 +182,91 @@ describe("TeamAdminEditableFieldsSettings", () => {
expect(screen.getByRole("button", { name: "Saving..." })).toBeDisabled();
expect(mutate).not.toHaveBeenCalled();
});
describe("raise_max_budget nested under max_budget", () => {
const supported = ["tpm_limit", "rpm_limit", "max_budget", "raise_max_budget"];
it("keeps the Raise checkbox disabled, and unticked when clicked, until Max Budget is ticked", () => {
mockSettings(supported, []);
mockSave({});
renderWithProviders(<TeamAdminEditableFieldsSettings />);
expect(raiseMaxBudgetCheckbox()).toHaveAttribute("aria-disabled", "true");
fireEvent.click(raiseMaxBudgetCheckbox());
expect(raiseMaxBudgetCheckbox()).not.toBeChecked();
expect(saveButton()).toBeDisabled();
fireEvent.click(maxBudgetCheckbox());
expect(raiseMaxBudgetCheckbox()).not.toHaveAttribute("aria-disabled", "true");
fireEvent.click(raiseMaxBudgetCheckbox());
expect(raiseMaxBudgetCheckbox()).toBeChecked();
});
it("saves Max Budget together with Raise in the proxy's field order", async () => {
mockSettings(supported, []);
const mutate = mockSave({});
renderWithProviders(<TeamAdminEditableFieldsSettings />);
fireEvent.click(maxBudgetCheckbox());
fireEvent.click(raiseMaxBudgetCheckbox());
expect(raiseMaxBudgetCheckbox()).toBeChecked();
fireEvent.click(saveButton());
await waitFor(() => expect(mutate).toHaveBeenCalledTimes(1));
expect(mutate).toHaveBeenCalledWith(
{ team_admin_editable_team_fields: ["max_budget", "raise_max_budget"] },
expect.anything(),
);
});
it("drops Raise from the saved list when Max Budget is unticked", async () => {
mockSettings(supported, ["tpm_limit", "max_budget", "raise_max_budget"]);
const mutate = mockSave({});
renderWithProviders(<TeamAdminEditableFieldsSettings />);
expect(screen.getByText("3 fields enabled")).toBeInTheDocument();
expect(raiseMaxBudgetCheckbox()).toBeChecked();
fireEvent.click(maxBudgetCheckbox());
expect(raiseMaxBudgetCheckbox()).not.toBeChecked();
expect(raiseMaxBudgetCheckbox()).toHaveAttribute("aria-disabled", "true");
fireEvent.click(saveButton());
await waitFor(() => expect(mutate).toHaveBeenCalledTimes(1));
expect(mutate).toHaveBeenCalledWith({ team_admin_editable_team_fields: ["tpm_limit"] }, expect.anything());
});
it("explains what Raise allows in a tooltip on its help icon, with a link to the docs", async () => {
const user = userEvent.setup();
mockSettings(supported, []);
mockSave({});
renderWithProviders(<TeamAdminEditableFieldsSettings />);
expect(screen.queryByText(RAISE_MAX_BUDGET_TOOLTIP)).not.toBeInTheDocument();
await user.hover(screen.getByRole("button", { name: RAISE_MAX_BUDGET_HELP_LABEL }));
expect(await screen.findByText(RAISE_MAX_BUDGET_TOOLTIP)).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Learn more" })).toHaveAttribute("href", RAISE_MAX_BUDGET_DOCS_URL);
});
it("renders nothing nested when the proxy does not support Raise", () => {
mockSettings(["max_budget", "tpm_limit"], ["max_budget"]);
mockSave({});
renderWithProviders(<TeamAdminEditableFieldsSettings />);
expect(screen.queryByRole("checkbox", { name: RAISE_MAX_BUDGET_LABEL })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: RAISE_MAX_BUDGET_HELP_LABEL })).not.toBeInTheDocument();
expect(screen.getAllByRole("checkbox")).toHaveLength(2);
});
});
});

View file

@ -1,5 +1,7 @@
"use client";
import { CircleHelp } from "lucide-react";
import { Fragment, type ReactNode } from "react";
import { Controller } from "react-hook-form";
import { z } from "zod";
@ -9,13 +11,17 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import {
parseSupportedTeamAdminEditableFields,
parseTeamAdminEditableFields,
TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION,
teamAdminFieldLabel,
type TeamAdminSettingsField,
} from "@/components/team/teamAdminEditAccess";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/cva.config";
import { useZodForm } from "@/lib/forms/useZodForm";
import { toast } from "@/lib/toast";
@ -23,6 +29,26 @@ const editableFieldsSchema = z.object({ team_admin_editable_team_fields: z.array
type SaveEditableFields = ReturnType<typeof useUpdateUISettings>["mutate"];
const MAX_BUDGET_FIELD: TeamAdminSettingsField = "max_budget";
const RAISE_MAX_BUDGET_HELP_LABEL = "About raising the team's max budget";
const RAISE_MAX_BUDGET_TOOLTIP =
"Lets team admins raise the budget too, capped by the organization's budget when the team has one. Only proxy admins can remove it.";
const RAISE_MAX_BUDGET_DOCS_URL =
"https://docs.litellm.ai/docs/proxy/access_control#choosing-what-team-admins-can-edit";
const toggleEditableField = (
supportedFields: readonly string[],
draft: readonly string[],
name: string,
checked: boolean,
): string[] => {
const selected = supportedFields.filter((item) => (item === name ? checked : draft.includes(item)));
return selected.includes(MAX_BUDGET_FIELD)
? selected
: selected.filter((item) => item !== TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION);
};
export default function TeamAdminEditableFieldsSettings() {
const { accessToken } = useAuthorized();
const { data, isLoading } = useUISettings();
@ -100,33 +126,42 @@ function TeamAdminEditableFieldsForm({
);
}
const raiseMaxBudgetSupported = supportedFields.includes(TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION);
const listedFields = supportedFields.filter((name) => name !== TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION);
return (
<form onSubmit={(event) => void submit(event)} className="space-y-4">
<Controller
control={form.control}
name="team_admin_editable_team_fields"
render={({ field }) => (
<div className="space-y-2">
{supportedFields.map((name) => {
const checkboxId = `team-admin-editable-${name}`;
return (
<label key={name} htmlFor={checkboxId} className="flex cursor-pointer items-center gap-2">
<Checkbox
id={checkboxId}
render={({ field }) => {
const toggle = (name: string) => (checked: boolean) =>
field.onChange(toggleEditableField(supportedFields, field.value, name, checked));
return (
<div className="space-y-2">
{listedFields.map((name) => (
<Fragment key={name}>
<EditableFieldCheckbox
name={name}
checked={field.value.includes(name)}
disabled={isPending}
onCheckedChange={(checked) =>
field.onChange(
supportedFields.filter((item) => (item === name ? checked : field.value.includes(item))),
)
}
onCheckedChange={toggle(name)}
/>
<span className="text-sm text-foreground">{teamAdminFieldLabel(name)}</span>
</label>
);
})}
</div>
)}
{name === MAX_BUDGET_FIELD && raiseMaxBudgetSupported && (
<EditableFieldCheckbox
name={TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION}
checked={field.value.includes(TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION)}
disabled={isPending || !field.value.includes(MAX_BUDGET_FIELD)}
onCheckedChange={toggle(TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION)}
className="ml-6"
help={<RaiseMaxBudgetHelp />}
/>
)}
</Fragment>
))}
</div>
);
}}
/>
<div className="flex justify-end">
<Button type="submit" disabled={isPending || !form.formState.isDirty}>
@ -136,3 +171,56 @@ function TeamAdminEditableFieldsForm({
</form>
);
}
interface EditableFieldCheckboxProps {
name: string;
checked: boolean;
disabled: boolean;
onCheckedChange: (checked: boolean) => void;
className?: string;
help?: ReactNode;
}
function EditableFieldCheckbox({
name,
checked,
disabled,
onCheckedChange,
className,
help,
}: EditableFieldCheckboxProps) {
const checkboxId = `team-admin-editable-${name}`;
return (
<div className={cn("flex items-center gap-2", className)}>
<label htmlFor={checkboxId} className="flex cursor-pointer items-center gap-2">
<Checkbox id={checkboxId} checked={checked} disabled={disabled} onCheckedChange={onCheckedChange} />
<span className="text-sm text-foreground">{teamAdminFieldLabel(name)}</span>
</label>
{help}
</div>
);
}
function RaiseMaxBudgetHelp() {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
type="button"
aria-label={RAISE_MAX_BUDGET_HELP_LABEL}
className="inline-flex cursor-help items-center rounded-sm text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<CircleHelp className="size-3.5" />
</TooltipTrigger>
<TooltipContent>
<span>
{RAISE_MAX_BUDGET_TOOLTIP}{" "}
<a href={RAISE_MAX_BUDGET_DOCS_URL} target="_blank" rel="noopener noreferrer" className="underline">
Learn more
</a>
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View file

@ -5,13 +5,17 @@ import { fireEvent, renderWithProviders, screen, waitFor } from "@/../tests/test
import TeamAdminSettingsForm from "./TeamAdminSettingsForm";
const renderForm = (editableFields: ReadonlySet<string>, overrides: { isSaving?: boolean } = {}) => {
const renderForm = (
editableFields: ReadonlySet<string>,
overrides: { isSaving?: boolean; mayRaiseMaxBudget?: boolean } = {},
) => {
const onSave = vi.fn().mockResolvedValue(undefined);
const onCancel = vi.fn();
renderWithProviders(
<TeamAdminSettingsForm
initialValues={{ tpm_limit: 1000, rpm_limit: 50, max_budget: 20 }}
editableFields={editableFields}
mayRaiseMaxBudget={overrides.mayRaiseMaxBudget ?? false}
isSaving={overrides.isSaving ?? false}
onCancel={onCancel}
onSave={onSave}
@ -97,4 +101,20 @@ describe("TeamAdminSettingsForm", () => {
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
expect(screen.getByRole("button", { name: /save changes/i })).toBeDisabled();
});
it.each([
[false, "You can keep or lower this budget. Ask a proxy admin to raise it."],
[true, "You can raise this budget. Raises are capped by your organization's budget when the team belongs to one."],
])("when mayRaiseMaxBudget is %s, describes only the Max Budget input with: %s", (mayRaiseMaxBudget, hint) => {
renderForm(new Set(["tpm_limit", "max_budget"]), { mayRaiseMaxBudget });
expect(screen.getByLabelText("Max Budget (USD)")).toHaveAccessibleDescription(hint);
expect(screen.getByLabelText("Tokens per minute Limit (TPM)")).not.toHaveAccessibleDescription();
});
it("shows no budget hint when the proxy has not enabled Max Budget for team admins", () => {
renderForm(new Set(["tpm_limit"]), { mayRaiseMaxBudget: true });
expect(screen.queryByText(/this budget/)).not.toBeInTheDocument();
});
});

View file

@ -30,9 +30,19 @@ const teamAdminSettingsSchema = z.object({
const INPUT_STEP: Readonly<Record<TeamAdminSettingsField, number>> = { tpm_limit: 1, rpm_limit: 1, max_budget: 0.01 };
const MAX_BUDGET_KEEP_OR_LOWER_HINT = "You can keep or lower this budget. Ask a proxy admin to raise it.";
const MAX_BUDGET_RAISE_HINT =
"You can raise this budget. Raises are capped by your organization's budget when the team belongs to one.";
const fieldHint = (name: TeamAdminSettingsField, mayRaiseMaxBudget: boolean): string | undefined => {
if (name !== "max_budget") return undefined;
return mayRaiseMaxBudget ? MAX_BUDGET_RAISE_HINT : MAX_BUDGET_KEEP_OR_LOWER_HINT;
};
interface TeamAdminSettingsFormProps {
initialValues: TeamAdminSettingsValues;
editableFields: ReadonlySet<string>;
mayRaiseMaxBudget: boolean;
isSaving: boolean;
onCancel: () => void;
onSave: (changes: TeamAdminSettingsChanges) => Promise<void>;
@ -41,6 +51,7 @@ interface TeamAdminSettingsFormProps {
export default function TeamAdminSettingsForm({
initialValues,
editableFields,
mayRaiseMaxBudget,
isSaving,
onCancel,
onSave,
@ -57,7 +68,13 @@ export default function TeamAdminSettingsForm({
A proxy admin chose which settings team admins can change. Ask a proxy admin to change anything else.
</p>
{TEAM_ADMIN_SETTINGS_FIELDS.filter((name) => editableFields.has(name)).map((name) => (
<FormField key={name} control={form.control} name={name} label={teamAdminFieldLabel(name)}>
<FormField
key={name}
control={form.control}
name={name}
label={teamAdminFieldLabel(name)}
description={fieldHint(name, mayRaiseMaxBudget)}
>
{({ ref, value, ...field }) => (
<NumericalInput {...field} ref={ref} value={value ?? ""} step={INPUT_STEP[name]} />
)}

View file

@ -1266,6 +1266,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<TeamAdminSettingsForm
initialValues={{ tpm_limit: info.tpm_limit, rpm_limit: info.rpm_limit, max_budget: info.max_budget }}
editableFields={teamEditAccess.editableFields}
mayRaiseMaxBudget={teamEditAccess.mayRaiseMaxBudget}
isSaving={isTeamSaving}
onCancel={() => setIsEditing(false)}
onSave={saveTeamAdminSettings}

View file

@ -13,6 +13,7 @@ describe("teamAdminFieldLabel", () => {
["tpm_limit", "Tokens per minute Limit (TPM)"],
["rpm_limit", "Requests per minute Limit (RPM)"],
["max_budget", "Max Budget (USD)"],
["raise_max_budget", "Raise the team's max budget"],
["projects", "Create and update projects"],
])("names %s the way the team settings form does", (field, label) => {
expect(teamAdminFieldLabel(field)).toBe(label);
@ -129,6 +130,20 @@ describe("parseTeamEditAccess", () => {
expect(parseTeamEditAccess({ kind: "team_admin", editable_fields: ["tpm_limit"] })).toEqual({
kind: "team_admin",
editableFields: new Set(["tpm_limit"]),
mayRaiseMaxBudget: false,
});
});
it.each([
["the proxy allows raising", { may_raise_max_budget: true }, true],
["the proxy forbids raising", { may_raise_max_budget: false }, false],
["an older proxy omits the flag", {}, false],
["the flag is not a boolean", { may_raise_max_budget: "yes" }, false],
])("lets a team admin raise the max budget only when %s", (_label, flag, mayRaiseMaxBudget) => {
expect(parseTeamEditAccess({ kind: "team_admin", editable_fields: ["max_budget"], ...flag })).toEqual({
kind: "team_admin",
editableFields: new Set(["max_budget"]),
mayRaiseMaxBudget,
});
});

View file

@ -3,13 +3,19 @@ import { numberOrNull } from "@/lib/forms/numberOrNull";
export const TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING = "team_admin_editable_team_fields";
export const TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION = "raise_max_budget";
export const TEAM_ADMIN_EDITING_DISABLED_TITLE = "Team admins cannot edit team settings on this proxy";
export const TEAM_ADMIN_EDITING_DISABLED_DESCRIPTION =
"Ask a proxy admin to enable fields under Settings > UI > Team admin editable fields.";
const callerEditAccessSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("unrestricted") }),
z.object({ kind: z.literal("team_admin"), editable_fields: z.array(z.string()) }),
z.object({
kind: z.literal("team_admin"),
editable_fields: z.array(z.string()),
may_raise_max_budget: z.boolean().catch(false),
}),
z.object({ kind: z.literal("team_admin_disabled") }),
z.object({ kind: z.literal("none") }),
]);
@ -18,7 +24,7 @@ export type CallerEditAccess = z.infer<typeof callerEditAccessSchema>;
export type TeamEditAccess =
| { readonly kind: "unrestricted" }
| { readonly kind: "team_admin"; readonly editableFields: ReadonlySet<string> }
| { readonly kind: "team_admin"; readonly editableFields: ReadonlySet<string>; readonly mayRaiseMaxBudget: boolean }
| { readonly kind: "team_admin_disabled" }
| { readonly kind: "none" };
@ -48,6 +54,7 @@ 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)"],
[TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION, "Raise the team's max budget"],
["projects", "Create and update projects"],
["member_key_budgets", "Update budgets on team members' keys"],
]);
@ -74,7 +81,11 @@ export const parseTeamEditAccess = (callerEditAccess: unknown): TeamEditAccess =
const parsed = callerEditAccessSchema.safeParse(callerEditAccess);
if (!parsed.success) return { kind: "none" };
if (parsed.data.kind === "team_admin") {
return { kind: "team_admin", editableFields: new Set(parsed.data.editable_fields) };
return {
kind: "team_admin",
editableFields: new Set(parsed.data.editable_fields),
mayRaiseMaxBudget: parsed.data.may_raise_max_budget,
};
}
return parsed.data;
};