mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-15 04:19:08 +00:00
feat(proxy): let proxy admins open team member defaults to team admins (#46353)
* feat(proxy): let proxy admins open team member defaults to team admins * fix(proxy): keep a team admin's never-resets duration on a team's first member budget * test(proxy): pin the existing-row and stored never-resets cases for member defaults
This commit is contained in:
parent
47c40586e1
commit
df3b44dbee
12 changed files with 557 additions and 27 deletions
|
|
@ -17,6 +17,7 @@ from pydantic import TypeAdapter, ValidationError
|
|||
from typing_extensions import assert_never
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.models.budget import LiteLLM_BudgetTable
|
||||
from litellm.models.team import LiteLLM_TeamTable
|
||||
from litellm.models.verification_token import LiteLLM_VerificationToken
|
||||
from litellm.proxy._types import (
|
||||
|
|
@ -32,7 +33,16 @@ 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", "soft_budget"}
|
||||
{
|
||||
"tpm_limit",
|
||||
"rpm_limit",
|
||||
"max_budget",
|
||||
"soft_budget",
|
||||
"team_member_budget",
|
||||
"team_member_budget_duration",
|
||||
"team_member_tpm_limit",
|
||||
"team_member_rpm_limit",
|
||||
}
|
||||
)
|
||||
TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION: Final = "raise_max_budget"
|
||||
TEAM_ADMIN_PROJECTS_PERMISSION: Final = "projects"
|
||||
|
|
@ -66,6 +76,19 @@ _NOT_COLUMNS: Final[frozenset[str]] = frozenset({"team_id", "metadata"})
|
|||
_SETTINGS_LOCATION: Final = "Settings > UI > Team admin editable fields"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TeamMemberDefaults:
|
||||
team_member_budget: float | None = None
|
||||
team_member_budget_duration: str | None = None
|
||||
team_member_tpm_limit: int | None = None
|
||||
team_member_rpm_limit: int | None = None
|
||||
|
||||
|
||||
TEAM_MEMBER_DEFAULT_FIELDS: Final[frozenset[str]] = frozenset(
|
||||
{"team_member_budget", "team_member_budget_duration", "team_member_tpm_limit", "team_member_rpm_limit"}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TeamAdminEditAllowed:
|
||||
request: UpdateTeamRequest
|
||||
|
|
@ -250,34 +273,76 @@ def _metadata_changes(
|
|||
)
|
||||
|
||||
|
||||
def stored_team_member_budget_id(team: LiteLLM_TeamTable) -> str | None:
|
||||
budget_id: Final = _stored_metadata(_JSON_OBJECT.validate_json(team.model_dump_json())).get("team_member_budget_id")
|
||||
return budget_id if isinstance(budget_id, str) else None
|
||||
|
||||
|
||||
def team_member_defaults(budget: LiteLLM_BudgetTable | None) -> TeamMemberDefaults | None:
|
||||
if budget is None:
|
||||
return None
|
||||
return TeamMemberDefaults(
|
||||
team_member_budget=budget.max_budget,
|
||||
team_member_budget_duration=budget.budget_duration,
|
||||
team_member_tpm_limit=budget.tpm_limit,
|
||||
team_member_rpm_limit=budget.rpm_limit,
|
||||
)
|
||||
|
||||
|
||||
def _stored_model_aliases(existing_row: LiteLLM_TeamTable) -> Mapping[str, object]:
|
||||
table: Final = existing_row.litellm_model_table
|
||||
return _as_object(_JSON_OBJECT.validate_json(table.model_dump_json()).get("model_aliases")) if table else _EMPTY
|
||||
|
||||
|
||||
def _column_changed(
|
||||
field: str, submitted: Mapping[str, object], existing: Mapping[str, object], existing_row: LiteLLM_TeamTable
|
||||
field: str,
|
||||
submitted: Mapping[str, object],
|
||||
existing: Mapping[str, object],
|
||||
existing_row: LiteLLM_TeamTable,
|
||||
member_defaults: Mapping[str, object],
|
||||
) -> bool:
|
||||
if field == "model_aliases":
|
||||
return _as_object(submitted.get(field)) != _stored_model_aliases(existing_row)
|
||||
if field in member_defaults:
|
||||
return submitted.get(field) != member_defaults[field]
|
||||
if field in LiteLLM_TeamTable.model_fields:
|
||||
return submitted.get(field) != existing.get(field)
|
||||
return True
|
||||
|
||||
|
||||
def changed_team_fields(data: UpdateTeamRequest, existing_row: LiteLLM_TeamTable) -> frozenset[str]:
|
||||
def _member_defaults_by_field(member_defaults: TeamMemberDefaults | None) -> Mapping[str, object]:
|
||||
stored: Final = member_defaults or TeamMemberDefaults()
|
||||
return MappingProxyType(
|
||||
{
|
||||
"team_member_budget": stored.team_member_budget,
|
||||
"team_member_budget_duration": stored.team_member_budget_duration,
|
||||
"team_member_tpm_limit": stored.team_member_tpm_limit,
|
||||
"team_member_rpm_limit": stored.team_member_rpm_limit,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def changed_team_fields(
|
||||
data: UpdateTeamRequest,
|
||||
existing_row: LiteLLM_TeamTable,
|
||||
member_defaults: TeamMemberDefaults | None = None,
|
||||
) -> frozenset[str]:
|
||||
"""Logical field names whose stored value the request would change.
|
||||
|
||||
Request and stored row are compared as JSON values so both sides share one representation. Fields the
|
||||
server folds into metadata are attributed to their own name whether they arrive top-level or inside
|
||||
``metadata``; anything else in ``metadata`` is attributed to ``metadata``. Fields with no stored
|
||||
counterpart on the team row count as changed whenever they are sent.
|
||||
``metadata``; anything else in ``metadata`` is attributed to ``metadata``. The ``team_member_*`` defaults
|
||||
compare against the team's member budget row (``None`` when it has none, read as all null). Fields with no
|
||||
stored counterpart count as changed whenever they are sent.
|
||||
"""
|
||||
submitted: Final = _JSON_OBJECT.validate_json(data.model_dump_json(exclude_unset=True))
|
||||
existing: Final = _JSON_OBJECT.validate_json(existing_row.model_dump_json())
|
||||
column_fields: Final = frozenset(data.model_fields_set) - _NOT_COLUMNS - _METADATA_FOLDED_FIELDS
|
||||
stored_member_defaults: Final = _member_defaults_by_field(member_defaults)
|
||||
column_changes: Final = frozenset(
|
||||
field for field in column_fields if _column_changed(field, submitted, existing, existing_row)
|
||||
field
|
||||
for field in column_fields
|
||||
if _column_changed(field, submitted, existing, existing_row, stored_member_defaults)
|
||||
)
|
||||
return column_changes | _metadata_changes(data, submitted, existing)
|
||||
|
||||
|
|
@ -295,14 +360,21 @@ def team_admin_edit_verdict(
|
|||
data: UpdateTeamRequest,
|
||||
existing: LiteLLM_TeamTable,
|
||||
permitted: frozenset[str],
|
||||
member_defaults: TeamMemberDefaults | None = None,
|
||||
) -> TeamAdminEditVerdict:
|
||||
if not permitted:
|
||||
return TeamAdminEditingDisabled()
|
||||
changed: Final = changed_team_fields(data, existing)
|
||||
changed: Final = changed_team_fields(data, existing, member_defaults)
|
||||
blocked: Final = sorted(changed - permitted)
|
||||
if blocked:
|
||||
return TeamAdminFieldNotPermitted(field=blocked[0])
|
||||
return TeamAdminEditAllowed(request=_only_changes(data, changed))
|
||||
creates_member_budget: Final = member_defaults is None and bool(changed & TEAM_MEMBER_DEFAULT_FIELDS)
|
||||
sent_for_new_member_budget: Final = (
|
||||
frozenset(data.model_fields_set) & TEAM_MEMBER_DEFAULT_FIELDS & permitted
|
||||
if creates_member_budget
|
||||
else frozenset[str]()
|
||||
)
|
||||
return TeamAdminEditAllowed(request=_only_changes(data, changed | sent_for_new_member_budget))
|
||||
|
||||
|
||||
def team_admin_request_or_raise(verdict: TeamAdminEditVerdict) -> UpdateTeamRequest:
|
||||
|
|
|
|||
|
|
@ -164,10 +164,14 @@ from litellm.proxy.management_endpoints.tag_management_endpoints import (
|
|||
)
|
||||
from litellm.proxy.management_endpoints.team_admin_field_permissions import (
|
||||
SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS,
|
||||
TEAM_MEMBER_DEFAULT_FIELDS,
|
||||
TeamMemberDefaults,
|
||||
resolve_team_admin_editable_fields,
|
||||
stored_team_member_budget_id,
|
||||
team_admin_edit_verdict,
|
||||
team_admin_may_raise_max_budget,
|
||||
team_admin_request_or_raise,
|
||||
team_member_defaults,
|
||||
)
|
||||
from litellm.proxy.management_helpers.access_group_team_sync import (
|
||||
TEAM_ADVISORY_LOCK_SQL,
|
||||
|
|
@ -2365,12 +2369,19 @@ async def update_team(
|
|||
if access_role is None:
|
||||
team_access_denied()
|
||||
if access_role == "team_admin":
|
||||
permitted: Final = resolve_team_admin_editable_fields(
|
||||
_general_settings(), SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS
|
||||
)
|
||||
reads_member_defaults: Final = bool(permitted) and bool(data.model_fields_set & TEAM_MEMBER_DEFAULT_FIELDS)
|
||||
data = team_admin_request_or_raise( # rebind-ok: resent values must not reach the derived writes below
|
||||
team_admin_edit_verdict(
|
||||
data=data,
|
||||
existing=existing_team,
|
||||
permitted=resolve_team_admin_editable_fields(
|
||||
_general_settings(), SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS
|
||||
permitted=permitted,
|
||||
member_defaults=(
|
||||
await _stored_team_member_defaults(prisma_client, existing_team)
|
||||
if reads_member_defaults
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
|
@ -4899,6 +4910,15 @@ async def validate_membership(user_api_key_dict: UserAPIKeyAuth, team_table: Lit
|
|||
)
|
||||
|
||||
|
||||
async def _stored_team_member_defaults(
|
||||
prisma_client: PrismaClient, team: LiteLLM_TeamTable
|
||||
) -> TeamMemberDefaults | None:
|
||||
budget_id: Final = stored_team_member_budget_id(team)
|
||||
if budget_id is None:
|
||||
return None
|
||||
return team_member_defaults(await BudgetRepository(prisma_client).find_by_id(budget_id))
|
||||
|
||||
|
||||
async def _add_team_member_budget_table(
|
||||
team_member_budget_id: str,
|
||||
prisma_client: PrismaClient,
|
||||
|
|
|
|||
|
|
@ -162,6 +162,87 @@ async def test_team_update_org_admin_resolved_from_team_without_org_context(
|
|||
assert "Team admin editable fields" in denied_team_admin.text, denied_team_admin.text
|
||||
|
||||
|
||||
_MEMBER_DEFAULTS = [
|
||||
pytest.param("team_member_budget", 25.0, "max_budget", id="team_member_budget"),
|
||||
pytest.param("team_member_budget_duration", "7d", "budget_duration", id="team_member_budget_duration"),
|
||||
pytest.param("team_member_tpm_limit", 200, "tpm_limit", id="team_member_tpm_limit"),
|
||||
pytest.param("team_member_rpm_limit", 9, "rpm_limit", id="team_member_rpm_limit"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field,value,budget_column", _MEMBER_DEFAULTS)
|
||||
async def test_team_update_team_admin_member_default_follows_the_proxy_admin_grant(
|
||||
field: str, value: str | float, budget_column: str, gateway_client, db, resources, actors
|
||||
):
|
||||
callers = await _callers(db, actors, resources.prefix)
|
||||
await _seed_target(db, actors, callers, "alpha", resources.prefix)
|
||||
headers = {"Authorization": f"Bearer {callers[Actor.TEAM_ADMIN].cleartext}"}
|
||||
body = {"team_id": resources.prefix, field: value}
|
||||
|
||||
with team_admin_permissions(gateway_client.gateway, ["tpm_limit"]):
|
||||
denied = await gateway_client.post("/team/update", headers=headers, json=body)
|
||||
assert denied.status_code == 403, denied.text
|
||||
assert f"'{field}'" in denied.text and "Team admin editable fields" in denied.text, denied.text
|
||||
|
||||
with team_admin_permissions(gateway_client.gateway, [field]):
|
||||
allowed = await gateway_client.post("/team/update", headers=headers, json=body)
|
||||
assert allowed.status_code == 200, allowed.text
|
||||
|
||||
info = await gateway_client.get("/team/info", params={"team_id": resources.prefix})
|
||||
assert info.status_code == 200, info.text
|
||||
assert info.json()["team_info"]["team_member_budget_table"][budget_column] == value
|
||||
|
||||
|
||||
async def test_team_update_team_admin_resending_member_defaults_leaves_the_budget_row_alone(
|
||||
gateway_client, db, resources, actors
|
||||
):
|
||||
callers = await _callers(db, actors, resources.prefix)
|
||||
await _seed_target(db, actors, callers, "alpha", resources.prefix)
|
||||
stored = {
|
||||
"team_member_budget": 10.0,
|
||||
"team_member_budget_duration": "30d",
|
||||
"team_member_tpm_limit": 100,
|
||||
"team_member_rpm_limit": 5,
|
||||
}
|
||||
seeded = await gateway_client.post("/team/update", json={"team_id": resources.prefix, **stored})
|
||||
assert seeded.status_code == 200, seeded.text
|
||||
team = await db.db.litellm_teamtable.find_unique(where={"team_id": resources.prefix})
|
||||
assert team is not None
|
||||
budget_id = team.metadata["team_member_budget_id"]
|
||||
before = await db.db.litellm_budgettable.find_unique(where={"budget_id": budget_id})
|
||||
assert before is not None
|
||||
|
||||
with team_admin_permissions(gateway_client.gateway, ["tpm_limit"]):
|
||||
resp = await gateway_client.post(
|
||||
"/team/update",
|
||||
headers={"Authorization": f"Bearer {callers[Actor.TEAM_ADMIN].cleartext}"},
|
||||
json={"team_id": resources.prefix, "tpm_limit": 1000, **stored},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
after = await db.db.litellm_budgettable.find_unique(where={"budget_id": budget_id})
|
||||
assert after is not None and after.updated_at == before.updated_at
|
||||
team = await db.db.litellm_teamtable.find_unique(where={"team_id": resources.prefix})
|
||||
assert team is not None and team.tpm_limit == 1000
|
||||
|
||||
|
||||
async def test_team_update_team_admin_cannot_change_member_defaults_of_another_team(
|
||||
gateway_client, db, resources, actors
|
||||
):
|
||||
callers = await _callers(db, actors, resources.prefix)
|
||||
await _seed_target(db, actors, callers, "beta", resources.prefix)
|
||||
|
||||
with team_admin_permissions(gateway_client.gateway, ["team_member_budget"]):
|
||||
resp = await gateway_client.post(
|
||||
"/team/update",
|
||||
headers={"Authorization": f"Bearer {callers[Actor.TEAM_ADMIN].cleartext}"},
|
||||
json={"team_id": resources.prefix, "team_member_budget": 25.0},
|
||||
)
|
||||
assert resp.status_code == 403, resp.text
|
||||
team = await db.db.litellm_teamtable.find_unique(where={"team_id": resources.prefix})
|
||||
assert team is not None and "team_member_budget_id" not in (team.metadata or {}), "denied but team mutated"
|
||||
|
||||
|
||||
async def test_team_update_team_admin_soft_budget_follows_the_proxy_admin_grant(gateway_client, db, resources, actors):
|
||||
callers = await _callers(db, actors, resources.prefix)
|
||||
await _seed_target(db, actors, callers, "alpha", resources.prefix)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from litellm.proxy.management_endpoints.team_admin_field_permissions import (
|
|||
TeamAdminFieldNotPermitted,
|
||||
TeamAdminKeyEditAllowed,
|
||||
TeamAdminMemberKeyEditingDisabled,
|
||||
TeamMemberDefaults,
|
||||
changed_key_fields,
|
||||
changed_team_fields,
|
||||
resolve_team_admin_editable_fields,
|
||||
|
|
@ -160,8 +161,8 @@ class TestChangedTeamFields:
|
|||
assert changed_team_fields(UpdateTeamRequest(team_id="team-1", model_aliases={}), _team()) == frozenset()
|
||||
|
||||
def test_field_without_a_stored_counterpart_counts_as_changed_when_sent(self):
|
||||
data = UpdateTeamRequest(team_id="team-1", team_member_budget=10.0)
|
||||
assert changed_team_fields(data, _team()) == frozenset({"team_member_budget"})
|
||||
data = UpdateTeamRequest(team_id="team-1", team_member_key_duration="30d")
|
||||
assert changed_team_fields(data, _team()) == frozenset({"team_member_key_duration"})
|
||||
|
||||
|
||||
class TestTeamAdminEditVerdict:
|
||||
|
|
@ -192,27 +193,105 @@ class TestTeamAdminEditVerdict:
|
|||
assert verdict == TeamAdminFieldNotPermitted(field="blocked")
|
||||
|
||||
|
||||
def _enabled(*fields: str) -> frozenset[str]:
|
||||
def _permitted(*fields: str) -> frozenset[str]:
|
||||
return resolve_team_admin_editable_fields(
|
||||
{"team_admin_editable_team_fields": list(fields)}, SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS
|
||||
)
|
||||
|
||||
|
||||
_STORED_MEMBER_DEFAULTS: Final = TeamMemberDefaults(
|
||||
team_member_budget=10.0, team_member_budget_duration="30d", team_member_tpm_limit=100, team_member_rpm_limit=5
|
||||
)
|
||||
|
||||
_MEMBER_DEFAULT_EDITS: Final = (
|
||||
pytest.param("team_member_budget", 25.0, 10.0, id="team_member_budget"),
|
||||
pytest.param("team_member_budget_duration", "7d", "30d", id="team_member_budget_duration"),
|
||||
pytest.param("team_member_tpm_limit", 200, 100, id="team_member_tpm_limit"),
|
||||
pytest.param("team_member_rpm_limit", 9, 5, id="team_member_rpm_limit"),
|
||||
)
|
||||
|
||||
|
||||
class TestTeamMemberDefaultEdits:
|
||||
@pytest.mark.parametrize("field,new,_stored", _MEMBER_DEFAULT_EDITS)
|
||||
def test_enabled_field_change_is_allowed(self, field: str, new: object, _stored: object):
|
||||
data: Final = UpdateTeamRequest.model_validate({"team_id": "team-1", field: new})
|
||||
verdict: Final = team_admin_edit_verdict(data, _team(), _permitted(field), _STORED_MEMBER_DEFAULTS)
|
||||
assert isinstance(verdict, TeamAdminEditAllowed)
|
||||
assert verdict.request.model_dump(exclude_unset=True) == {"team_id": "team-1", field: new}
|
||||
|
||||
@pytest.mark.parametrize("field,new,_stored", _MEMBER_DEFAULT_EDITS)
|
||||
def test_change_without_the_field_enabled_names_it(self, field: str, new: object, _stored: object):
|
||||
data: Final = UpdateTeamRequest.model_validate({"team_id": "team-1", field: new})
|
||||
verdict: Final = team_admin_edit_verdict(data, _team(), _permitted("tpm_limit"), _STORED_MEMBER_DEFAULTS)
|
||||
assert verdict == TeamAdminFieldNotPermitted(field=field)
|
||||
|
||||
@pytest.mark.parametrize("field,_new,stored", _MEMBER_DEFAULT_EDITS)
|
||||
def test_resending_the_stored_value_is_dropped_without_the_field_enabled(
|
||||
self, field: str, _new: object, stored: object
|
||||
):
|
||||
data: Final = UpdateTeamRequest.model_validate({"team_id": "team-1", field: stored, "tpm_limit": 6})
|
||||
verdict: Final = team_admin_edit_verdict(
|
||||
data, _team(tpm_limit=5), _permitted("tpm_limit"), _STORED_MEMBER_DEFAULTS
|
||||
)
|
||||
assert isinstance(verdict, TeamAdminEditAllowed)
|
||||
assert verdict.request.model_dump(exclude_unset=True) == {"team_id": "team-1", "tpm_limit": 6}
|
||||
|
||||
def test_nulls_resent_for_a_team_without_member_defaults_are_dropped(self):
|
||||
data: Final = UpdateTeamRequest(
|
||||
team_id="team-1",
|
||||
team_member_budget=None,
|
||||
team_member_budget_duration=None,
|
||||
team_member_tpm_limit=None,
|
||||
team_member_rpm_limit=None,
|
||||
tpm_limit=6,
|
||||
)
|
||||
verdict: Final = team_admin_edit_verdict(data, _team(tpm_limit=5), _permitted("tpm_limit"), None)
|
||||
assert isinstance(verdict, TeamAdminEditAllowed)
|
||||
assert verdict.request.model_dump(exclude_unset=True) == {"team_id": "team-1", "tpm_limit": 6}
|
||||
|
||||
def test_first_member_budget_keeps_an_explicit_never_resets_duration(self):
|
||||
data: Final = UpdateTeamRequest(team_id="team-1", team_member_budget=5.0, team_member_budget_duration=None)
|
||||
permitted: Final = _permitted("team_member_budget", "team_member_budget_duration")
|
||||
verdict: Final = team_admin_edit_verdict(data, _team(budget_duration="7d"), permitted, None)
|
||||
assert isinstance(verdict, TeamAdminEditAllowed)
|
||||
assert verdict.request.model_dump(exclude_unset=True) == {
|
||||
"team_id": "team-1",
|
||||
"team_member_budget": 5.0,
|
||||
"team_member_budget_duration": None,
|
||||
}
|
||||
|
||||
def test_first_member_budget_drops_a_duration_the_team_admin_may_not_set(self):
|
||||
data: Final = UpdateTeamRequest(team_id="team-1", team_member_budget=5.0, team_member_budget_duration=None)
|
||||
verdict: Final = team_admin_edit_verdict(
|
||||
data, _team(budget_duration="7d"), _permitted("team_member_budget"), None
|
||||
)
|
||||
assert isinstance(verdict, TeamAdminEditAllowed)
|
||||
assert verdict.request.model_dump(exclude_unset=True) == {"team_id": "team-1", "team_member_budget": 5.0}
|
||||
|
||||
def test_existing_member_budget_drops_a_resent_duration_next_to_a_changed_budget(self):
|
||||
data: Final = UpdateTeamRequest(team_id="team-1", team_member_budget=5.0, team_member_budget_duration="7d")
|
||||
stored: Final = TeamMemberDefaults(team_member_budget=1.0, team_member_budget_duration="7d")
|
||||
permitted: Final = _permitted("team_member_budget", "team_member_budget_duration")
|
||||
verdict: Final = team_admin_edit_verdict(data, _team(), permitted, stored)
|
||||
assert isinstance(verdict, TeamAdminEditAllowed)
|
||||
assert verdict.request.model_dump(exclude_unset=True) == {"team_id": "team-1", "team_member_budget": 5.0}
|
||||
|
||||
|
||||
class TestSoftBudgetEdits:
|
||||
def test_enabled_soft_budget_change_is_allowed(self):
|
||||
data: Final = UpdateTeamRequest(team_id="team-1", soft_budget=20.0)
|
||||
verdict: Final = team_admin_edit_verdict(data, _team(soft_budget=10.0), _enabled("soft_budget"))
|
||||
verdict: Final = team_admin_edit_verdict(data, _team(soft_budget=10.0), _permitted("soft_budget"))
|
||||
assert isinstance(verdict, TeamAdminEditAllowed)
|
||||
assert verdict.request.model_dump(exclude_unset=True) == {"team_id": "team-1", "soft_budget": 20.0}
|
||||
|
||||
def test_soft_budget_change_without_it_enabled_names_it(self):
|
||||
data: Final = UpdateTeamRequest(team_id="team-1", soft_budget=20.0)
|
||||
verdict: Final = team_admin_edit_verdict(data, _team(soft_budget=10.0), _enabled("max_budget"))
|
||||
verdict: Final = team_admin_edit_verdict(data, _team(soft_budget=10.0), _permitted("max_budget"))
|
||||
assert verdict == TeamAdminFieldNotPermitted(field="soft_budget")
|
||||
|
||||
def test_unchanged_soft_budget_resend_is_dropped_when_not_enabled(self):
|
||||
def test_unchanged_soft_budget_resend_is_dropped_when_not_permitted(self):
|
||||
data: Final = UpdateTeamRequest(team_id="team-1", tpm_limit=6, soft_budget=10)
|
||||
verdict: Final = team_admin_edit_verdict(data, _team(soft_budget=10.0), _enabled("tpm_limit"))
|
||||
verdict: Final = team_admin_edit_verdict(data, _team(soft_budget=10.0), _permitted("tpm_limit"))
|
||||
assert isinstance(verdict, TeamAdminEditAllowed)
|
||||
assert verdict.request.model_dump(exclude_unset=True) == {"team_id": "team-1", "tpm_limit": 6}
|
||||
|
||||
|
|
|
|||
|
|
@ -16950,6 +16950,41 @@ async def test_update_team_team_admin_echoing_unpermitted_fields_unchanged_is_al
|
|||
assert prisma.db.litellm_teamtable.update.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_team_team_admin_resending_stored_member_defaults_is_not_an_edit(
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
import contextlib
|
||||
from litellm.models.budget import LiteLLM_BudgetTable
|
||||
|
||||
with contextlib.ExitStack() as stack:
|
||||
prisma = _wire_update_team(stack, {"team_member_budget_id": "member-budget-1"})
|
||||
prisma.db.litellm_budgettable.find_unique = AsyncMock(
|
||||
return_value=LiteLLM_BudgetTable(
|
||||
budget_id="member-budget-1", max_budget=10.0, budget_duration="30d", tpm_limit=100, rpm_limit=5
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch("litellm.proxy.proxy_server.general_settings", {"team_admin_editable_team_fields": ["tpm_limit"]}) # test-quality-ok: update_team reads general_settings as a proxy_server module global
|
||||
)
|
||||
result = await update_team(
|
||||
data=UpdateTeamRequest(
|
||||
team_id="test_team_id",
|
||||
team_member_budget=10.0,
|
||||
team_member_budget_duration="30d",
|
||||
team_member_tpm_limit=100,
|
||||
team_member_rpm_limit=5,
|
||||
tpm_limit=6,
|
||||
),
|
||||
http_request=_update_request_stub(),
|
||||
user_api_key_dict=_TEAM_ADMIN_CALLER,
|
||||
)
|
||||
|
||||
assert result["data"].team_id == "test_team_id"
|
||||
assert prisma.db.litellm_teamtable.update.call_args.kwargs["data"]["tpm_limit"] == 6
|
||||
assert not prisma.db.litellm_budgettable.update.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_team_team_admin_changes_tpm_limit_once_a_proxy_admin_enables_it(
|
||||
disable_audit_logging_for_mocked_team,
|
||||
|
|
|
|||
|
|
@ -127,6 +127,22 @@ describe("TeamAdminEditableFieldsSettings", () => {
|
|||
expect(mutate).toHaveBeenCalledWith({ team_admin_editable_team_fields: ["max_budget"] }, expect.anything());
|
||||
});
|
||||
|
||||
it("offers the member defaults as named checkboxes and saves the ticked ones", async () => {
|
||||
mockSettings(["team_member_budget", "team_member_budget_duration", "tpm_limit"], []);
|
||||
const mutate = mockSave({});
|
||||
|
||||
renderWithProviders(<TeamAdminEditableFieldsSettings />);
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Member Default Budget (USD)" }));
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Member Default Budget Duration" }));
|
||||
fireEvent.click(saveButton());
|
||||
|
||||
await waitFor(() => expect(mutate).toHaveBeenCalledTimes(1));
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
{ team_admin_editable_team_fields: ["team_member_budget", "team_member_budget_duration"] },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("offers the soft budget as a named checkbox and saves it when ticked", async () => {
|
||||
mockSettings(["max_budget", "soft_budget"], []);
|
||||
const mutate = mockSave({});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { fireEvent, renderWithProviders, screen, waitFor } from "@/../tests/test-utils";
|
||||
import { chooseSelectOption, fireEvent, renderWithProviders, screen, waitFor } from "@/../tests/test-utils";
|
||||
|
||||
import TeamAdminSettingsForm from "./TeamAdminSettingsForm";
|
||||
|
||||
|
|
@ -13,7 +13,16 @@ const renderForm = (
|
|||
const onCancel = vi.fn();
|
||||
renderWithProviders(
|
||||
<TeamAdminSettingsForm
|
||||
initialValues={{ tpm_limit: 1000, rpm_limit: 50, max_budget: 20, soft_budget: 15 }}
|
||||
initialValues={{
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 50,
|
||||
max_budget: 20,
|
||||
soft_budget: 15,
|
||||
team_member_budget: 10,
|
||||
team_member_budget_duration: "30d",
|
||||
team_member_tpm_limit: 100,
|
||||
team_member_rpm_limit: 5,
|
||||
}}
|
||||
editableFields={editableFields}
|
||||
mayRaiseMaxBudget={overrides.mayRaiseMaxBudget ?? false}
|
||||
isSaving={overrides.isSaving ?? false}
|
||||
|
|
@ -84,6 +93,57 @@ describe("TeamAdminSettingsForm", () => {
|
|||
expect(save).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows the team's member defaults when the proxy lets team admins edit them", () => {
|
||||
renderForm(
|
||||
new Set(["team_member_budget", "team_member_budget_duration", "team_member_tpm_limit", "team_member_rpm_limit"]),
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Member Default Budget (USD)")).toHaveValue(10);
|
||||
expect(screen.getByLabelText("Member Default Budget Duration")).toHaveTextContent("monthly");
|
||||
expect(screen.getByLabelText("Member Default TPM Limit")).toHaveValue(100);
|
||||
expect(screen.getByLabelText("Member Default RPM Limit")).toHaveValue(5);
|
||||
});
|
||||
|
||||
it("saves a new member default budget and a weekly reset without resending the unchanged limits", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSave } = renderForm(
|
||||
new Set(["team_member_budget", "team_member_budget_duration", "team_member_tpm_limit", "team_member_rpm_limit"]),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Member Default Budget (USD)"), { target: { value: "25" } });
|
||||
await chooseSelectOption(user, screen.getByLabelText("Member Default Budget Duration"), "weekly");
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onSave).toHaveBeenCalledWith({ team_member_budget: 25, team_member_budget_duration: "7d" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("saves a member budget that never resets as a null duration", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSave } = renderForm(new Set(["team_member_budget_duration"]));
|
||||
|
||||
await chooseSelectOption(user, screen.getByLabelText("Member Default Budget Duration"), "Never resets");
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ team_member_budget_duration: null }));
|
||||
});
|
||||
|
||||
it("shows a stored member budget that never resets as Never resets", () => {
|
||||
renderWithProviders(
|
||||
<TeamAdminSettingsForm
|
||||
initialValues={{ team_member_budget: 10, team_member_budget_duration: null }}
|
||||
editableFields={new Set(["team_member_budget_duration"])}
|
||||
mayRaiseMaxBudget={false}
|
||||
isSaving={false}
|
||||
onCancel={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Member Default Budget Duration")).toHaveTextContent("Never resets");
|
||||
});
|
||||
|
||||
it("shows the soft budget when the proxy lets team admins edit it", () => {
|
||||
renderForm(new Set(["soft_budget"]));
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ import { FieldGroup } from "@/components/ui/field";
|
|||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
|
||||
import BudgetDurationDropdown, { NEVER_RESETS_BUDGET_DURATION } from "../common_components/budget_duration_dropdown";
|
||||
import NumericalInput from "../shared/numerical_input";
|
||||
import {
|
||||
isTeamAdminDurationSettingsField,
|
||||
TEAM_ADMIN_SETTINGS_FIELDS,
|
||||
teamAdminFieldLabel,
|
||||
teamAdminSettingsChanges,
|
||||
|
|
@ -27,15 +29,22 @@ const teamAdminSettingsShape = {
|
|||
rpm_limit: numericInputSchema,
|
||||
max_budget: numericInputSchema,
|
||||
soft_budget: numericInputSchema,
|
||||
team_member_budget: numericInputSchema,
|
||||
team_member_budget_duration: z.string().nullish(),
|
||||
team_member_tpm_limit: numericInputSchema,
|
||||
team_member_rpm_limit: numericInputSchema,
|
||||
};
|
||||
|
||||
const teamAdminSettingsSchema = z.object(teamAdminSettingsShape);
|
||||
|
||||
const INPUT_STEP: Readonly<Record<TeamAdminSettingsField, number>> = {
|
||||
const INPUT_STEP: Readonly<Record<Exclude<TeamAdminSettingsField, "team_member_budget_duration">, number>> = {
|
||||
tpm_limit: 1,
|
||||
rpm_limit: 1,
|
||||
max_budget: 0.01,
|
||||
soft_budget: 0.01,
|
||||
team_member_budget: 0.01,
|
||||
team_member_tpm_limit: 1,
|
||||
team_member_rpm_limit: 1,
|
||||
};
|
||||
|
||||
const MAX_BUDGET_KEEP_OR_LOWER_HINT = "You can keep or lower this budget. Ask a proxy admin to raise it.";
|
||||
|
|
@ -47,6 +56,11 @@ const fieldHint = (name: TeamAdminSettingsField, mayRaiseMaxBudget: boolean): st
|
|||
return mayRaiseMaxBudget ? MAX_BUDGET_RAISE_HINT : MAX_BUDGET_KEEP_OR_LOWER_HINT;
|
||||
};
|
||||
|
||||
const durationSelectValue = (value: string | number | null | undefined): string | undefined => {
|
||||
if (value === null) return NEVER_RESETS_BUDGET_DURATION;
|
||||
return typeof value === "string" ? value : undefined;
|
||||
};
|
||||
|
||||
interface TeamAdminSettingsFormProps {
|
||||
initialValues: TeamAdminSettingsValues;
|
||||
editableFields: ReadonlySet<string>;
|
||||
|
|
@ -83,9 +97,26 @@ export default function TeamAdminSettingsForm({
|
|||
label={teamAdminFieldLabel(name)}
|
||||
description={fieldHint(name, mayRaiseMaxBudget)}
|
||||
>
|
||||
{({ ref, value, ...field }) => (
|
||||
<NumericalInput {...field} ref={ref} value={value ?? ""} step={INPUT_STEP[name]} />
|
||||
)}
|
||||
{({ ref, id, value, onChange, ...field }) =>
|
||||
isTeamAdminDurationSettingsField(name) ? (
|
||||
<BudgetDurationDropdown
|
||||
id={id}
|
||||
showNeverResets
|
||||
placeholder="Inherit team reset period"
|
||||
value={durationSelectValue(value)}
|
||||
onChange={(next) => onChange(next === NEVER_RESETS_BUDGET_DURATION ? null : next ?? undefined)}
|
||||
/>
|
||||
) : (
|
||||
<NumericalInput
|
||||
{...field}
|
||||
ref={ref}
|
||||
id={id}
|
||||
value={value ?? ""}
|
||||
onChange={onChange}
|
||||
step={INPUT_STEP[name]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</FormField>
|
||||
))}
|
||||
</FieldGroup>
|
||||
|
|
|
|||
|
|
@ -2047,6 +2047,35 @@ describe("TeamInfoView", () => {
|
|||
expect(screen.getByRole("button", { name: /save changes/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("prefills the member defaults a team admin may edit from the team's member budget", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||
createMockTeamData({
|
||||
team_member_budget_table: { max_budget: 10, budget_duration: "30d", tpm_limit: 100, rpm_limit: 5 },
|
||||
caller_edit_access: {
|
||||
kind: "team_admin",
|
||||
editable_fields: [
|
||||
"team_member_budget",
|
||||
"team_member_budget_duration",
|
||||
"team_member_tpm_limit",
|
||||
"team_member_rpm_limit",
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
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("Member Default Budget (USD)")).toHaveValue(10);
|
||||
expect(screen.getByLabelText("Member Default Budget Duration")).toHaveTextContent("monthly");
|
||||
expect(screen.getByLabelText("Member Default TPM Limit")).toHaveValue(100);
|
||||
expect(screen.getByLabelText("Member Default RPM Limit")).toHaveValue(5);
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -1311,6 +1311,10 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
rpm_limit: info.rpm_limit,
|
||||
max_budget: info.max_budget,
|
||||
soft_budget: info.soft_budget,
|
||||
team_member_budget: info.team_member_budget_table?.max_budget,
|
||||
team_member_budget_duration: info.team_member_budget_table?.budget_duration,
|
||||
team_member_tpm_limit: info.team_member_budget_table?.tpm_limit,
|
||||
team_member_rpm_limit: info.team_member_budget_table?.rpm_limit,
|
||||
}}
|
||||
editableFields={teamEditAccess.editableFields}
|
||||
mayRaiseMaxBudget={teamEditAccess.mayRaiseMaxBudget}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ describe("teamAdminFieldLabel", () => {
|
|||
["soft_budget", "Soft Budget (USD)"],
|
||||
["raise_max_budget", "Raise the team's max budget"],
|
||||
["projects", "Create and update projects"],
|
||||
["team_member_budget", "Member Default Budget (USD)"],
|
||||
["team_member_budget_duration", "Member Default Budget Duration"],
|
||||
["team_member_tpm_limit", "Member Default TPM Limit"],
|
||||
["team_member_rpm_limit", "Member Default RPM Limit"],
|
||||
["create_member_keys", "Create keys for team members"],
|
||||
])("names %s the way the team settings form does", (field, label) => {
|
||||
expect(teamAdminFieldLabel(field)).toBe(label);
|
||||
|
|
@ -77,6 +81,65 @@ describe("teamAdminSettingsChanges", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("teamAdminSettingsChanges for team member defaults", () => {
|
||||
const stored = {
|
||||
team_member_budget: 10,
|
||||
team_member_budget_duration: "30d",
|
||||
team_member_tpm_limit: 100,
|
||||
team_member_rpm_limit: 5,
|
||||
};
|
||||
const enabled = new Set([
|
||||
"team_member_budget",
|
||||
"team_member_budget_duration",
|
||||
"team_member_tpm_limit",
|
||||
"team_member_rpm_limit",
|
||||
]);
|
||||
|
||||
const durationEnabled = new Set(["team_member_budget_duration"]);
|
||||
|
||||
it("sends every changed member default and skips the unchanged ones", () => {
|
||||
const values = {
|
||||
team_member_budget: "25",
|
||||
team_member_budget_duration: "7d",
|
||||
team_member_tpm_limit: "100",
|
||||
team_member_rpm_limit: "9",
|
||||
};
|
||||
|
||||
expect(teamAdminSettingsChanges(values, stored, enabled)).toStrictEqual({
|
||||
team_member_budget: 25,
|
||||
team_member_budget_duration: "7d",
|
||||
team_member_rpm_limit: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends a never-resetting duration as null", () => {
|
||||
expect(teamAdminSettingsChanges({ team_member_budget_duration: null }, stored, durationEnabled)).toStrictEqual({
|
||||
team_member_budget_duration: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends a never-resetting duration for a team without member defaults yet", () => {
|
||||
expect(teamAdminSettingsChanges({ team_member_budget_duration: null }, {}, durationEnabled)).toStrictEqual({
|
||||
team_member_budget_duration: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the stored duration", "30d", stored],
|
||||
["an untouched duration on a team with a stored duration", undefined, stored],
|
||||
["an untouched duration on a team without member defaults", undefined, {}],
|
||||
["a stored never-resetting duration picked again", null, { team_member_budget_duration: null }],
|
||||
])("sends no duration for %s", (_label, team_member_budget_duration, initialValues) => {
|
||||
expect(teamAdminSettingsChanges({ team_member_budget_duration }, initialValues, durationEnabled)).toStrictEqual({});
|
||||
});
|
||||
|
||||
it("leaves out member defaults the proxy did not enable", () => {
|
||||
const values = { team_member_budget: "25", team_member_budget_duration: "7d" };
|
||||
|
||||
expect(teamAdminSettingsChanges(values, stored, new Set(["max_budget"]))).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("teamAdminSettingsChanges for the soft budget", () => {
|
||||
const stored = { soft_budget: 50 };
|
||||
const enabled = new Set(["soft_budget"]);
|
||||
|
|
|
|||
|
|
@ -48,15 +48,36 @@ export const parseSupportedTeamAdminEditableFields = (uiSettingsFieldSchema: unk
|
|||
return items.success ? fieldListSchema.parse(items.data.enum) : [];
|
||||
};
|
||||
|
||||
export const TEAM_ADMIN_SETTINGS_FIELDS = ["tpm_limit", "rpm_limit", "max_budget", "soft_budget"] as const;
|
||||
export const TEAM_ADMIN_SETTINGS_FIELDS = [
|
||||
"tpm_limit",
|
||||
"rpm_limit",
|
||||
"max_budget",
|
||||
"soft_budget",
|
||||
"team_member_budget",
|
||||
"team_member_budget_duration",
|
||||
"team_member_tpm_limit",
|
||||
"team_member_rpm_limit",
|
||||
] as const;
|
||||
|
||||
export type TeamAdminSettingsField = (typeof TEAM_ADMIN_SETTINGS_FIELDS)[number];
|
||||
|
||||
type TeamAdminDurationSettingsField = "team_member_budget_duration";
|
||||
|
||||
type TeamAdminNumericSettingsField = Exclude<TeamAdminSettingsField, TeamAdminDurationSettingsField>;
|
||||
|
||||
export const isTeamAdminDurationSettingsField = (
|
||||
field: TeamAdminSettingsField,
|
||||
): field is TeamAdminDurationSettingsField => field === "team_member_budget_duration";
|
||||
|
||||
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)"],
|
||||
["soft_budget", "Soft Budget (USD)"],
|
||||
["team_member_budget", "Member Default Budget (USD)"],
|
||||
["team_member_budget_duration", "Member Default Budget Duration"],
|
||||
["team_member_tpm_limit", "Member Default TPM Limit"],
|
||||
["team_member_rpm_limit", "Member Default RPM Limit"],
|
||||
[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"],
|
||||
|
|
@ -67,7 +88,26 @@ export const teamAdminFieldLabel = (field: string): string => TEAM_ADMIN_FIELD_L
|
|||
|
||||
export type TeamAdminSettingsValues = { readonly [F in TeamAdminSettingsField]?: string | number | null };
|
||||
|
||||
export type TeamAdminSettingsChanges = { readonly [F in TeamAdminSettingsField]?: number | null };
|
||||
export type TeamAdminSettingsChanges = { readonly [F in TeamAdminNumericSettingsField]?: number | null } & {
|
||||
readonly [F in TeamAdminDurationSettingsField]?: string | null;
|
||||
};
|
||||
|
||||
const durationOrNull = (value: string | number | null | undefined): string | null =>
|
||||
typeof value === "string" && value !== "" ? value : null;
|
||||
|
||||
const changedValue = (
|
||||
field: TeamAdminSettingsField,
|
||||
value: string | number | null | undefined,
|
||||
initialValue: string | number | null | undefined,
|
||||
): readonly [TeamAdminSettingsField, string | number | null] | null => {
|
||||
if (!isTeamAdminDurationSettingsField(field)) {
|
||||
const number = numberOrNull(value);
|
||||
return number === numberOrNull(initialValue) ? null : [field, number];
|
||||
}
|
||||
if (value === undefined) return null;
|
||||
const duration = durationOrNull(value);
|
||||
return initialValue !== undefined && duration === durationOrNull(initialValue) ? null : [field, duration];
|
||||
};
|
||||
|
||||
export const teamAdminSettingsChanges = (
|
||||
values: TeamAdminSettingsValues,
|
||||
|
|
@ -76,8 +116,8 @@ export const teamAdminSettingsChanges = (
|
|||
): TeamAdminSettingsChanges =>
|
||||
Object.fromEntries(
|
||||
TEAM_ADMIN_SETTINGS_FIELDS.flatMap((field) => {
|
||||
const value = numberOrNull(values[field]);
|
||||
return editableFields.has(field) && value !== numberOrNull(initialValues[field]) ? [[field, value]] : [];
|
||||
const change = editableFields.has(field) ? changedValue(field, values[field], initialValues[field]) : null;
|
||||
return change === null ? [] : [change];
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue