diff --git a/litellm/proxy/management_endpoints/team_admin_field_permissions.py b/litellm/proxy/management_endpoints/team_admin_field_permissions.py
index 67cbb0be45d..3eb54c2147a 100644
--- a/litellm/proxy/management_endpoints/team_admin_field_permissions.py
+++ b/litellm/proxy/management_endpoints/team_admin_field_permissions.py
@@ -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:
diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py
index 9cc1afd7beb..35aded51415 100644
--- a/litellm/proxy/management_endpoints/team_endpoints.py
+++ b/litellm/proxy/management_endpoints/team_endpoints.py
@@ -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,
diff --git a/tests/integration/management/test_team_update.py b/tests/integration/management/test_team_update.py
index 0414d0bb77f..e1d9c43f318 100644
--- a/tests/integration/management/test_team_update.py
+++ b/tests/integration/management/test_team_update.py
@@ -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)
diff --git a/tests/unit/proxy/management_endpoints/test_team_admin_field_permissions.py b/tests/unit/proxy/management_endpoints/test_team_admin_field_permissions.py
index 0c9ed9356f8..0ffb3694942 100644
--- a/tests/unit/proxy/management_endpoints/test_team_admin_field_permissions.py
+++ b/tests/unit/proxy/management_endpoints/test_team_admin_field_permissions.py
@@ -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}
diff --git a/tests/unit/proxy/management_endpoints/test_team_endpoints.py b/tests/unit/proxy/management_endpoints/test_team_endpoints.py
index bbb83ec1248..15eed3b105a 100644
--- a/tests/unit/proxy/management_endpoints/test_team_endpoints.py
+++ b/tests/unit/proxy/management_endpoints/test_team_endpoints.py
@@ -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,
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/TeamAdminEditableFieldsSettings.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/TeamAdminEditableFieldsSettings.test.tsx
index 36fbe086df3..df3dafc4b28 100644
--- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/TeamAdminEditableFieldsSettings.test.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/TeamAdminEditableFieldsSettings.test.tsx
@@ -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();
+ 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({});
diff --git a/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.test.tsx b/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.test.tsx
index 7fe6461e285..a84d7bc4bac 100644
--- a/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.test.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.test.tsx
@@ -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(
{
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(
+ ,
+ );
+
+ 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"]));
diff --git a/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.tsx b/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.tsx
index 5ce50bb6c72..05595d6a1a4 100644
--- a/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.tsx
@@ -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> = {
+const INPUT_STEP: Readonly, 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;
@@ -83,9 +97,26 @@ export default function TeamAdminSettingsForm({
label={teamAdminFieldLabel(name)}
description={fieldHint(name, mayRaiseMaxBudget)}
>
- {({ ref, value, ...field }) => (
-
- )}
+ {({ ref, id, value, onChange, ...field }) =>
+ isTeamAdminDurationSettingsField(name) ? (
+ onChange(next === NEVER_RESETS_BUDGET_DURATION ? null : next ?? undefined)}
+ />
+ ) : (
+
+ )
+ }
))}
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
index 31d3016d4be..973b2ecee80 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
@@ -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();
+
+ 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(
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
index cfac81cb64b..0743e73005e 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
@@ -1311,6 +1311,10 @@ const TeamInfoView: React.FC = ({
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}
diff --git a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts
index 590dd806d7f..84898563899 100644
--- a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts
+++ b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts
@@ -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"]);
diff --git a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts
index 3ab51b4cdbb..28ba5eed3e4 100644
--- a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts
+++ b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts
@@ -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;
+
+export const isTeamAdminDurationSettingsField = (
+ field: TeamAdminSettingsField,
+): field is TeamAdminDurationSettingsField => field === "team_member_budget_duration";
+
const TEAM_ADMIN_FIELD_LABELS: ReadonlyMap = 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];
}),
);