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:
yuneng-jiang 2026-10-14 14:05:58 -07:00 • committed by GitHub
parent 47c40586e1
commit df3b44dbee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 557 additions and 27 deletions

View file

@ -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:

View file

@ -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,

View file

@ -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)

View file

@ -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}

View file

@ -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,

View file

@ -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({});

View file

@ -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"]));

View file

@ -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>

View file

@ -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(

View file

@ -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}

View file

@ -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"]);

View file

@ -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];
}),
);