mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
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:
parent
3ebc71be2a
commit
4c78023db2
17 changed files with 739 additions and 74 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue