mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
Merge pull request #41525 from BerriAI/litellm_team_admin_rpm_budget_fields
feat(proxy): let team admins edit rpm_limit and max_budget when enabled
This commit is contained in:
commit
8b64f1ef03
13 changed files with 470 additions and 125 deletions
|
|
@ -20,7 +20,7 @@ from litellm.proxy._types import (
|
|||
TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING: Final = "team_admin_editable_team_fields"
|
||||
|
||||
# TODO(LIT-5722): add the remaining team settings one per PR, each with its value-diff tests and dashboard field
|
||||
SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS: Final[frozenset[str]] = frozenset({"tpm_limit"})
|
||||
SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS: Final[frozenset[str]] = frozenset({"tpm_limit", "rpm_limit", "max_budget"})
|
||||
|
||||
_FIELD_LIST: Final = TypeAdapter(list[str])
|
||||
_JSON_OBJECT: Final = TypeAdapter(dict[str, object])
|
||||
|
|
@ -148,7 +148,7 @@ def _only_changes(data: UpdateTeamRequest, changed: frozenset[str]) -> UpdateTea
|
|||
"""The request without the values it resends unchanged, which would otherwise still trigger derived writes
|
||||
such as a resent budget_duration pushing budget_reset_at back."""
|
||||
sent: Final = frozenset(data.model_fields_set)
|
||||
via_metadata: Final = frozenset({"metadata"}) if changed - sent else frozenset()
|
||||
via_metadata: Final = frozenset({"metadata"}) if changed - sent else frozenset[str]()
|
||||
kept: Final = frozenset({"team_id"}) | (changed & sent) | via_metadata
|
||||
return UpdateTeamRequest.model_validate(data.model_dump(include=MappingProxyType({field: True for field in kept})))
|
||||
|
||||
|
|
@ -169,8 +169,8 @@ def team_admin_edit_verdict(
|
|||
|
||||
def team_admin_request_or_raise(verdict: TeamAdminEditVerdict) -> UpdateTeamRequest:
|
||||
match verdict:
|
||||
case TeamAdminEditAllowed(request=request):
|
||||
return request
|
||||
case TeamAdminEditAllowed():
|
||||
return verdict.request
|
||||
case TeamAdminEditingDisabled():
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import math
|
|||
import traceback
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from collections.abc import Set as AbstractSet
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from types import MappingProxyType
|
||||
from typing import (
|
||||
|
|
@ -340,6 +341,14 @@ class _ErrorDetail(TypedDict):
|
|||
error: ReadOnly[str]
|
||||
|
||||
|
||||
class _TeamIdWhere(TypedDict):
|
||||
team_id: ReadOnly[str]
|
||||
|
||||
|
||||
class _TeamIdAndBudgetWhere(_TeamIdWhere):
|
||||
max_budget: ReadOnly[float | None]
|
||||
|
||||
|
||||
class _TeamCreateTx(AccessGroupSyncTx, Protocol):
|
||||
@property
|
||||
def litellm_teamtable(self) -> "TableActions[prisma_models.LiteLLM_TeamTable]": ...
|
||||
|
|
@ -1200,26 +1209,39 @@ async def _check_user_team_limits(
|
|||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _MaxBudgetGuard:
|
||||
"""The team write only lands while the stored max_budget still equals `expected`."""
|
||||
|
||||
expected: float | None
|
||||
|
||||
|
||||
def _check_team_budget_update_authority(
|
||||
data: UpdateTeamRequest,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
existing_team_max_budget: float | None,
|
||||
) -> None:
|
||||
) -> _MaxBudgetGuard | None:
|
||||
"""
|
||||
Restrict who can grow a standalone team's spend ceiling on /team/update.
|
||||
Restrict who can grow a team's spend ceiling on /team/update.
|
||||
|
||||
A team admin (already authorized via _verify_team_access) may keep or lower
|
||||
the team budget, but only a proxy admin may grow it - by raising max_budget
|
||||
above the team's current value or by removing the cap (setting it to None).
|
||||
Setting a finite budget on a team that has no cap is a restriction and is
|
||||
allowed. Org-scoped teams are governed by _check_org_team_limits().
|
||||
A team admin may keep or lower the team budget, but only a proxy admin may
|
||||
grow it - by raising max_budget above the team's current value or by
|
||||
removing the cap (setting it to None). Setting a finite budget on a team
|
||||
that has no cap is a restriction and is allowed. Org admins editing
|
||||
org-scoped teams are governed by _check_org_team_limits() instead.
|
||||
|
||||
The verdict holds only for the budget it was checked against, so a restricted
|
||||
caller's budget write gets a guard; without it, a concurrent budget cut could
|
||||
be overwritten with a higher value.
|
||||
"""
|
||||
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN:
|
||||
return
|
||||
if existing_team_max_budget is None:
|
||||
return
|
||||
return None
|
||||
|
||||
budget_explicitly_set: Final = "max_budget" in (getattr(data, "model_fields_set", None) or set())
|
||||
guard: Final = _MaxBudgetGuard(expected=existing_team_max_budget) if budget_explicitly_set else None
|
||||
if existing_team_max_budget is None:
|
||||
return guard
|
||||
|
||||
if budget_explicitly_set and data.max_budget is None:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
|
|
@ -1235,6 +1257,37 @@ def _check_team_budget_update_authority(
|
|||
"error": f"Only a proxy admin can raise a team's max_budget. Team's current max_budget={existing_team_max_budget}, requested={data.max_budget}."
|
||||
},
|
||||
)
|
||||
return guard
|
||||
|
||||
|
||||
_TEAM_UPDATE_INCLUDE: Final = MappingProxyType(
|
||||
{
|
||||
"litellm_model_table": True,
|
||||
# `object_permission` is included so `_refresh_cached_team`
|
||||
# doesn't write a cached team with the relation nulled out.
|
||||
# See team_model_add for the full rationale.
|
||||
"object_permission": True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _write_team_update(
|
||||
prisma_client: PrismaClient | None,
|
||||
team_id: str,
|
||||
team_update_data: Mapping[str, object],
|
||||
max_budget_guard: _MaxBudgetGuard | None,
|
||||
) -> "prisma_models.LiteLLM_TeamTable | None":
|
||||
by_id: Final[_TeamIdWhere] = {"team_id": team_id}
|
||||
if max_budget_guard is None:
|
||||
return await _team_db(prisma_client).update(where=by_id, data=team_update_data, include=_TEAM_UPDATE_INCLUDE)
|
||||
by_id_and_budget: Final[_TeamIdAndBudgetWhere] = {"team_id": team_id, "max_budget": max_budget_guard.expected}
|
||||
written: Final = await _team_db(prisma_client).update_many(where=by_id_and_budget, data=team_update_data)
|
||||
if written == 0:
|
||||
conflict: Final[_ErrorDetail] = {
|
||||
"error": "The team's max_budget changed during this update. Reload the team and try again."
|
||||
}
|
||||
raise HTTPException(status_code=409, detail=conflict)
|
||||
return await _team_db(prisma_client).find_unique(where=by_id, include=_TEAM_UPDATE_INCLUDE)
|
||||
|
||||
|
||||
def _existing_model_cap(raw_budget_config: object) -> BudgetConfig | None:
|
||||
|
|
@ -2339,14 +2392,17 @@ async def update_team(
|
|||
prisma_client=prisma_client,
|
||||
)
|
||||
|
||||
# Only a proxy admin may grow a standalone team's spend ceiling.
|
||||
# Org-scoped teams are validated by _check_org_team_limits() above.
|
||||
if org_id_to_check is None:
|
||||
# A team admin never grows its own team's spend ceiling. Org admins grow org-scoped teams
|
||||
# within the org limits _check_org_team_limits() enforced above.
|
||||
max_budget_guard: Final = (
|
||||
_check_team_budget_update_authority(
|
||||
data=data,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
existing_team_max_budget=existing_team_row.max_budget,
|
||||
)
|
||||
if org_id_to_check is None or access_role == "team_admin"
|
||||
else None
|
||||
)
|
||||
_check_team_model_budget_update_authority(
|
||||
data=data,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
|
|
@ -2493,17 +2549,7 @@ async def update_team(
|
|||
|
||||
updated_kv = prisma_client.jsonify_team_object(db_data=updated_kv)
|
||||
team_update_data: Final[Mapping[str, object]] = updated_kv
|
||||
team_row: Final = await _team_db(prisma_client).update(
|
||||
where={"team_id": data.team_id},
|
||||
data=team_update_data,
|
||||
# `object_permission` is included so `_refresh_cached_team`
|
||||
# doesn't write a cached team with the relation nulled out.
|
||||
# See team_model_add for the full rationale.
|
||||
include={
|
||||
"litellm_model_table": True,
|
||||
"object_permission": True,
|
||||
},
|
||||
)
|
||||
team_row: Final = await _write_team_update(prisma_client, data.team_id, team_update_data, max_budget_guard)
|
||||
|
||||
if team_row is None or team_row.team_id is None:
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
- {id: mgmt.team.update.persists, module: mgmt, tier: P1, surface: api, assertions: [persists], source: "team_endpoints.py:1582", rationale: "Metadata/budget updates persist"}
|
||||
- {id: mgmt.team.update.team_admin_forbidden_until_enabled, module: mgmt, tier: P0, surface: api, assertions: [team_admin_forbidden_until_enabled], source: "team_admin_field_permissions.py:156", rationale: "With no team admin editable fields enabled, a team admin's /team/update is 403 and /team/info reports editing disabled"}
|
||||
- {id: mgmt.team.update.team_admin_limited_to_enabled_fields, module: mgmt, tier: P0, surface: api, assertions: [team_admin_limited_to_enabled_fields], source: "team_admin_field_permissions.py:156", rationale: "A team admin may change only the enabled fields; a request that also changes any other field is 403 and writes nothing"}
|
||||
- {id: mgmt.team.update.team_admin_cannot_grow_budget, module: mgmt, tier: P0, surface: api, assertions: [team_admin_cannot_grow_budget], source: "team_endpoints.py:1203", fail_before_fix: proven, rationale: "With max_budget enabled, a team admin may keep or lower its team's budget; raising or removing it is 403 and writes nothing, also under an organization's larger cap"}
|
||||
- {id: mgmt.team.update.team_admin_resend_keeps_budget_reset, module: mgmt, tier: P1, surface: api, assertions: [team_admin_resend_keeps_budget_reset], source: "team_admin_field_permissions.py:147", fail_before_fix: proven, rationale: "A team admin resending unchanged budget settings with an enabled field must not push the team's budget reset times back"}
|
||||
- {id: mgmt.team.delete.persists, module: mgmt, tier: P1, surface: api, assertions: [persists], source: "team_endpoints.py:1750", rationale: "Deletion prevents key access"}
|
||||
- {id: mgmt.team.block.persists, module: mgmt, tier: P1, surface: api, assertions: [persists], source: "team_endpoints.py", rationale: "Block suspends all members"}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ from lifecycle import ResourceManager
|
|||
from management_client import ManagementClient
|
||||
from models import (
|
||||
KeyGenerateBody,
|
||||
OrgNewBody,
|
||||
TeamInfoParams,
|
||||
TeamMemberAddBody,
|
||||
TeamMemberDeleteBody,
|
||||
|
|
@ -45,6 +46,8 @@ pytestmark = pytest.mark.e2e
|
|||
TeamRole = Literal["admin", "user"]
|
||||
|
||||
_TEAM_TPM_LIMIT: Final = 1000
|
||||
_TEAM_MAX_BUDGET: Final = 10.0
|
||||
_ORG_MAX_BUDGET: Final = 100.0
|
||||
|
||||
|
||||
class TeamBlockBody(BaseModel):
|
||||
|
|
@ -114,9 +117,14 @@ class TeamInfoRead(BaseModel):
|
|||
|
||||
class TeamWithAdminNewBody(TeamNewBody):
|
||||
tpm_limit: int
|
||||
max_budget: float | None = None
|
||||
members_with_roles: list[TeamMemberEntry]
|
||||
|
||||
|
||||
class OrgWithBudgetNewBody(OrgNewBody):
|
||||
max_budget: float
|
||||
|
||||
|
||||
class TeamSettingsChange(PartialBody, TeamSettings):
|
||||
pass
|
||||
|
||||
|
|
@ -414,13 +422,26 @@ def tpm_limit_editable_by_team_admins(client: ManagementClient) -> Generator[Non
|
|||
yield
|
||||
|
||||
|
||||
def _team_with_admin(client: ManagementClient, resources: ResourceManager) -> tuple[str, str]:
|
||||
@pytest.fixture(scope="class")
|
||||
def rpm_limit_and_max_budget_editable_by_team_admins(client: ManagementClient) -> Generator[None]:
|
||||
with _team_admins_may_edit(client, ["rpm_limit", "max_budget"]):
|
||||
yield
|
||||
|
||||
|
||||
def _team_with_admin(
|
||||
client: ManagementClient,
|
||||
resources: ResourceManager,
|
||||
max_budget: float | None = None,
|
||||
organization_id: str | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""A team with a tpm_limit, and the key of a user who is an admin of that team."""
|
||||
admin_id = _create_user(client, resources, f"e2e-team-admin-{unique_marker()}@example.com")
|
||||
team_id = client.create_team(
|
||||
TeamWithAdminNewBody(
|
||||
team_alias=f"e2e-team-admin-{unique_marker()}",
|
||||
tpm_limit=_TEAM_TPM_LIMIT,
|
||||
max_budget=max_budget,
|
||||
organization_id=organization_id,
|
||||
members_with_roles=[TeamMemberEntry(role="admin", user_id=admin_id)],
|
||||
)
|
||||
)
|
||||
|
|
@ -580,3 +601,93 @@ class TestTeamAdminWithTpmLimitEnabled:
|
|||
assert after.budget_limits == budgeted.budget_limits, (
|
||||
f"the team admin pushed the budget window resets from {budgeted.budget_limits} to {after.budget_limits}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("rpm_limit_and_max_budget_editable_by_team_admins")
|
||||
class TestTeamAdminWithRpmLimitAndMaxBudgetEnabled:
|
||||
"""A proxy admin has enabled rpm_limit and max_budget, so a team admin may change the RPM limit and keep or
|
||||
lower the team's budget. Raising or removing the budget stays with the proxy admin."""
|
||||
|
||||
@pytest.mark.covers("mgmt.team.update.team_admin_limited_to_enabled_fields")
|
||||
@pytest.mark.parametrize(
|
||||
"current_budget",
|
||||
[pytest.param(_TEAM_MAX_BUDGET, id="lower"), pytest.param(None, id="first-budget")],
|
||||
)
|
||||
def test_team_admin_saves_a_new_rpm_limit_and_a_tighter_budget(
|
||||
self, client: ManagementClient, resources: ResourceManager, current_budget: float | None
|
||||
) -> None:
|
||||
team_id, admin_key = _team_with_admin(client, resources, max_budget=current_budget)
|
||||
access = _read_team(client, team_id, admin_key).team_info.caller_edit_access
|
||||
assert access == CallerEditAccess(kind="team_admin", editable_fields=["max_budget", "rpm_limit"]), (
|
||||
f"/team/info should list max_budget and rpm_limit as the team admin's editable fields, got {access}"
|
||||
)
|
||||
before = _read_team(client, team_id).team_info
|
||||
|
||||
outcome = _update_team_as(
|
||||
client, admin_key, TeamSettingsUpdate(team_id=team_id, rpm_limit=50, max_budget=_TEAM_MAX_BUDGET / 2)
|
||||
)
|
||||
|
||||
assert outcome.status_code == 200, (
|
||||
f"a team admin setting an RPM limit and tightening the budget from {current_budget} must succeed, "
|
||||
f"got {outcome.status_code}: {outcome.body[:300]}"
|
||||
)
|
||||
after = _poll_team(
|
||||
client,
|
||||
team_id,
|
||||
lambda info: info.rpm_limit == 50 and info.max_budget == _TEAM_MAX_BUDGET / 2,
|
||||
f"/team/info never reflected rpm_limit=50 and max_budget={_TEAM_MAX_BUDGET / 2}",
|
||||
)
|
||||
assert after.model_copy(update={"rpm_limit": before.rpm_limit, "max_budget": before.max_budget}) == before, (
|
||||
f"the update changed more than rpm_limit and max_budget: before {before}, after {after}"
|
||||
)
|
||||
|
||||
@pytest.mark.covers("mgmt.team.update.team_admin_cannot_grow_budget")
|
||||
@pytest.mark.parametrize(
|
||||
("max_budget", "refusal"),
|
||||
[
|
||||
pytest.param(_TEAM_MAX_BUDGET * 2, "Only a proxy admin can raise", id="raise"),
|
||||
pytest.param(None, "Only a proxy admin can remove", id="remove"),
|
||||
],
|
||||
)
|
||||
def test_team_admin_cannot_raise_or_remove_the_budget(
|
||||
self, client: ManagementClient, resources: ResourceManager, max_budget: float | None, refusal: str
|
||||
) -> None:
|
||||
team_id, admin_key = _team_with_admin(client, resources, max_budget=_TEAM_MAX_BUDGET)
|
||||
before = _read_team(client, team_id).team_info
|
||||
|
||||
outcome = _update_team_as(
|
||||
client, admin_key, TeamSettingsUpdate(team_id=team_id, rpm_limit=50, max_budget=max_budget)
|
||||
)
|
||||
|
||||
assert outcome.status_code == 403, (
|
||||
f"a team admin changing max_budget from {_TEAM_MAX_BUDGET} to {max_budget} must be 403, "
|
||||
f"got {outcome.status_code}: {outcome.body[:300]}"
|
||||
)
|
||||
assert refusal in outcome.body, f"403 body should say {refusal!r}, got: {outcome.body[:300]}"
|
||||
after = _read_team(client, team_id).team_info
|
||||
assert after == before, (
|
||||
f"the refused update still wrote to the team, the rpm_limit included: before {before}, after {after}"
|
||||
)
|
||||
|
||||
@pytest.mark.covers("mgmt.team.update.team_admin_cannot_grow_budget")
|
||||
def test_team_admin_cannot_raise_an_org_team_budget_under_the_org_cap(
|
||||
self, client: ManagementClient, resources: ResourceManager
|
||||
) -> None:
|
||||
org_id = client.create_org(
|
||||
OrgWithBudgetNewBody(organization_alias=f"e2e-team-admin-org-{unique_marker()}", max_budget=_ORG_MAX_BUDGET)
|
||||
)
|
||||
resources.defer(lambda: client.delete_org(org_id))
|
||||
team_id, admin_key = _team_with_admin(client, resources, max_budget=_TEAM_MAX_BUDGET, organization_id=org_id)
|
||||
before = _read_team(client, team_id).team_info
|
||||
|
||||
outcome = _update_team_as(
|
||||
client, admin_key, TeamSettingsUpdate(team_id=team_id, max_budget=_ORG_MAX_BUDGET / 2)
|
||||
)
|
||||
|
||||
assert outcome.status_code == 403, (
|
||||
f"a team admin raising an org team's max_budget from {_TEAM_MAX_BUDGET} to {_ORG_MAX_BUDGET / 2}, "
|
||||
f"under the org's {_ORG_MAX_BUDGET}, must be 403, got {outcome.status_code}: {outcome.body[:300]}"
|
||||
)
|
||||
assert "Only a proxy admin can raise" in outcome.body, f"403 body should say why, got: {outcome.body[:300]}"
|
||||
after = _read_team(client, team_id).team_info
|
||||
assert after == before, f"the refused update still wrote to the team: before {before}, after {after}"
|
||||
|
|
|
|||
|
|
@ -6653,40 +6653,18 @@ async def test_update_team_standalone_uncapped_team_admin_sets_finite_allowed(
|
|||
"litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock()
|
||||
),
|
||||
):
|
||||
mock_existing_team = MagicMock()
|
||||
mock_existing_team.team_id = "standalone-uncapped-123"
|
||||
mock_existing_team.organization_id = None
|
||||
mock_existing_team.max_budget = None # team has no cap
|
||||
mock_existing_team.model_id = None
|
||||
mock_existing_team.model_dump.return_value = {
|
||||
"team_id": "standalone-uncapped-123",
|
||||
"organization_id": None,
|
||||
"max_budget": None,
|
||||
"members_with_roles": [
|
||||
{"user_id": "uncapped-team-admin", "role": "admin"}
|
||||
],
|
||||
}
|
||||
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(
|
||||
return_value=mock_existing_team
|
||||
_TeamRowStore(
|
||||
mock_prisma.db.litellm_teamtable,
|
||||
{
|
||||
"team_id": "standalone-uncapped-123",
|
||||
"max_budget": None,
|
||||
"members_with_roles": [{"user_id": "uncapped-team-admin", "role": "admin"}],
|
||||
},
|
||||
)
|
||||
mock_prisma.jsonify_team_object = lambda db_data: db_data
|
||||
mock_cache.async_get_cache = AsyncMock(return_value=None)
|
||||
mock_cache.async_set_cache = AsyncMock()
|
||||
|
||||
mock_updated_team = MagicMock()
|
||||
mock_updated_team.team_id = "standalone-uncapped-123"
|
||||
mock_updated_team.organization_id = None
|
||||
mock_updated_team.max_budget = 1000.0
|
||||
mock_updated_team.litellm_model_table = None
|
||||
mock_updated_team.model_dump.return_value = {
|
||||
"team_id": "standalone-uncapped-123",
|
||||
"organization_id": None,
|
||||
"max_budget": 1000.0,
|
||||
}
|
||||
mock_prisma.db.litellm_teamtable.update = AsyncMock(
|
||||
return_value=mock_updated_team
|
||||
)
|
||||
|
||||
result = await update_team(
|
||||
data=update_request,
|
||||
http_request=dummy_request,
|
||||
|
|
@ -6847,21 +6825,13 @@ async def test_update_team_standalone_lower_budget_allowed(
|
|||
"litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock()
|
||||
) as mock_audit,
|
||||
):
|
||||
mock_existing_team = MagicMock()
|
||||
mock_existing_team.team_id = "standalone-lower-budget-123"
|
||||
mock_existing_team.organization_id = None
|
||||
mock_existing_team.max_budget = 500.0
|
||||
mock_existing_team.model_id = None
|
||||
mock_existing_team.model_dump.return_value = {
|
||||
"team_id": "standalone-lower-budget-123",
|
||||
"organization_id": None,
|
||||
"max_budget": 500.0,
|
||||
"members_with_roles": [
|
||||
{"user_id": "standalone-lower-budget-admin", "role": "admin"}
|
||||
],
|
||||
}
|
||||
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(
|
||||
return_value=mock_existing_team
|
||||
_TeamRowStore(
|
||||
mock_prisma.db.litellm_teamtable,
|
||||
{
|
||||
"team_id": "standalone-lower-budget-123",
|
||||
"max_budget": 500.0,
|
||||
"members_with_roles": [{"user_id": "standalone-lower-budget-admin", "role": "admin"}],
|
||||
},
|
||||
)
|
||||
mock_prisma.jsonify_team_object = lambda db_data: db_data
|
||||
|
||||
|
|
@ -6872,20 +6842,6 @@ async def test_update_team_standalone_lower_budget_allowed(
|
|||
mock_cache.async_get_cache = AsyncMock(return_value=mock_user_obj)
|
||||
mock_cache.async_set_cache = AsyncMock()
|
||||
|
||||
mock_updated_team = MagicMock()
|
||||
mock_updated_team.team_id = "standalone-lower-budget-123"
|
||||
mock_updated_team.organization_id = None
|
||||
mock_updated_team.max_budget = 300.0
|
||||
mock_updated_team.litellm_model_table = None
|
||||
mock_updated_team.model_dump.return_value = {
|
||||
"team_id": "standalone-lower-budget-123",
|
||||
"organization_id": None,
|
||||
"max_budget": 300.0,
|
||||
}
|
||||
mock_prisma.db.litellm_teamtable.update = AsyncMock(
|
||||
return_value=mock_updated_team
|
||||
)
|
||||
|
||||
result = await update_team(
|
||||
data=update_request,
|
||||
http_request=dummy_request,
|
||||
|
|
@ -7124,8 +7080,10 @@ async def test_update_team_org_scoped_budget_bypasses_user_limit(
|
|||
mock_org.litellm_budget_table = mock_budget_table
|
||||
|
||||
with (
|
||||
_team_admin_may_edit("max_budget"),
|
||||
_not_org_admin(),
|
||||
patch( # test-quality-ok: the org-admin lookup needs a real prisma client this file's MagicMock cannot provide
|
||||
"litellm.proxy.management_endpoints.team_endpoints._is_user_org_admin_for_team",
|
||||
AsyncMock(return_value=True),
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
|
||||
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
|
||||
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
|
||||
|
|
@ -7147,9 +7105,7 @@ async def test_update_team_org_scoped_budget_bypasses_user_limit(
|
|||
"team_id": "org-team-update-budget-123",
|
||||
"organization_id": "test-org-update-budget",
|
||||
"max_budget": 30.0,
|
||||
"members_with_roles": [
|
||||
{"user_id": "org-admin-update-budget-test", "role": "admin"}
|
||||
],
|
||||
"members_with_roles": [],
|
||||
}
|
||||
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(
|
||||
return_value=mock_existing_team
|
||||
|
|
@ -14968,6 +14924,49 @@ def _update_request_stub():
|
|||
return Mock(spec=Request)
|
||||
|
||||
|
||||
class _TeamRowStore:
|
||||
"""One team row whose writes honor their where clause, as Postgres does.
|
||||
|
||||
`budget_set_after_read` is a proxy admin's budget change that commits after update_team read the row."""
|
||||
|
||||
def __init__(self, table: MagicMock, row: dict[str, object], budget_set_after_read: float | None = None) -> None:
|
||||
self.row: Final = {
|
||||
"organization_id": None,
|
||||
"soft_budget": None,
|
||||
"model_id": None,
|
||||
"model_max_budget": None,
|
||||
"litellm_model_table": None,
|
||||
"metadata": {},
|
||||
**row,
|
||||
}
|
||||
self._budget_set_after_read = budget_set_after_read
|
||||
table.find_unique = self.find_unique
|
||||
table.update = self.update
|
||||
table.update_many = self.update_many
|
||||
|
||||
def _snapshot(self) -> MagicMock:
|
||||
snapshot: Final = MagicMock(**self.row)
|
||||
snapshot.model_dump.return_value = dict(self.row)
|
||||
return snapshot
|
||||
|
||||
async def find_unique(self, where, include=None):
|
||||
snapshot: Final = self._snapshot()
|
||||
if self._budget_set_after_read is not None:
|
||||
self.row["max_budget"] = self._budget_set_after_read
|
||||
self._budget_set_after_read = None
|
||||
return snapshot
|
||||
|
||||
async def update(self, where, data, include=None):
|
||||
self.row.update(data)
|
||||
return self._snapshot()
|
||||
|
||||
async def update_many(self, where, data):
|
||||
if any(self.row.get(column) != value for column, value in where.items()):
|
||||
return 0
|
||||
self.row.update(data)
|
||||
return 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_team_team_admin_is_refused_before_any_write_when_no_fields_are_enabled():
|
||||
import contextlib
|
||||
|
|
@ -15177,6 +15176,116 @@ async def test_update_team_holds_a_team_admin_to_the_org_tpm_limit(disable_audit
|
|||
assert prisma.db.litellm_teamtable.update.call_args.kwargs["data"]["tpm_limit"] == 8000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_team_stops_a_team_admin_raising_an_org_team_budget_under_the_org_cap(
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
"""The org cap alone would let a team admin with max_budget enabled grow its own team's budget up to the org's."""
|
||||
import contextlib
|
||||
|
||||
budgeted_org = LiteLLM_OrganizationTable(
|
||||
organization_id="budgeted-org",
|
||||
budget_id="budgeted-org-budget",
|
||||
created_by="admin",
|
||||
updated_by="admin",
|
||||
litellm_budget_table=LiteLLM_BudgetTable(max_budget=100.0),
|
||||
)
|
||||
with contextlib.ExitStack() as stack:
|
||||
prisma = _wire_update_team(stack, {})
|
||||
store = _TeamRowStore(
|
||||
prisma.db.litellm_teamtable,
|
||||
{
|
||||
"team_id": "test_team_id",
|
||||
"team_alias": "test_team",
|
||||
"organization_id": "budgeted-org",
|
||||
"max_budget": 10.0,
|
||||
"members_with_roles": [{"user_id": "team-admin", "role": "admin"}],
|
||||
},
|
||||
)
|
||||
stack.enter_context(_team_admin_may_edit("max_budget"))
|
||||
stack.enter_context(_not_org_admin())
|
||||
stack.enter_context(
|
||||
patch( # test-quality-ok: update_team reads orgs through this module-level import; no seam to inject
|
||||
"litellm.proxy.management_endpoints.team_endpoints.get_org_object",
|
||||
AsyncMock(return_value=budgeted_org),
|
||||
)
|
||||
)
|
||||
with pytest.raises(ProxyException) as raised:
|
||||
await update_team(
|
||||
data=UpdateTeamRequest(team_id="test_team_id", max_budget=50.0),
|
||||
http_request=_update_request_stub(),
|
||||
user_api_key_dict=_TEAM_ADMIN_CALLER,
|
||||
)
|
||||
budget_after_raise = store.row["max_budget"]
|
||||
await update_team(
|
||||
data=UpdateTeamRequest(team_id="test_team_id", max_budget=5.0),
|
||||
http_request=_update_request_stub(),
|
||||
user_api_key_dict=_TEAM_ADMIN_CALLER,
|
||||
)
|
||||
|
||||
assert str(raised.value.code) == "403"
|
||||
assert "Only a proxy admin can raise a team's max_budget" in str(raised.value.message)
|
||||
assert budget_after_raise == 10.0
|
||||
assert store.row["max_budget"] == 5.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("organization_id", "budget_read", "requested"),
|
||||
[
|
||||
pytest.param(None, 100.0, 90.0, id="lowering"),
|
||||
pytest.param(None, None, 90.0, id="first-budget"),
|
||||
pytest.param("budgeted-org", 100.0, 90.0, id="org-team"),
|
||||
],
|
||||
)
|
||||
async def test_update_team_keeps_a_budget_cut_that_lands_while_a_team_admin_update_runs(
|
||||
disable_audit_logging_for_mocked_team, organization_id, budget_read, requested
|
||||
):
|
||||
"""The team admin's check passed against the budget it read, which no longer holds once a proxy admin
|
||||
cut it to 20, so writing 90 would grow the team's live ceiling."""
|
||||
import contextlib
|
||||
|
||||
with contextlib.ExitStack() as stack:
|
||||
prisma = _wire_update_team(stack, {})
|
||||
store = _TeamRowStore(
|
||||
prisma.db.litellm_teamtable,
|
||||
{
|
||||
"team_id": "test_team_id",
|
||||
"team_alias": "test_team",
|
||||
"organization_id": organization_id,
|
||||
"max_budget": budget_read,
|
||||
"members_with_roles": [{"user_id": "team-admin", "role": "admin"}],
|
||||
},
|
||||
budget_set_after_read=20.0,
|
||||
)
|
||||
stack.enter_context(_team_admin_may_edit("max_budget"))
|
||||
stack.enter_context(_not_org_admin())
|
||||
stack.enter_context(
|
||||
patch( # test-quality-ok: update_team reads orgs through this module-level import; no seam to inject
|
||||
"litellm.proxy.management_endpoints.team_endpoints.get_org_object",
|
||||
AsyncMock(
|
||||
return_value=LiteLLM_OrganizationTable(
|
||||
organization_id="budgeted-org",
|
||||
budget_id="budgeted-org-budget",
|
||||
created_by="admin",
|
||||
updated_by="admin",
|
||||
litellm_budget_table=LiteLLM_BudgetTable(max_budget=1000.0),
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
with pytest.raises(ProxyException) as raised:
|
||||
await update_team(
|
||||
data=UpdateTeamRequest(team_id="test_team_id", max_budget=requested),
|
||||
http_request=_update_request_stub(),
|
||||
user_api_key_dict=_TEAM_ADMIN_CALLER,
|
||||
)
|
||||
|
||||
assert str(raised.value.code) == "409"
|
||||
assert "max_budget changed" in str(raised.value.message)
|
||||
assert store.row["max_budget"] == 20.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_team_org_admin_is_not_filtered_by_the_team_admin_field_list(
|
||||
disable_audit_logging_for_mocked_team,
|
||||
|
|
|
|||
|
|
@ -3324,15 +3324,17 @@ class TestTeamAdminEditableTeamFieldsSetting:
|
|||
general_settings: dict = {"team_admin_editable_team_fields": []}
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings)
|
||||
|
||||
enabled = ["tpm_limit", "rpm_limit", "max_budget"]
|
||||
|
||||
try:
|
||||
response = client.patch("/update/ui_settings", json={"team_admin_editable_team_fields": ["tpm_limit"]})
|
||||
response = client.patch("/update/ui_settings", json={"team_admin_editable_team_fields": enabled})
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
stored = json.loads(mock_prisma.db.litellm_uisettings.upsert.call_args.kwargs["data"]["create"]["ui_settings"])
|
||||
assert stored["team_admin_editable_team_fields"] == ["tpm_limit"]
|
||||
assert general_settings["team_admin_editable_team_fields"] == ["tpm_limit"]
|
||||
assert stored["team_admin_editable_team_fields"] == enabled
|
||||
assert general_settings["team_admin_editable_team_fields"] == enabled
|
||||
|
||||
def test_patch_with_an_empty_list_turns_team_admin_editing_off_again(self, monkeypatch):
|
||||
mock_prisma = self._as_proxy_admin(monkeypatch)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ vi.mock("@/app/(dashboard)/hooks/uiSettings/useUpdateUISettings", () => ({
|
|||
}));
|
||||
|
||||
const TPM_LABEL = "Tokens per minute Limit (TPM)";
|
||||
const MAX_BUDGET_LABEL = "Max Budget (USD)";
|
||||
|
||||
const mockSettings = (supported: readonly string[], enabled: readonly string[]) =>
|
||||
mockUseUISettings.mockReturnValue({
|
||||
|
|
@ -80,7 +81,7 @@ describe("TeamAdminEditableFieldsSettings", () => {
|
|||
expect(screen.getByText("Team admin editable fields")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 field enabled")).toBeInTheDocument();
|
||||
expect(screen.getByText("Fields a team admin may change")).toBeInTheDocument();
|
||||
expect(screen.getByRole("checkbox", { name: "max_budget" })).not.toBeChecked();
|
||||
expect(screen.getByRole("checkbox", { name: MAX_BUDGET_LABEL })).not.toBeChecked();
|
||||
expect(screen.getByRole("checkbox", { name: TPM_LABEL })).toBeChecked();
|
||||
expect(saveButton()).toBeDisabled();
|
||||
});
|
||||
|
|
@ -90,9 +91,9 @@ describe("TeamAdminEditableFieldsSettings", () => {
|
|||
const mutate = mockSave({});
|
||||
|
||||
renderWithProviders(<TeamAdminEditableFieldsSettings />);
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "max_budget" }));
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: MAX_BUDGET_LABEL }));
|
||||
|
||||
expect(screen.getByRole("checkbox", { name: "max_budget" })).toBeChecked();
|
||||
expect(screen.getByRole("checkbox", { name: MAX_BUDGET_LABEL })).toBeChecked();
|
||||
expect(mutate).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(saveButton());
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const renderForm = (editableFields: ReadonlySet<string>, overrides: { isSaving?:
|
|||
const onCancel = vi.fn();
|
||||
renderWithProviders(
|
||||
<TeamAdminSettingsForm
|
||||
initialValues={{ tpm_limit: 1000 }}
|
||||
initialValues={{ tpm_limit: 1000, rpm_limit: 50, max_budget: 20 }}
|
||||
editableFields={editableFields}
|
||||
isSaving={overrides.isSaving ?? false}
|
||||
onCancel={onCancel}
|
||||
|
|
@ -21,16 +21,20 @@ const renderForm = (editableFields: ReadonlySet<string>, overrides: { isSaving?:
|
|||
};
|
||||
|
||||
describe("TeamAdminSettingsForm", () => {
|
||||
it("shows the team's current TPM limit when the proxy lets team admins edit it", () => {
|
||||
renderForm(new Set(["tpm_limit"]));
|
||||
it("shows the team's current values for every field the proxy lets team admins edit", () => {
|
||||
renderForm(new Set(["tpm_limit", "rpm_limit", "max_budget"]));
|
||||
|
||||
expect(screen.getByLabelText("Tokens per minute Limit (TPM)")).toHaveValue(1000);
|
||||
expect(screen.getByLabelText("Requests per minute Limit (RPM)")).toHaveValue(50);
|
||||
expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue(20);
|
||||
});
|
||||
|
||||
it("hides the TPM limit when the proxy has not enabled it for team admins", () => {
|
||||
renderForm(new Set(["max_budget"]));
|
||||
it("hides the fields the proxy has not enabled for team admins", () => {
|
||||
renderForm(new Set(["rpm_limit"]));
|
||||
|
||||
expect(screen.getByLabelText("Requests per minute Limit (RPM)")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Tokens per minute Limit (TPM)")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Max Budget (USD)")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves the new TPM limit and nothing else", async () => {
|
||||
|
|
@ -43,6 +47,17 @@ describe("TeamAdminSettingsForm", () => {
|
|||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ tpm_limit: 5000 }));
|
||||
});
|
||||
|
||||
it("saves a lowered budget and a new RPM limit without resending the unchanged TPM limit", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSave } = renderForm(new Set(["tpm_limit", "rpm_limit", "max_budget"]));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Requests per minute Limit (RPM)"), { target: { value: "80" } });
|
||||
fireEvent.change(screen.getByLabelText("Max Budget (USD)"), { target: { value: "12.5" } });
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ rpm_limit: 80, max_budget: 12.5 }));
|
||||
});
|
||||
|
||||
it("saves a cleared TPM limit as no limit", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSave } = renderForm(new Set(["tpm_limit"]));
|
||||
|
|
|
|||
|
|
@ -12,16 +12,24 @@ import { useZodForm } from "@/lib/forms/useZodForm";
|
|||
|
||||
import NumericalInput from "../shared/numerical_input";
|
||||
import {
|
||||
TEAM_ADMIN_SETTINGS_FIELDS,
|
||||
teamAdminFieldLabel,
|
||||
teamAdminSettingsChanges,
|
||||
type TeamAdminSettingsChanges,
|
||||
type TeamAdminSettingsField,
|
||||
type TeamAdminSettingsValues,
|
||||
} from "./teamAdminEditAccess";
|
||||
|
||||
const numericInputSchema = z.union([z.string(), z.number()]).nullish();
|
||||
|
||||
const teamAdminSettingsSchema = z.object({
|
||||
tpm_limit: z.union([z.string(), z.number()]).nullish(),
|
||||
tpm_limit: numericInputSchema,
|
||||
rpm_limit: numericInputSchema,
|
||||
max_budget: numericInputSchema,
|
||||
});
|
||||
|
||||
const INPUT_STEP: Readonly<Record<TeamAdminSettingsField, number>> = { tpm_limit: 1, rpm_limit: 1, max_budget: 0.01 };
|
||||
|
||||
interface TeamAdminSettingsFormProps {
|
||||
initialValues: TeamAdminSettingsValues;
|
||||
editableFields: ReadonlySet<string>;
|
||||
|
|
@ -48,11 +56,13 @@ export default function TeamAdminSettingsForm({
|
|||
<p className="text-sm text-muted-foreground">
|
||||
A proxy admin chose which settings team admins can change. Ask a proxy admin to change anything else.
|
||||
</p>
|
||||
{editableFields.has("tpm_limit") && (
|
||||
<FormField control={form.control} name="tpm_limit" label={teamAdminFieldLabel("tpm_limit")}>
|
||||
{({ ref, value, ...field }) => <NumericalInput {...field} ref={ref} value={value ?? ""} step={1} />}
|
||||
{TEAM_ADMIN_SETTINGS_FIELDS.filter((name) => editableFields.has(name)).map((name) => (
|
||||
<FormField key={name} control={form.control} name={name} label={teamAdminFieldLabel(name)}>
|
||||
{({ ref, value, ...field }) => (
|
||||
<NumericalInput {...field} ref={ref} value={value ?? ""} step={INPUT_STEP[name]} />
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
))}
|
||||
</FieldGroup>
|
||||
|
||||
<div className="mt-6 flex items-center justify-end gap-2">
|
||||
|
|
|
|||
|
|
@ -1918,6 +1918,26 @@ describe("TeamInfoView", () => {
|
|||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefills the RPM limit and budget a team admin may edit with the team's stored values", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||
createMockTeamData({
|
||||
rpm_limit: 50,
|
||||
max_budget: 20,
|
||||
caller_edit_access: { kind: "team_admin", editable_fields: ["rpm_limit", "max_budget"] },
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithProviders(<TeamInfoView {...teamAdminProps} />);
|
||||
|
||||
await user.click(await screen.findByRole("tab", { name: "Settings" }));
|
||||
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
|
||||
|
||||
expect(await screen.findByLabelText("Requests per minute Limit (RPM)")).toHaveValue(50);
|
||||
expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue(20);
|
||||
expect(screen.getByRole("button", { name: /save changes/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("opens the form when the proxy reports unrestricted access although the props only mark a team admin", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||
|
|
|
|||
|
|
@ -1156,7 +1156,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
const teamAdminSettingsEditor =
|
||||
teamEditAccess.kind === "team_admin" ? (
|
||||
<TeamAdminSettingsForm
|
||||
initialValues={{ tpm_limit: info.tpm_limit }}
|
||||
initialValues={{ tpm_limit: info.tpm_limit, rpm_limit: info.rpm_limit, max_budget: info.max_budget }}
|
||||
editableFields={teamEditAccess.editableFields}
|
||||
isSaving={isTeamSaving}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,16 @@ import {
|
|||
} from "./teamAdminEditAccess";
|
||||
|
||||
describe("teamAdminFieldLabel", () => {
|
||||
it("names tpm_limit the way the team settings form does", () => {
|
||||
expect(teamAdminFieldLabel("tpm_limit")).toBe("Tokens per minute Limit (TPM)");
|
||||
it.each([
|
||||
["tpm_limit", "Tokens per minute Limit (TPM)"],
|
||||
["rpm_limit", "Requests per minute Limit (RPM)"],
|
||||
["max_budget", "Max Budget (USD)"],
|
||||
])("names %s the way the team settings form does", (field, label) => {
|
||||
expect(teamAdminFieldLabel(field)).toBe(label);
|
||||
});
|
||||
|
||||
it("falls back to the raw field name for a field the dashboard has no label for", () => {
|
||||
expect(teamAdminFieldLabel("max_budget")).toBe("max_budget");
|
||||
expect(teamAdminFieldLabel("team_alias")).toBe("team_alias");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -46,6 +50,27 @@ describe("teamAdminSettingsChanges", () => {
|
|||
it("leaves tpm_limit out when the proxy did not enable it for team admins", () => {
|
||||
expect(teamAdminSettingsChanges({ tpm_limit: "5000" }, stored, new Set(["max_budget"]))).toStrictEqual({});
|
||||
});
|
||||
|
||||
const allStored = { tpm_limit: 1000, rpm_limit: 10, max_budget: 20 };
|
||||
|
||||
it("sends every enabled field that changed and skips the ones that did not", () => {
|
||||
const values = { tpm_limit: "1000", rpm_limit: "50", max_budget: "12.5" };
|
||||
const enabled = new Set(["tpm_limit", "rpm_limit", "max_budget"]);
|
||||
|
||||
expect(teamAdminSettingsChanges(values, allStored, enabled)).toStrictEqual({ rpm_limit: 50, max_budget: 12.5 });
|
||||
});
|
||||
|
||||
it("sends a cleared max budget as no budget", () => {
|
||||
expect(teamAdminSettingsChanges({ max_budget: "" }, allStored, new Set(["max_budget"]))).toStrictEqual({
|
||||
max_budget: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves out changed fields the proxy did not enable", () => {
|
||||
const values = { tpm_limit: "5000", rpm_limit: "50", max_budget: "5" };
|
||||
|
||||
expect(teamAdminSettingsChanges(values, allStored, new Set(["rpm_limit"]))).toStrictEqual({ rpm_limit: 50 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTeamAdminEditableFields", () => {
|
||||
|
|
|
|||
|
|
@ -39,17 +39,21 @@ export const parseSupportedTeamAdminEditableFields = (uiSettingsFieldSchema: unk
|
|||
return items.success ? fieldListSchema.parse(items.data.enum) : [];
|
||||
};
|
||||
|
||||
const TEAM_ADMIN_FIELD_LABELS: ReadonlyMap<string, string> = new Map([["tpm_limit", "Tokens per minute Limit (TPM)"]]);
|
||||
export const TEAM_ADMIN_SETTINGS_FIELDS = ["tpm_limit", "rpm_limit", "max_budget"] as const;
|
||||
|
||||
export type TeamAdminSettingsField = (typeof TEAM_ADMIN_SETTINGS_FIELDS)[number];
|
||||
|
||||
const TEAM_ADMIN_FIELD_LABELS: ReadonlyMap<string, string> = new Map([
|
||||
["tpm_limit", "Tokens per minute Limit (TPM)"],
|
||||
["rpm_limit", "Requests per minute Limit (RPM)"],
|
||||
["max_budget", "Max Budget (USD)"],
|
||||
]);
|
||||
|
||||
export const teamAdminFieldLabel = (field: string): string => TEAM_ADMIN_FIELD_LABELS.get(field) ?? field;
|
||||
|
||||
export interface TeamAdminSettingsValues {
|
||||
readonly tpm_limit?: string | number | null;
|
||||
}
|
||||
export type TeamAdminSettingsValues = { readonly [F in TeamAdminSettingsField]?: string | number | null };
|
||||
|
||||
export interface TeamAdminSettingsChanges {
|
||||
readonly tpm_limit?: number | null;
|
||||
}
|
||||
export type TeamAdminSettingsChanges = { readonly [F in TeamAdminSettingsField]?: number | null };
|
||||
|
||||
const numberOrNull = (value: string | number | null | undefined): number | null => {
|
||||
if (value === null || value === undefined || String(value).trim() === "") return null;
|
||||
|
|
@ -61,12 +65,13 @@ export const teamAdminSettingsChanges = (
|
|||
values: TeamAdminSettingsValues,
|
||||
initialValues: TeamAdminSettingsValues,
|
||||
editableFields: ReadonlySet<string>,
|
||||
): TeamAdminSettingsChanges => {
|
||||
const tpmLimit = numberOrNull(values.tpm_limit);
|
||||
return editableFields.has("tpm_limit") && tpmLimit !== numberOrNull(initialValues.tpm_limit)
|
||||
? { tpm_limit: tpmLimit }
|
||||
: {};
|
||||
};
|
||||
): TeamAdminSettingsChanges =>
|
||||
Object.fromEntries(
|
||||
TEAM_ADMIN_SETTINGS_FIELDS.flatMap((field) => {
|
||||
const value = numberOrNull(values[field]);
|
||||
return editableFields.has(field) && value !== numberOrNull(initialValues[field]) ? [[field, value]] : [];
|
||||
}),
|
||||
);
|
||||
|
||||
export const parseTeamEditAccess = (callerEditAccess: unknown): TeamEditAccess => {
|
||||
const parsed = callerEditAccessSchema.safeParse(callerEditAccess);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue