feat(proxy): let proxy admins open team metadata to team admins, keeping proxy settings with proxy admins (#46506)

* 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

* feat(proxy): let proxy admins open soft_budget to team admins

* feat(proxy): let proxy admins open team metadata to team admins, keeping proxy settings with proxy admins

* test(ui): name expected objects in team admin edit access tests

Keeps local/no-large-inline-object-arg within its eslint budget

* fix(proxy): compare team metadata without recursion and count key presence

A proxy-controlled metadata key that appears or disappears with a null value
is now a change, and the JSON comparison no longer recurses

* fix(proxy): compare team metadata numbers exactly

Numbers are compared as decimals, so integers past float precision and close
floats are no longer treated as equal
This commit is contained in:
yuneng-jiang 2026-10-14 16:51:44 -07:00 • committed by GitHub
parent 01cec8f938
commit 9271edc734
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1120 additions and 37 deletions

View file

@ -4275,6 +4275,7 @@ class TeamEditAsTeamAdmin(LiteLLMBaseModel):
kind: Literal["team_admin"] = "team_admin"
editable_fields: tuple[str, ...]
may_raise_max_budget: bool = False
proxy_controlled_metadata_keys: tuple[str, ...] = ()
class TeamEditAsTeamAdminDisabled(LiteLLMBaseModel):

View file

@ -7,16 +7,19 @@ above the organization's budget, never removing the cap), the ``projects`` permi
user and the key is confined to LLM API routes: the team admin who holds its secret gains no management
authority through it."""
import json
from collections.abc import Mapping
from dataclasses import dataclass
from decimal import Decimal
from types import MappingProxyType
from typing import Final, Literal, TypeAlias
from fastapi import HTTPException
from pydantic import TypeAdapter, ValidationError
from pydantic import JsonValue, TypeAdapter, ValidationError
from typing_extensions import assert_never
from litellm._logging import verbose_proxy_logger
from litellm.constants import ADMIN_ONLY_BATCH_LIMIT_METADATA_KEYS, OTEL_SERVICE_NAME_METADATA_KEYS
from litellm.models.budget import LiteLLM_BudgetTable
from litellm.models.team import LiteLLM_TeamTable
from litellm.models.verification_token import LiteLLM_VerificationToken
@ -28,6 +31,10 @@ from litellm.proxy._types import (
UpdateKeyRequest,
UpdateTeamRequest,
)
from litellm.types.proxy.management_endpoints.scim_v2 import (
SCIM_MANAGED_TEAM_METADATA_KEY,
SCIM_TEAM_DATA_METADATA_KEY,
)
TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING: Final = "team_admin_editable_team_fields"
@ -38,6 +45,7 @@ SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS: Final[frozenset[str]] = frozenset(
"rpm_limit",
"max_budget",
"soft_budget",
"metadata",
"team_member_budget",
"team_member_budget_duration",
"team_member_tpm_limit",
@ -66,12 +74,40 @@ _KEY_REQUEST_IDENTITY: Final[frozenset[str]] = frozenset({"key", "token", "metad
_FIELD_LIST: Final = TypeAdapter(list[str])
_JSON_OBJECT: Final = TypeAdapter(dict[str, object])
_JSON_VALUE: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
_COMPARABLE_JSON: Final[TypeAdapter[object]] = TypeAdapter(object)
_WINDOW_LIST: Final = TypeAdapter(list[dict[str, object]])
_EMPTY: Final[Mapping[str, object]] = MappingProxyType({})
_METADATA_FOLDED_FIELDS: Final[frozenset[str]] = frozenset(
(*LiteLLM_ManagementEndpoint_MetadataFields, *LiteLLM_ManagementEndpoint_MetadataFields_Premium)
)
_SYSTEM_MANAGED_METADATA_KEYS: Final[frozenset[str]] = frozenset({"team_member_budget_id"})
PROXY_CONTROLLED_TEAM_METADATA_KEYS: Final[frozenset[str]] = frozenset(
(
*_METADATA_FOLDED_FIELDS,
*_SYSTEM_MANAGED_METADATA_KEYS,
*OTEL_SERVICE_NAME_METADATA_KEYS,
*ADMIN_ONLY_BATCH_LIMIT_METADATA_KEYS,
SCIM_MANAGED_TEAM_METADATA_KEY,
SCIM_TEAM_DATA_METADATA_KEY,
"opted_out_global_guardrails",
"spend_logs_metadata",
"model_config",
"callback_settings",
"priority",
"zguard_policy_id",
"allowed_tools",
"allow_client_mock_response",
"allow_client_message_redaction_opt_out",
"allow_client_pricing_override",
"allow_litellm_changed_by_header",
"soft_budget_alerting_emails",
"team_member_max_budget_alert_emails",
"phoenix_project_name_override",
"phoenix_project_name",
"opik",
)
)
_NOT_COLUMNS: Final[frozenset[str]] = frozenset({"team_id", "metadata"})
_SETTINGS_LOCATION: Final = "Settings > UI > Team admin editable fields"
@ -106,7 +142,25 @@ class TeamAdminFieldNotPermitted:
kind: Literal["field_not_permitted"] = "field_not_permitted"
TeamAdminEditVerdict: TypeAlias = TeamAdminEditAllowed | TeamAdminEditingDisabled | TeamAdminFieldNotPermitted
@dataclass(frozen=True, slots=True)
class TeamAdminMetadataKeyProxyControlled:
key: str
kind: Literal["metadata_key_proxy_controlled"] = "metadata_key_proxy_controlled"
@dataclass(frozen=True, slots=True)
class TeamAdminDeclaredMetadataKeyCleared:
key: str
kind: Literal["declared_metadata_key_cleared"] = "declared_metadata_key_cleared"
TeamAdminEditVerdict: TypeAlias = (
TeamAdminEditAllowed
| TeamAdminEditingDisabled
| TeamAdminFieldNotPermitted
| TeamAdminMetadataKeyProxyControlled
| TeamAdminDeclaredMetadataKeyCleared
)
def resolve_team_admin_editable_fields(
@ -347,6 +401,47 @@ def changed_team_fields(
return column_changes | _metadata_changes(data, submitted, existing)
def _json_number(text: str) -> tuple[str, Decimal]:
return ("number", Decimal(text))
def _comparable_json(value: JsonValue) -> object:
return _COMPARABLE_JSON.validate_python(
json.loads(json.dumps(value), parse_int=_json_number, parse_float=_json_number)
)
def _metadata_value_changed(new: object, old: object) -> bool:
return _comparable_json(_JSON_VALUE.validate_python(new)) != _comparable_json(_JSON_VALUE.validate_python(old))
def _is_blank(value: object) -> bool:
return value is None or (isinstance(value, str) and not value.strip())
def changed_team_metadata(data: UpdateTeamRequest, existing_row: LiteLLM_TeamTable) -> Mapping[str, object]:
"""Metadata keys the request would change, mapped to the value each would be left with (None when removed).
A system-managed key left out of a metadata dict is not a change, since update_team restores it then.
"""
submitted: Final = _JSON_OBJECT.validate_json(data.model_dump_json(exclude_unset=True))
existing: Final = _JSON_OBJECT.validate_json(existing_row.model_dump_json())
merged: Final = _submitted_metadata(data, submitted, existing)
stored: Final = _stored_metadata(existing)
sent: Final = submitted.get("metadata")
sent_metadata: Final = _as_object(sent)
restored: Final = (
_SYSTEM_MANAGED_METADATA_KEYS - sent_metadata.keys() if isinstance(sent, dict) else frozenset[str]()
)
return MappingProxyType(
{
key: merged.get(key)
for key in (merged.keys() | stored.keys()) - restored
if (key in merged) != (key in stored) or _metadata_value_changed(merged.get(key), stored.get(key))
}
)
def _only_changes(data: UpdateTeamRequest, changed: frozenset[str]) -> UpdateTeamRequest:
"""The request without the values it resends unchanged, which would otherwise still trigger derived writes
such as a resent budget_duration pushing budget_reset_at back."""
@ -361,13 +456,24 @@ def team_admin_edit_verdict(
existing: LiteLLM_TeamTable,
permitted: frozenset[str],
member_defaults: TeamMemberDefaults | None = None,
proxy_controlled_metadata_keys: frozenset[str] = PROXY_CONTROLLED_TEAM_METADATA_KEYS,
declared_metadata_keys: frozenset[str] = frozenset(),
) -> TeamAdminEditVerdict:
if not permitted:
return TeamAdminEditingDisabled()
metadata_changes: Final = changed_team_metadata(data, existing)
controlled: Final = sorted(metadata_changes.keys() & proxy_controlled_metadata_keys)
if controlled:
return TeamAdminMetadataKeyProxyControlled(key=controlled[0])
changed: Final = changed_team_fields(data, existing, member_defaults)
blocked: Final = sorted(changed - permitted)
if blocked:
return TeamAdminFieldNotPermitted(field=blocked[0])
cleared: Final = sorted(
key for key in metadata_changes.keys() & declared_metadata_keys if _is_blank(metadata_changes[key])
)
if cleared:
return TeamAdminDeclaredMetadataKeyCleared(key=cleared[0])
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
@ -397,6 +503,22 @@ def team_admin_request_or_raise(verdict: TeamAdminEditVerdict) -> UpdateTeamRequ
f"Ask a proxy admin to add it under {_SETTINGS_LOCATION}."
),
)
case TeamAdminMetadataKeyProxyControlled(key=key):
raise HTTPException(
status_code=403,
detail=(
f"Team metadata key '{key}' is a proxy setting that team admins cannot add, change or remove "
"in team settings. Ask a proxy admin to change it."
),
)
case TeamAdminDeclaredMetadataKeyCleared(key=key):
raise HTTPException(
status_code=403,
detail=(
f"Team metadata key '{key}' is declared in the proxy's team metadata schema, so team admins "
"can change its value but cannot remove it or leave it empty. Ask a proxy admin to remove it."
),
)
case _:
assert_never(verdict)

View file

@ -164,6 +164,7 @@ from litellm.proxy.management_endpoints.tag_management_endpoints import (
get_daily_activity,
)
from litellm.proxy.management_endpoints.team_admin_field_permissions import (
PROXY_CONTROLLED_TEAM_METADATA_KEYS,
SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS,
TEAM_MEMBER_DEFAULT_FIELDS,
TeamMemberDefaults,
@ -536,6 +537,9 @@ def _caller_edit_access(role: TeamRole | None, general_settings: Mapping[str, ob
return TeamEditAsTeamAdmin(
editable_fields=tuple(sorted(permitted)),
may_raise_max_budget=team_admin_may_raise_max_budget(general_settings),
proxy_controlled_metadata_keys=(
tuple(sorted(PROXY_CONTROLLED_TEAM_METADATA_KEYS)) if "metadata" in permitted else ()
),
)
case None:
return TeamEditNone()
@ -2384,6 +2388,8 @@ async def update_team(
if reads_member_defaults
else None
),
proxy_controlled_metadata_keys=PROXY_CONTROLLED_TEAM_METADATA_KEYS,
declared_metadata_keys=frozenset(field.key for field in TEAM_METADATA_SCHEMA_REGISTRY.get()),
)
)

View file

@ -1,3 +1,5 @@
import json
import pytest
from litellm.proxy._types import LitellmUserRoles
@ -280,6 +282,92 @@ async def test_team_update_team_admin_cannot_set_soft_budget_on_a_team_they_do_n
assert row is not None and row.soft_budget != 7.5, "denied but team mutated"
async def _seed_team_metadata(db, team_id: str, metadata: dict[str, object]) -> None:
await db.db.litellm_teamtable.update(where={"team_id": team_id}, data={"metadata": json.dumps(metadata)})
async def _stored_team_metadata(db, team_id: str) -> dict[str, object]:
row = await db.db.litellm_teamtable.find_unique(where={"team_id": team_id})
assert row is not None
return dict(row.metadata or {})
_STORED_TEAM_METADATA = {"cost_center": "eng", "region": "eu", "priority": "high"}
@pytest.mark.parametrize(
"sent",
[
pytest.param({**_STORED_TEAM_METADATA, "project": "atlas"}, id="add"),
pytest.param({**_STORED_TEAM_METADATA, "cost_center": "sales"}, id="edit"),
pytest.param({"region": "eu", "priority": "high"}, id="delete"),
],
)
async def test_team_update_team_admin_edits_ordinary_metadata_keys(sent, gateway_client, db, resources, actors):
callers = await _callers(db, actors, resources.prefix)
await _seed_target(db, actors, callers, "alpha", resources.prefix)
await _seed_team_metadata(db, resources.prefix, _STORED_TEAM_METADATA)
headers = {"Authorization": f"Bearer {callers[Actor.TEAM_ADMIN].cleartext}"}
with team_admin_permissions(gateway_client.gateway, ["tpm_limit"]):
denied = await gateway_client.post(
"/team/update", headers=headers, json={"team_id": resources.prefix, "metadata": sent}
)
assert denied.status_code == 403, denied.text
assert "'metadata'" in denied.text, denied.text
assert await _stored_team_metadata(db, resources.prefix) == _STORED_TEAM_METADATA
with team_admin_permissions(gateway_client.gateway, ["metadata"]):
allowed = await gateway_client.post(
"/team/update", headers=headers, json={"team_id": resources.prefix, "metadata": sent}
)
assert allowed.status_code == 200, allowed.text
assert await _stored_team_metadata(db, resources.prefix) == sent
@pytest.mark.parametrize(
"sent",
[
pytest.param({**_STORED_TEAM_METADATA, "priority": "low"}, id="change"),
pytest.param({"cost_center": "eng", "region": "eu"}, id="remove"),
pytest.param({**_STORED_TEAM_METADATA, "callback_settings": {"callbacks": []}}, id="add"),
],
)
async def test_team_update_team_admin_cannot_touch_proxy_controlled_metadata_keys(
sent, gateway_client, db, resources, actors
):
callers = await _callers(db, actors, resources.prefix)
await _seed_target(db, actors, callers, "alpha", resources.prefix)
await _seed_team_metadata(db, resources.prefix, _STORED_TEAM_METADATA)
with team_admin_permissions(gateway_client.gateway, ["metadata"]):
resp = await gateway_client.post(
"/team/update",
headers={"Authorization": f"Bearer {callers[Actor.TEAM_ADMIN].cleartext}"},
json={"team_id": resources.prefix, "metadata": sent},
)
assert resp.status_code == 403, resp.text
assert "proxy setting" in resp.text, resp.text
assert await _stored_team_metadata(db, resources.prefix) == _STORED_TEAM_METADATA
async def test_team_update_team_admin_resending_stored_metadata_is_not_an_edit(gateway_client, db, resources, actors):
callers = await _callers(db, actors, resources.prefix)
await _seed_target(db, actors, callers, "alpha", resources.prefix)
await _seed_team_metadata(db, resources.prefix, _STORED_TEAM_METADATA)
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": 500, "metadata": dict(_STORED_TEAM_METADATA)},
)
assert resp.status_code == 200, resp.text
row = await db.db.litellm_teamtable.find_unique(where={"team_id": resources.prefix})
assert row is not None and row.tpm_limit == 500
assert dict(row.metadata or {}) == _STORED_TEAM_METADATA
# Relocation gate — moving a team to a different org. The resources team starts
# in actors.org_a_id; each scenario relocates it to actors.org_b_id. PROXY_ADMIN bypasses;
# ORG_B_ADMIN reaches the handler but holds no role on the source team (403);

View file

@ -14,11 +14,13 @@ from litellm.proxy._types import (
)
from litellm.proxy.management_endpoints.team_admin_field_permissions import (
SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS,
TeamAdminDeclaredMetadataKeyCleared,
TeamAdminEditAllowed,
TeamAdminEditingDisabled,
TeamAdminFieldNotPermitted,
TeamAdminKeyEditAllowed,
TeamAdminMemberKeyEditingDisabled,
TeamAdminMetadataKeyProxyControlled,
TeamMemberDefaults,
changed_key_fields,
changed_team_fields,
@ -111,8 +113,8 @@ class TestChangedTeamFields:
data: Final = UpdateTeamRequest(team_id="team-1", require_trace_id=False)
assert changed_team_fields(data, existing) == frozenset({"require_trace_id"})
assert team_admin_edit_verdict(data, existing, frozenset({"tpm_limit"})) == TeamAdminFieldNotPermitted(
field="require_trace_id"
assert team_admin_edit_verdict(data, existing, frozenset({"tpm_limit"})) == TeamAdminMetadataKeyProxyControlled(
key="require_trace_id"
)
assert changed_team_fields(UpdateTeamRequest(team_id="team-1", require_trace_id=True), existing) == frozenset()
@ -121,8 +123,8 @@ class TestChangedTeamFields:
existing: Final = _team(metadata={"require_trace_id": True})
assert changed_team_fields(data, existing) == frozenset({"require_trace_id"})
assert team_admin_edit_verdict(data, existing, frozenset({"tpm_limit"})) == TeamAdminFieldNotPermitted(
field="require_trace_id"
assert team_admin_edit_verdict(data, existing, frozenset({"tpm_limit"})) == TeamAdminMetadataKeyProxyControlled(
key="require_trace_id"
)
def test_resending_stored_require_trace_id_is_not_a_change(self):
@ -180,7 +182,9 @@ class TestTeamAdminEditVerdict:
def test_permitted_field_changed_inside_metadata_keeps_the_metadata(self):
data = UpdateTeamRequest(team_id="team-1", metadata={"guardrails": ["b"]}, team_alias="alpha")
existing = _team(team_alias="alpha", metadata={"guardrails": ["a"]})
verdict = team_admin_edit_verdict(data, existing, frozenset({"guardrails"}))
verdict = team_admin_edit_verdict(
data, existing, frozenset({"guardrails"}), proxy_controlled_metadata_keys=frozenset()
)
assert isinstance(verdict, TeamAdminEditAllowed)
assert verdict.request.model_dump(exclude_unset=True) == {
"team_id": "team-1",
@ -296,6 +300,204 @@ class TestSoftBudgetEdits:
assert verdict.request.model_dump(exclude_unset=True) == {"team_id": "team-1", "tpm_limit": 6}
_PROXY_READ_TEAM_METADATA_KEYS: Final = (
"opted_out_global_guardrails",
"disable_global_guardrails",
"guardrails",
"policies",
"tags",
"spend_logs_metadata",
"model_config",
"callback_settings",
"logging",
"priority",
"zguard_policy_id",
"team_member_key_duration",
"secret_manager_settings",
"model_rpm_limit",
"model_tpm_limit",
"mcp_rpm_limit",
"default_estimated_output_tokens",
"default_estimated_output_tokens_per_model",
"allowed_vector_store_indexes",
"enforced_file_expires_after",
"enforced_batch_output_expires_after",
"require_trace_id",
"allowed_passthrough_routes",
"denied_passthrough_routes",
"allowed_tools",
"allow_client_mock_response",
"allow_client_message_redaction_opt_out",
"allow_client_pricing_override",
"allow_litellm_changed_by_header",
"otel_service_name_override",
"otel_service_name",
"soft_budget_alerting_emails",
"team_member_max_budget_alert_emails",
"batch_enqueued_token_limit",
"max_batch_file_records",
"max_batch_file_uploads_per_day",
"max_file_downloads_per_minute",
"scim_managed",
"scim_data",
"phoenix_project_name_override",
"phoenix_project_name",
"opik",
)
_STORED_METADATA: Final = {"cost_center": "eng", "region": "eu", "priority": "high", "guardrails": ["pii"]}
def _metadata_verdict(stored: dict[str, object], sent: dict[str, object], declared: frozenset[str] = frozenset()):
return team_admin_edit_verdict(
UpdateTeamRequest(team_id="team-1", metadata=sent),
_team(metadata=stored),
_permitted("metadata"),
declared_metadata_keys=declared,
)
class TestTeamMetadataEdits:
def test_metadata_change_without_metadata_enabled_names_metadata(self):
verdict: Final = team_admin_edit_verdict(
UpdateTeamRequest(team_id="team-1", metadata={**_STORED_METADATA, "project": "x"}),
_team(metadata=_STORED_METADATA),
_permitted("tpm_limit"),
)
assert verdict == TeamAdminFieldNotPermitted(field="metadata")
@pytest.mark.parametrize(
"sent",
[
pytest.param({**_STORED_METADATA, "project": "x"}, id="add"),
pytest.param({**_STORED_METADATA, "cost_center": "sales"}, id="edit"),
pytest.param({"region": "eu", "priority": "high", "guardrails": ["pii"]}, id="delete"),
],
)
def test_ordinary_key_add_edit_and_delete_are_allowed(self, sent: dict[str, object]):
verdict: Final = _metadata_verdict(_STORED_METADATA, sent)
assert isinstance(verdict, TeamAdminEditAllowed)
assert verdict.request.metadata == sent
@pytest.mark.parametrize("key", _PROXY_READ_TEAM_METADATA_KEYS)
def test_adding_a_key_the_proxy_reads_names_it(self, key: str):
verdict: Final = _metadata_verdict(_STORED_METADATA, {**_STORED_METADATA, key: "set-by-team-admin"})
assert verdict == TeamAdminMetadataKeyProxyControlled(key=key)
@pytest.mark.parametrize("key", ["opted_out_global_guardrails", "callback_settings", "priority", "guardrails"])
def test_changing_or_removing_a_stored_proxy_controlled_key_names_it(self, key: str):
stored: Final = {**_STORED_METADATA, key: ["stored"]}
changed: Final = _metadata_verdict(stored, {**stored, key: ["changed"]})
removed: Final = _metadata_verdict(stored, {name: value for name, value in stored.items() if name != key})
assert changed == TeamAdminMetadataKeyProxyControlled(key=key)
assert removed == TeamAdminMetadataKeyProxyControlled(key=key)
def test_proxy_controlled_field_sent_top_level_names_it(self):
verdict: Final = team_admin_edit_verdict(
UpdateTeamRequest(team_id="team-1", tags=["team-admin"]), _team(metadata={}), _permitted("metadata")
)
assert verdict == TeamAdminMetadataKeyProxyControlled(key="tags")
def test_unchanged_resend_of_the_full_stored_metadata_is_not_an_edit(self):
verdict: Final = team_admin_edit_verdict(
UpdateTeamRequest(team_id="team-1", tpm_limit=6, metadata=dict(_STORED_METADATA)),
_team(metadata=_STORED_METADATA),
_permitted("tpm_limit"),
)
assert isinstance(verdict, TeamAdminEditAllowed)
assert verdict.request.model_dump(exclude_unset=True) == {"team_id": "team-1", "tpm_limit": 6}
def test_system_managed_key_left_out_is_not_a_removal(self):
stored: Final = {**_STORED_METADATA, "team_member_budget_id": "budget-1"}
verdict: Final = _metadata_verdict(stored, {**_STORED_METADATA, "project": "x"})
assert isinstance(verdict, TeamAdminEditAllowed)
def test_system_managed_key_sent_with_another_value_names_it(self):
stored: Final = {**_STORED_METADATA, "team_member_budget_id": "budget-1"}
verdict: Final = _metadata_verdict(stored, {**stored, "team_member_budget_id": "budget-2"})
assert verdict == TeamAdminMetadataKeyProxyControlled(key="team_member_budget_id")
def test_declared_key_value_edit_is_allowed(self):
verdict: Final = _metadata_verdict(
_STORED_METADATA, {**_STORED_METADATA, "cost_center": "sales"}, frozenset({"cost_center"})
)
assert isinstance(verdict, TeamAdminEditAllowed)
assert verdict.request.metadata == {**_STORED_METADATA, "cost_center": "sales"}
@pytest.mark.parametrize(
"sent",
[
pytest.param({"region": "eu", "priority": "high", "guardrails": ["pii"]}, id="removed"),
pytest.param({**_STORED_METADATA, "cost_center": None}, id="null"),
pytest.param({**_STORED_METADATA, "cost_center": ""}, id="empty"),
pytest.param({**_STORED_METADATA, "cost_center": " "}, id="blank"),
],
)
def test_clearing_a_stored_declared_key_names_it(self, sent: dict[str, object]):
verdict: Final = _metadata_verdict(_STORED_METADATA, sent, frozenset({"cost_center"}))
assert verdict == TeamAdminDeclaredMetadataKeyCleared(key="cost_center")
def test_declared_key_never_set_is_not_a_removal_when_absent(self):
verdict: Final = _metadata_verdict(
_STORED_METADATA, {**_STORED_METADATA, "project": "x"}, frozenset({"department"})
)
assert isinstance(verdict, TeamAdminEditAllowed)
@pytest.mark.parametrize(
"stored,sent",
[
pytest.param(True, 1, id="true-to-1"),
pytest.param(1, True, id="1-to-true"),
pytest.param({"a": 0}, {"a": False}, id="nested-0-to-false"),
pytest.param(2**53, 2**53 + 1, id="adjacent-integers-past-float-precision"),
pytest.param(0.1, 0.1000000000000001, id="close-floats"),
],
)
def test_changing_the_type_or_value_of_a_proxy_controlled_value_names_it(self, stored: object, sent: object):
verdict: Final = _metadata_verdict(
{"allow_client_mock_response": stored, "a": 1}, {"allow_client_mock_response": sent, "a": 2}
)
assert verdict == TeamAdminMetadataKeyProxyControlled(key="allow_client_mock_response")
@pytest.mark.parametrize(
"stored,sent",
[
pytest.param({"a": 1}, {"a": 2, "callback_settings": None}, id="add-as-null"),
pytest.param({"a": 1, "callback_settings": None}, {"a": 2}, id="remove-stored-null"),
],
)
def test_a_null_valued_proxy_controlled_key_appearing_or_disappearing_names_it(
self, stored: dict[str, object], sent: dict[str, object]
):
assert _metadata_verdict(stored, sent) == TeamAdminMetadataKeyProxyControlled(key="callback_settings")
@pytest.mark.parametrize(
"stored,sent",
[
pytest.param(5.0, 5, id="whole-number-as-int"),
pytest.param({"tiers": [1.0, {"b": 2.0}]}, {"tiers": [1, {"b": 2}]}, id="nested-whole-numbers"),
pytest.param({"x": 1, "y": {"p": "a", "q": "b"}}, {"y": {"q": "b", "p": "a"}, "x": 1}, id="key-order"),
],
)
def test_resending_an_equal_proxy_controlled_value_is_not_a_change(self, stored: object, sent: object):
verdict: Final = _metadata_verdict({"priority": stored, "a": 1}, {"priority": sent, "a": 2})
assert isinstance(verdict, TeamAdminEditAllowed)
def test_null_metadata_on_a_team_with_member_defaults_names_the_system_key(self):
verdict: Final = team_admin_edit_verdict(
UpdateTeamRequest(team_id="team-1", metadata=None),
_team(metadata={"region": "eu", "team_member_budget_id": "budget-1"}),
_permitted("metadata"),
)
assert verdict == TeamAdminMetadataKeyProxyControlled(key="team_member_budget_id")
def test_proxy_controlled_rule_wins_over_declared(self):
verdict: Final = _metadata_verdict(
_STORED_METADATA, {**_STORED_METADATA, "priority": "low"}, frozenset({"priority"})
)
assert verdict == TeamAdminMetadataKeyProxyControlled(key="priority")
class TestTeamAdminRequestOrRaise:
def test_allowed_hands_back_its_request(self):
request = UpdateTeamRequest(team_id="team-1", tpm_limit=6)
@ -308,6 +510,16 @@ class TestTeamAdminRequestOrRaise:
assert "cannot edit team settings" in exc.value.detail
assert "Settings > UI > Team admin editable fields" in exc.value.detail
@pytest.mark.parametrize(
"verdict",
[TeamAdminMetadataKeyProxyControlled(key="priority"), TeamAdminDeclaredMetadataKeyCleared(key="priority")],
)
def test_metadata_key_verdicts_are_a_403_naming_the_key(self, verdict):
with pytest.raises(HTTPException) as exc:
team_admin_request_or_raise(verdict)
assert exc.value.status_code == 403
assert "'priority'" in exc.value.detail
def test_field_not_permitted_is_a_403_naming_the_field(self):
with pytest.raises(HTTPException) as exc:
team_admin_request_or_raise(TeamAdminFieldNotPermitted(field="blocked"))

View file

@ -44,6 +44,7 @@ from litellm.proxy._types import (
)
from litellm.proxy.management.teams.authz import TeamAccess
from litellm.proxy.management_endpoints.team_admin_field_permissions import (
PROXY_CONTROLLED_TEAM_METADATA_KEYS,
SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS,
SUPPORTED_TEAM_ADMIN_PERMISSIONS,
)
@ -109,6 +110,10 @@ def _team_admin_may_edit(*fields: str):
"litellm.proxy.management_endpoints.team_endpoints.SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS",
frozenset(fields) - (SUPPORTED_TEAM_ADMIN_PERMISSIONS - SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS),
),
patch( # test-quality-ok: the block list is a module constant update_team reads directly; no seam to inject
"litellm.proxy.management_endpoints.team_endpoints.PROXY_CONTROLLED_TEAM_METADATA_KEYS",
PROXY_CONTROLLED_TEAM_METADATA_KEYS - frozenset(fields),
),
patch("litellm.proxy.proxy_server.general_settings", {"team_admin_editable_team_fields": list(fields)}), # test-quality-ok: update_team reads general_settings as a proxy_server module global
):
yield
@ -15072,7 +15077,7 @@ async def test_update_team_batch_enqueued_token_limit_raised_rejected_for_team_a
with contextlib.ExitStack() as stack:
_wire_update_team(stack, {_TEAM_BATCH_LIMIT: 100000})
stack.enter_context(_team_admin_may_edit("metadata"))
stack.enter_context(_team_admin_may_edit("metadata", _TEAM_BATCH_LIMIT))
with pytest.raises(ProxyException) as exc:
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", metadata={_TEAM_BATCH_LIMIT: 10**12}),
@ -16982,6 +16987,82 @@ 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_cannot_clear_a_declared_metadata_key():
import contextlib
from litellm.proxy.management_helpers.team_metadata_validation import (
TEAM_METADATA_SCHEMA_REGISTRY,
parse_team_metadata_schema,
)
TEAM_METADATA_SCHEMA_REGISTRY.set(parse_team_metadata_schema([{"key": "cost_center", "label": "Cost Center"}]))
try:
with contextlib.ExitStack() as stack:
prisma = _wire_update_team(stack, {"cost_center": "eng", "region": "eu"})
stack.enter_context(_team_admin_may_edit("metadata"))
with pytest.raises(ProxyException) as exc:
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", metadata={"region": "us"}),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
finally:
TEAM_METADATA_SCHEMA_REGISTRY.set(())
assert str(exc.value.code) == "403"
assert "'cost_center'" in str(exc.value.message)
assert not prisma.db.litellm_teamtable.update.called
@pytest.mark.asyncio
async def test_update_team_team_admin_cannot_change_a_proxy_controlled_metadata_key():
import contextlib
with contextlib.ExitStack() as stack:
prisma = _wire_update_team(stack, {"priority": "high", "region": "eu"})
stack.enter_context(_team_admin_may_edit("metadata"))
with pytest.raises(ProxyException) as exc:
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", metadata={"priority": "low", "region": "eu"}),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
assert str(exc.value.code) == "403"
assert "'priority'" in str(exc.value.message)
assert not prisma.db.litellm_teamtable.update.called
@pytest.mark.asyncio
async def test_update_team_team_admin_metadata_still_goes_through_the_configured_validator():
import contextlib
recorded: Final[list[object]] = []
TEAM_METADATA_VALIDATOR_REGISTRY.set(
_recording_validator(recorded, valid=False, error_message="cost center rejected, contact FinOps")
)
try:
with contextlib.ExitStack() as stack:
prisma = _wire_update_team(stack, {"cost_center": "eng"})
prisma.db.litellm_teamtable.find_unique.return_value.team_alias = "test_team"
stack.enter_context(patch("litellm.proxy.proxy_server.premium_user", True))
stack.enter_context(_team_admin_may_edit("metadata"))
with pytest.raises(ProxyException) as exc:
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", metadata={"cost_center": "BAD"}),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
finally:
TEAM_METADATA_VALIDATOR_REGISTRY.set(None)
assert str(exc.value.code) == "400"
assert "cost center rejected" in str(exc.value.message)
assert len(recorded) == 1
assert not 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,
@ -17495,28 +17576,48 @@ _MEMBER_CALLER = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_i
_ROSTER_ADMIN_CALLER,
False,
("tpm_limit",),
{"kind": "team_admin", "editable_fields": ["tpm_limit"], "may_raise_max_budget": False},
{
"kind": "team_admin",
"editable_fields": ["tpm_limit"],
"may_raise_max_budget": False,
"proxy_controlled_metadata_keys": [],
},
id="team-admin-field-enabled",
),
pytest.param(
_ROSTER_ADMIN_CALLER,
False,
("max_budget",),
{"kind": "team_admin", "editable_fields": ["max_budget"], "may_raise_max_budget": False},
{
"kind": "team_admin",
"editable_fields": ["max_budget"],
"may_raise_max_budget": False,
"proxy_controlled_metadata_keys": [],
},
id="team-admin-keep-or-lower-budget",
),
pytest.param(
_ROSTER_ADMIN_CALLER,
False,
("max_budget", "raise_max_budget"),
{"kind": "team_admin", "editable_fields": ["max_budget"], "may_raise_max_budget": True},
{
"kind": "team_admin",
"editable_fields": ["max_budget"],
"may_raise_max_budget": True,
"proxy_controlled_metadata_keys": [],
},
id="team-admin-may-raise-budget",
),
pytest.param(
_ROSTER_ADMIN_CALLER,
False,
("tpm_limit", "raise_max_budget"),
{"kind": "team_admin", "editable_fields": ["tpm_limit"], "may_raise_max_budget": False},
{
"kind": "team_admin",
"editable_fields": ["tpm_limit"],
"may_raise_max_budget": False,
"proxy_controlled_metadata_keys": [],
},
id="team-admin-raise-without-max-budget-is-inert",
),
pytest.param(_MEMBER_CALLER, False, ("tpm_limit",), {"kind": "none"}, id="plain-member"),
@ -17555,6 +17656,40 @@ async def test_team_info_reports_what_the_caller_may_edit(caller, org_admin, ena
assert response["team_info"].caller_edit_access.model_dump(mode="json") == expected
@pytest.mark.parametrize("enabled_fields,expect_keys", [(("metadata",), True), (("tpm_limit",), False)])
@pytest.mark.asyncio
async def test_team_info_lists_proxy_controlled_metadata_keys_only_when_metadata_is_editable(
enabled_fields, expect_keys
):
from fastapi import Request
from litellm.proxy.management_endpoints import team_endpoints
team_row = LiteLLM_TeamTable(team_id="team-1", members_with_roles=[Member(user_id="admin-1", role="admin")])
mock_prisma = MagicMock()
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_row)
mock_prisma.db.litellm_usertable.find_many = AsyncMock(return_value=[])
mock_prisma.get_data = AsyncMock(return_value=[])
with (
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), # test-quality-ok: no seam on team_info
patch.object( # test-quality-ok: no seam on team_info
team_endpoints, "get_all_team_memberships", AsyncMock(return_value=[])
),
_not_org_admin(),
_team_admin_may_edit(*enabled_fields),
):
response = await team_endpoints.team_info(
http_request=MagicMock(spec=Request), team_id="team-1", user_api_key_dict=_ROSTER_ADMIN_CALLER
)
access: Final = response["team_info"].caller_edit_access
assert access.kind == "team_admin"
listed: Final = set(access.proxy_controlled_metadata_keys)
assert ({"priority", "guardrails", "callback_settings"} <= listed) is expect_keys
assert bool(access.proxy_controlled_metadata_keys) is expect_keys
def test_member_budget_patch_maps_temp_budget_fields() -> None:
from litellm.proxy.management_endpoints.common_utils import member_budget_patch

View file

@ -155,6 +155,23 @@ describe("TeamAdminEditableFieldsSettings", () => {
expect(mutate).toHaveBeenCalledWith({ team_admin_editable_team_fields: ["soft_budget"] }, expect.anything());
});
it("offers team metadata as a checkbox that explains which keys stay with proxy admins", async () => {
const user = userEvent.setup();
mockSettings(["max_budget", "metadata"], []);
const mutate = mockSave({});
renderWithProviders(<TeamAdminEditableFieldsSettings />);
await user.hover(screen.getByRole("button", { name: "About editing team metadata" }));
expect(await screen.findByText(/Keys the proxy reads as settings stay with proxy admins/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("checkbox", { name: "Team Metadata" }));
fireEvent.click(saveButton());
await waitFor(() => expect(mutate).toHaveBeenCalledTimes(1));
expect(mutate).toHaveBeenCalledWith({ team_admin_editable_team_fields: ["metadata"] }, expect.anything());
});
it("disables Save again when the draft is ticked back to the saved list", () => {
mockSettings(["tpm_limit"], []);
mockSave({});

View file

@ -11,6 +11,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import {
parseSupportedTeamAdminEditableFields,
parseTeamAdminEditableFields,
TEAM_ADMIN_METADATA_FIELD,
TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION,
teamAdminFieldLabel,
type TeamAdminSettingsField,
@ -34,7 +35,10 @@ const MAX_BUDGET_FIELD: TeamAdminSettingsField = "max_budget";
const RAISE_MAX_BUDGET_HELP_LABEL = "About raising the team's max budget";
const RAISE_MAX_BUDGET_TOOLTIP =
"Lets team admins raise the budget too, capped by the organization's budget when the team has one. Only proxy admins can remove it.";
const RAISE_MAX_BUDGET_DOCS_URL =
const METADATA_HELP_LABEL = "About editing team metadata";
const METADATA_TOOLTIP =
"Lets team admins add, change and remove their own metadata keys. Keys the proxy reads as settings stay with proxy admins, and keys declared in the team metadata schema can be changed but not removed.";
const TEAM_ADMIN_FIELDS_DOCS_URL =
"https://docs.litellm.ai/docs/proxy/access_control#choosing-what-team-admins-can-edit";
const toggleEditableField = (
@ -146,6 +150,11 @@ function TeamAdminEditableFieldsForm({
checked={field.value.includes(name)}
disabled={isPending}
onCheckedChange={toggle(name)}
help={
name === TEAM_ADMIN_METADATA_FIELD ? (
<FieldHelp label={METADATA_HELP_LABEL} text={METADATA_TOOLTIP} />
) : undefined
}
/>
{name === MAX_BUDGET_FIELD && raiseMaxBudgetSupported && (
<EditableFieldCheckbox
@ -154,7 +163,7 @@ function TeamAdminEditableFieldsForm({
disabled={isPending || !field.value.includes(MAX_BUDGET_FIELD)}
onCheckedChange={toggle(TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION)}
className="ml-6"
help={<RaiseMaxBudgetHelp />}
help={<FieldHelp label={RAISE_MAX_BUDGET_HELP_LABEL} text={RAISE_MAX_BUDGET_TOOLTIP} />}
/>
)}
</Fragment>
@ -201,21 +210,26 @@ function EditableFieldCheckbox({
);
}
function RaiseMaxBudgetHelp() {
interface FieldHelpProps {
label: string;
text: string;
}
function FieldHelp({ label, text }: FieldHelpProps) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
type="button"
aria-label={RAISE_MAX_BUDGET_HELP_LABEL}
aria-label={label}
className="inline-flex cursor-help items-center rounded-sm text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<CircleHelp className="size-3.5" />
</TooltipTrigger>
<TooltipContent>
<span>
{RAISE_MAX_BUDGET_TOOLTIP}{" "}
<a href={RAISE_MAX_BUDGET_DOCS_URL} target="_blank" rel="noopener noreferrer" className="underline">
{text}{" "}
<a href={TEAM_ADMIN_FIELDS_DOCS_URL} target="_blank" rel="noopener noreferrer" className="underline">
Learn more
</a>
</span>

View file

@ -3,11 +3,11 @@ import userEvent from "@testing-library/user-event";
import { chooseSelectOption, fireEvent, renderWithProviders, screen, waitFor } from "@/../tests/test-utils";
import TeamAdminSettingsForm from "./TeamAdminSettingsForm";
import TeamAdminSettingsForm, { type TeamAdminMetadataSettings } from "./TeamAdminSettingsForm";
const renderForm = (
editableFields: ReadonlySet<string>,
overrides: { isSaving?: boolean; mayRaiseMaxBudget?: boolean } = {},
overrides: { isSaving?: boolean; mayRaiseMaxBudget?: boolean; metadata?: TeamAdminMetadataSettings } = {},
) => {
const onSave = vi.fn().mockResolvedValue(undefined);
const onCancel = vi.fn();
@ -23,6 +23,7 @@ const renderForm = (
team_member_tpm_limit: 100,
team_member_rpm_limit: 5,
}}
metadata={overrides.metadata}
editableFields={editableFields}
mayRaiseMaxBudget={overrides.mayRaiseMaxBudget ?? false}
isSaving={overrides.isSaving ?? false}
@ -193,4 +194,110 @@ describe("TeamAdminSettingsForm", () => {
expect(screen.queryByText(/this budget/)).not.toBeInTheDocument();
});
describe("team metadata", () => {
const metadata: TeamAdminMetadataSettings = {
pairs: [
{ key: "cost_center", value: "eng" },
{ key: "region", value: "eu" },
],
context: {
storedMetadata: { cost_center: "eng", region: "eu", priority: "high" },
proxyControlledKeys: new Set(["priority"]),
declaredKeys: new Set(["cost_center"]),
},
schemaFields: [{ key: "cost_center", label: "Cost Center" }],
schemaLoading: false,
};
it("shows the declared field without a remove control and the team's own keys with one", () => {
renderForm(new Set(["metadata"]), { metadata });
expect(screen.getByLabelText("Cost Center")).toHaveValue("eng");
expect(screen.getByDisplayValue("region")).toBeInTheDocument();
expect(screen.queryByDisplayValue("priority")).not.toBeInTheDocument();
expect(screen.getAllByRole("button", { name: "Remove key-value pair" })).toHaveLength(1);
});
it("saves a new key with the proxy's own keys carried over", async () => {
const user = userEvent.setup();
const { onSave } = renderForm(new Set(["metadata"]), { metadata });
await user.click(screen.getByRole("button", { name: /add key-value pair/i }));
const keys = screen.getAllByPlaceholderText("Key");
const values = screen.getAllByPlaceholderText("Value");
await user.type(keys[keys.length - 1], "project");
await user.type(values[values.length - 1], "atlas");
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() =>
expect(onSave).toHaveBeenCalledWith({
metadata: { cost_center: "eng", region: "eu", project: "atlas", priority: "high" },
}),
);
});
it("refuses to save a key the proxy keeps for proxy admins", async () => {
const user = userEvent.setup();
const { onSave } = renderForm(new Set(["metadata"]), { metadata });
await user.click(screen.getByRole("button", { name: /add key-value pair/i }));
const keys = screen.getAllByPlaceholderText("Key");
await user.type(keys[keys.length - 1], "priority");
await user.click(screen.getByRole("button", { name: /save changes/i }));
expect(await screen.findByText("Only proxy admins can set this key")).toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
});
it("refuses to save an emptied declared field", async () => {
const user = userEvent.setup();
const { onSave } = renderForm(new Set(["metadata"]), { metadata });
await user.clear(screen.getByLabelText("Cost Center"));
await user.click(screen.getByRole("button", { name: /save changes/i }));
expect(await screen.findByText("This field cannot be left empty")).toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
});
it("waits for the metadata schema before showing the form, then enforces the declared field", async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
const loading: TeamAdminMetadataSettings = {
...metadata,
context: { ...metadata.context, declaredKeys: new Set() },
schemaFields: [],
schemaLoading: true,
};
const form = (settings: TeamAdminMetadataSettings) => (
<TeamAdminSettingsForm
initialValues={{}}
metadata={settings}
editableFields={new Set(["metadata"])}
mayRaiseMaxBudget={false}
isSaving={false}
onCancel={vi.fn()}
onSave={onSave}
/>
);
const { rerender } = renderWithProviders(form(loading));
expect(screen.queryByRole("button", { name: /save changes/i })).not.toBeInTheDocument();
rerender(form(metadata));
await user.clear(await screen.findByLabelText("Cost Center"));
await user.click(screen.getByRole("button", { name: /save changes/i }));
expect(await screen.findByText("This field cannot be left empty")).toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
});
it("hides the metadata section when the proxy has not enabled it", () => {
renderForm(new Set(["tpm_limit"]), { metadata });
expect(screen.queryByLabelText("Cost Center")).not.toBeInTheDocument();
expect(screen.getByLabelText("Tokens per minute Limit (TPM)")).toBeInTheDocument();
});
});
});

View file

@ -1,19 +1,30 @@
"use client";
import { Save } from "lucide-react";
import { useState } from "react";
import { useWatch } from "react-hook-form";
import { z } from "zod";
import type { TeamMetadataField } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { FieldGroup } from "@/components/ui/field";
import { Field, FieldDescription, FieldGroup, FieldLabel } 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 MetadataKeyValueFields, {
type MetadataPair,
metadataPairsSchema,
} from "../common_components/MetadataKeyValueFields";
import NumericalInput from "../shared/numerical_input";
import {
isTeamAdminDurationSettingsField,
TEAM_ADMIN_METADATA_FIELD,
type TeamAdminMetadataContext,
teamAdminMetadataChange,
teamAdminMetadataPairIssue,
TEAM_ADMIN_SETTINGS_FIELDS,
teamAdminFieldLabel,
teamAdminSettingsChanges,
@ -35,7 +46,26 @@ const teamAdminSettingsShape = {
team_member_rpm_limit: numericInputSchema,
};
const teamAdminSettingsSchema = z.object(teamAdminSettingsShape);
const teamAdminSettingsSchemaFor = (metadataContext: TeamAdminMetadataContext | undefined) =>
z.object({
...teamAdminSettingsShape,
metadata: metadataPairsSchema
.superRefine((pairs, ctx) => {
if (metadataContext === undefined) return;
pairs.forEach((pair, index) => {
const issue = teamAdminMetadataPairIssue(pair, metadataContext);
if (issue) ctx.addIssue({ code: "custom", message: issue.message, path: [index, issue.path] });
});
})
.optional(),
});
export interface TeamAdminMetadataSettings {
readonly pairs: readonly MetadataPair[];
readonly context: TeamAdminMetadataContext;
readonly schemaFields: readonly TeamMetadataField[];
readonly schemaLoading: boolean;
}
const INPUT_STEP: Readonly<Record<Exclude<TeamAdminSettingsField, "team_member_budget_duration">, number>> = {
tpm_limit: 1,
@ -63,6 +93,7 @@ const durationSelectValue = (value: string | number | null | undefined): string
interface TeamAdminSettingsFormProps {
initialValues: TeamAdminSettingsValues;
metadata?: TeamAdminMetadataSettings;
editableFields: ReadonlySet<string>;
mayRaiseMaxBudget: boolean;
isSaving: boolean;
@ -70,18 +101,38 @@ interface TeamAdminSettingsFormProps {
onSave: (changes: TeamAdminSettingsChanges) => Promise<void>;
}
export default function TeamAdminSettingsForm({
export default function TeamAdminSettingsForm(props: TeamAdminSettingsFormProps) {
const waitsForMetadataSchema =
props.metadata?.schemaLoading === true && props.editableFields.has(TEAM_ADMIN_METADATA_FIELD);
if (waitsForMetadataSchema) {
return (
<div className="flex justify-center py-6">
<UiLoadingSpinner className="size-5" />
</div>
);
}
return <TeamAdminSettingsFormFields {...props} />;
}
function TeamAdminSettingsFormFields({
initialValues,
metadata,
editableFields,
mayRaiseMaxBudget,
isSaving,
onCancel,
onSave,
}: TeamAdminSettingsFormProps) {
const form = useZodForm(teamAdminSettingsSchema, { defaultValues: initialValues });
const editsMetadata = metadata !== undefined && editableFields.has(TEAM_ADMIN_METADATA_FIELD);
const [schema] = useState(() => teamAdminSettingsSchemaFor(editsMetadata ? metadata.context : undefined));
const form = useZodForm(schema, { defaultValues: { ...initialValues, metadata: [...(metadata?.pairs ?? [])] } });
const draft = useWatch({ control: form.control });
const hasChanges = Object.keys(teamAdminSettingsChanges(draft, initialValues, editableFields)).length > 0;
const submit = form.handleSubmit((values) => onSave(teamAdminSettingsChanges(values, initialValues, editableFields)));
const changesFrom = (values: typeof draft): TeamAdminSettingsChanges => ({
...teamAdminSettingsChanges(values, initialValues, editableFields),
...(editsMetadata ? teamAdminMetadataChange(values.metadata, metadata.context) : {}),
});
const hasChanges = Object.keys(changesFrom(draft)).length > 0;
const submit = form.handleSubmit((values) => onSave(changesFrom(values)));
return (
<form onSubmit={(event) => void submit(event)}>
@ -119,6 +170,23 @@ export default function TeamAdminSettingsForm({
}
</FormField>
))}
{editsMetadata && (
<Field>
<FieldLabel>{teamAdminFieldLabel(TEAM_ADMIN_METADATA_FIELD)}</FieldLabel>
<MetadataKeyValueFields
control={form.control}
getValues={form.getValues}
name="metadata"
schemaFields={metadata.schemaFields}
schemaLoading={metadata.schemaLoading}
/>
<FieldDescription>
{
'A value that is valid JSON, like 3, true or {"region": "us"}, is saved as that type, anything else as text. Keys the proxy reads as settings stay with proxy admins and are kept as they are.'
}
</FieldDescription>
</Field>
)}
</FieldGroup>
<div className="mt-6 flex items-center justify-end gap-2">

View file

@ -2047,6 +2047,82 @@ describe("TeamInfoView", () => {
expect(screen.getByRole("button", { name: /save changes/i })).toBeDisabled();
});
it("saves a team admin's metadata edit with the proxy's own keys carried over", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(useTeamMetadataSchema).mockReturnValue({
data: [{ key: "cost_center", label: "Cost Center" }],
isLoading: false,
} as any);
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({
metadata: { cost_center: "eng", priority: "high" },
caller_edit_access: {
kind: "team_admin",
editable_fields: ["metadata"],
proxy_controlled_metadata_keys: ["priority"],
},
}),
);
renderWithProviders(<TeamInfoView {...teamAdminProps} />);
await user.click(await screen.findByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
const costCenter = await screen.findByLabelText("Cost Center");
expect(costCenter).toHaveValue("eng");
expect(screen.queryByDisplayValue("priority")).not.toBeInTheDocument();
await user.clear(costCenter);
await user.type(costCenter, "sales");
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalled());
expect(vi.mocked(networking.teamUpdateCall).mock.calls[0][1]).toMatchObject({
metadata: { cost_center: "sales", priority: "high" },
});
});
it("saves other fields when a declared metadata field is also a proxy setting", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(useTeamMetadataSchema).mockReturnValue({
data: [
{ key: "cost_center", label: "Cost Center" },
{ key: "priority", label: "Priority" },
],
isLoading: false,
} as any);
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({
tpm_limit: 1000,
metadata: { cost_center: "eng", priority: "high" },
caller_edit_access: {
kind: "team_admin",
editable_fields: ["metadata", "tpm_limit"],
proxy_controlled_metadata_keys: ["priority"],
},
}),
);
renderWithProviders(<TeamInfoView {...teamAdminProps} />);
await user.click(await screen.findByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
expect(screen.queryByLabelText("Priority")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /save changes/i })).toBeDisabled();
const tpm = screen.getByLabelText("Tokens per minute Limit (TPM)");
await user.clear(tpm);
await user.type(tpm, "2000");
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalled());
const sent = vi.mocked(networking.teamUpdateCall).mock.calls[0][1];
expect(sent).toMatchObject({ tpm_limit: 2000 });
expect(sent).not.toHaveProperty("metadata");
});
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(

View file

@ -56,6 +56,8 @@ import {
parseTeamEditAccess,
TEAM_ADMIN_EDITING_DISABLED_DESCRIPTION,
TEAM_ADMIN_EDITING_DISABLED_TITLE,
teamAdminDeclaredFields,
teamAdminMetadataPairs,
type TeamAdminSettingsChanges,
} from "./teamAdminEditAccess";
import TeamAdminSettingsForm from "./TeamAdminSettingsForm";
@ -1305,6 +1307,11 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const { team_info: info } = teamData;
const teamAdminSchemaFields =
teamEditAccess.kind === "team_admin"
? teamAdminDeclaredFields(teamMetadataSchemaFields, teamEditAccess.proxyControlledMetadataKeys)
: [];
const teamAdminDeclaredKeys = teamAdminSchemaFields.map((field) => field.key);
const teamAdminSettingsEditor =
teamEditAccess.kind === "team_admin" ? (
<TeamAdminSettingsForm
@ -1318,6 +1325,20 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
team_member_tpm_limit: info.team_member_budget_table?.tpm_limit,
team_member_rpm_limit: info.team_member_budget_table?.rpm_limit,
}}
metadata={{
pairs: teamAdminMetadataPairs(
info.metadata,
teamEditAccess.proxyControlledMetadataKeys,
teamAdminDeclaredKeys,
),
context: {
storedMetadata: info.metadata ?? {},
proxyControlledKeys: teamEditAccess.proxyControlledMetadataKeys,
declaredKeys: new Set(teamAdminDeclaredKeys),
},
schemaFields: teamAdminSchemaFields,
schemaLoading: isTeamMetadataSchemaLoading,
}}
editableFields={teamEditAccess.editableFields}
mayRaiseMaxBudget={teamEditAccess.mayRaiseMaxBudget}
isSaving={isTeamSaving}

View file

@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest";
import {
type TeamAdminMetadataContext,
teamAdminDeclaredFields,
teamAdminMetadataChange,
teamAdminMetadataPairIssue,
teamAdminMetadataPairs,
parseSupportedTeamAdminEditableFields,
parseTeamAdminEditableFields,
parseTeamEditAccess,
@ -14,6 +19,7 @@ describe("teamAdminFieldLabel", () => {
["rpm_limit", "Requests per minute Limit (RPM)"],
["max_budget", "Max Budget (USD)"],
["soft_budget", "Soft Budget (USD)"],
["metadata", "Team Metadata"],
["raise_max_budget", "Raise the team's max budget"],
["projects", "Create and update projects"],
["team_member_budget", "Member Default Budget (USD)"],
@ -213,11 +219,14 @@ describe("parseTeamEditAccess", () => {
});
it("hands a team admin the fields the proxy enabled", () => {
expect(parseTeamEditAccess({ kind: "team_admin", editable_fields: ["tpm_limit"] })).toEqual({
const expected = {
kind: "team_admin",
editableFields: new Set(["tpm_limit"]),
mayRaiseMaxBudget: false,
});
proxyControlledMetadataKeys: new Set(),
};
expect(parseTeamEditAccess({ kind: "team_admin", editable_fields: ["tpm_limit"] })).toEqual(expected);
});
it.each([
@ -226,10 +235,26 @@ describe("parseTeamEditAccess", () => {
["an older proxy omits the flag", {}, false],
["the flag is not a boolean", { may_raise_max_budget: "yes" }, false],
])("lets a team admin raise the max budget only when %s", (_label, flag, mayRaiseMaxBudget) => {
expect(parseTeamEditAccess({ kind: "team_admin", editable_fields: ["max_budget"], ...flag })).toEqual({
const expected = {
kind: "team_admin",
editableFields: new Set(["max_budget"]),
mayRaiseMaxBudget,
proxyControlledMetadataKeys: new Set(),
};
expect(parseTeamEditAccess({ kind: "team_admin", editable_fields: ["max_budget"], ...flag })).toEqual(expected);
});
it("hands a team admin the metadata keys the proxy keeps for proxy admins", () => {
const access = parseTeamEditAccess({
kind: "team_admin",
editable_fields: ["metadata"],
proxy_controlled_metadata_keys: ["guardrails", "priority"],
});
expect(access).toMatchObject({
kind: "team_admin",
proxyControlledMetadataKeys: new Set(["guardrails", "priority"]),
});
});
@ -242,3 +267,101 @@ describe("parseTeamEditAccess", () => {
expect(parseTeamEditAccess(value)).toEqual({ kind: "none" });
});
});
describe("teamAdminMetadataChange", () => {
const context: TeamAdminMetadataContext = {
storedMetadata: { cost_center: "eng", region: "eu", priority: "high", team_member_budget_id: "budget-1" },
proxyControlledKeys: new Set(["priority", "team_member_budget_id", "guardrails"]),
declaredKeys: new Set(["cost_center", "department"]),
};
const storedPairs = [
{ key: "cost_center", value: "eng" },
{ key: "region", value: "eu" },
];
it("sends nothing when the editable keys are unchanged", () => {
expect(teamAdminMetadataChange(storedPairs, context)).toStrictEqual({});
});
it("sends the full metadata with the proxy settings carried over when a key is added", () => {
expect(teamAdminMetadataChange([...storedPairs, { key: "project", value: "atlas" }], context)).toStrictEqual({
metadata: {
cost_center: "eng",
region: "eu",
project: "atlas",
priority: "high",
team_member_budget_id: "budget-1",
},
});
});
it("sends the full metadata without a deleted key", () => {
expect(teamAdminMetadataChange([{ key: "cost_center", value: "eng" }], context)).toStrictEqual({
metadata: { cost_center: "eng", priority: "high", team_member_budget_id: "budget-1" },
});
});
it("leaves out an empty declared field the team never set", () => {
expect(teamAdminMetadataChange([...storedPairs, { key: "department", value: "" }], context)).toStrictEqual({});
});
});
describe("teamAdminMetadataPairIssue", () => {
const context: TeamAdminMetadataContext = {
storedMetadata: { cost_center: "eng" },
proxyControlledKeys: new Set(["priority"]),
declaredKeys: new Set(["cost_center", "department"]),
};
it("flags a key the proxy keeps for proxy admins", () => {
expect(teamAdminMetadataPairIssue({ key: "priority", value: "low" }, context)?.path).toBe("key");
});
it("flags emptying a declared field the team has set", () => {
expect(teamAdminMetadataPairIssue({ key: "cost_center", value: "" }, context)?.path).toBe("value");
});
it.each([
["JSON null", "null"],
["a JSON empty string", '""'],
["only spaces", " "],
])("flags a declared field the team has set changed to %s", (_label, value) => {
expect(teamAdminMetadataPairIssue({ key: "cost_center", value }, context)?.path).toBe("value");
});
it("accepts a declared field the proxy admin stored empty, resent as it is", () => {
const storedEmpty = { ...context, storedMetadata: { cost_center: "" } };
expect(teamAdminMetadataPairIssue({ key: "cost_center", value: "" }, storedEmpty)).toBeNull();
});
it.each([
["an ordinary key", { key: "project", value: "" }],
["a declared field the team never set", { key: "department", value: "" }],
["a declared field with a new value", { key: "cost_center", value: "sales" }],
])("accepts %s", (_label, pair) => {
expect(teamAdminMetadataPairIssue(pair, context)).toBeNull();
});
});
describe("teamAdminMetadataPairs", () => {
it("lists declared fields first in schema order, then the team's own keys, without the proxy's keys", () => {
const metadata = { region: "eu", priority: "high", department: "r&d", cost_center: "eng" };
const pairs = teamAdminMetadataPairs(metadata, new Set(["priority"]), ["cost_center", "department"]);
expect(pairs.map((pair) => pair.key)).toStrictEqual(["cost_center", "department", "region"]);
});
});
describe("teamAdminDeclaredFields", () => {
it("leaves out declared fields that are also proxy settings", () => {
const fields = [
{ key: "cost_center", label: "Cost Center" },
{ key: "priority", label: "Priority" },
];
expect(teamAdminDeclaredFields(fields, new Set(["priority"]))).toStrictEqual([
{ key: "cost_center", label: "Cost Center" },
]);
});
});

View file

@ -1,23 +1,34 @@
import { z } from "zod";
import { numberOrNull } from "@/lib/forms/numberOrNull";
import {
type MetadataPair,
metadataObjectToPairs,
metadataPairsToObject,
} from "../common_components/MetadataKeyValueFields";
export const TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING = "team_admin_editable_team_fields";
export const TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION = "raise_max_budget";
export const TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION = "create_member_keys";
export const TEAM_ADMIN_METADATA_FIELD = "metadata";
export const TEAM_ADMIN_EDITING_DISABLED_TITLE = "Team admins cannot edit team settings on this proxy";
export const TEAM_ADMIN_EDITING_DISABLED_DESCRIPTION =
"Ask a proxy admin to enable fields under Settings > UI > Team admin editable fields.";
const teamAdminEditAccessShape = {
kind: z.literal("team_admin"),
editable_fields: z.array(z.string()),
may_raise_max_budget: z.boolean().catch(false),
proxy_controlled_metadata_keys: z.array(z.string()).catch([]),
};
const callerEditAccessSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("unrestricted") }),
z.object({
kind: z.literal("team_admin"),
editable_fields: z.array(z.string()),
may_raise_max_budget: z.boolean().catch(false),
}),
z.object(teamAdminEditAccessShape),
z.object({ kind: z.literal("team_admin_disabled") }),
z.object({ kind: z.literal("none") }),
]);
@ -26,7 +37,12 @@ export type CallerEditAccess = z.infer<typeof callerEditAccessSchema>;
export type TeamEditAccess =
| { readonly kind: "unrestricted" }
| { readonly kind: "team_admin"; readonly editableFields: ReadonlySet<string>; readonly mayRaiseMaxBudget: boolean }
| {
readonly kind: "team_admin";
readonly editableFields: ReadonlySet<string>;
readonly mayRaiseMaxBudget: boolean;
readonly proxyControlledMetadataKeys: ReadonlySet<string>;
}
| { readonly kind: "team_admin_disabled" }
| { readonly kind: "none" };
@ -73,6 +89,7 @@ const TEAM_ADMIN_FIELD_LABELS: ReadonlyMap<string, string> = new Map([
["tpm_limit", "Tokens per minute Limit (TPM)"],
["rpm_limit", "Requests per minute Limit (RPM)"],
["max_budget", "Max Budget (USD)"],
[TEAM_ADMIN_METADATA_FIELD, "Team Metadata"],
["soft_budget", "Soft Budget (USD)"],
["team_member_budget", "Member Default Budget (USD)"],
["team_member_budget_duration", "Member Default Budget Duration"],
@ -86,11 +103,13 @@ const TEAM_ADMIN_FIELD_LABELS: ReadonlyMap<string, string> = new Map([
export const teamAdminFieldLabel = (field: string): string => TEAM_ADMIN_FIELD_LABELS.get(field) ?? field;
export type TeamAdminSettingsValues = { readonly [F in TeamAdminSettingsField]?: string | number | null };
export type TeamAdminSettingsValues = { readonly [F in TeamAdminNumericSettingsField]?: string | number | null } & {
readonly [F in TeamAdminDurationSettingsField]?: string | null;
};
export type TeamAdminSettingsChanges = { readonly [F in TeamAdminNumericSettingsField]?: number | null } & {
readonly [F in TeamAdminDurationSettingsField]?: string | null;
};
} & { readonly metadata?: Record<string, unknown> };
const durationOrNull = (value: string | number | null | undefined): string | null =>
typeof value === "string" && value !== "" ? value : null;
@ -129,7 +148,81 @@ export const parseTeamEditAccess = (callerEditAccess: unknown): TeamEditAccess =
kind: "team_admin",
editableFields: new Set(parsed.data.editable_fields),
mayRaiseMaxBudget: parsed.data.may_raise_max_budget,
proxyControlledMetadataKeys: new Set(parsed.data.proxy_controlled_metadata_keys),
};
}
return parsed.data;
};
export interface TeamAdminMetadataContext {
readonly storedMetadata: Readonly<Record<string, unknown>>;
readonly proxyControlledKeys: ReadonlySet<string>;
readonly declaredKeys: ReadonlySet<string>;
}
const canonicalJson = (value: unknown): string =>
JSON.stringify(value, (_key, nested: unknown) =>
nested !== null && typeof nested === "object" && !Array.isArray(nested)
? Object.fromEntries(Object.entries(nested).sort(([a], [b]) => a.localeCompare(b)))
: nested,
);
const pairValue = (pair: Partial<MetadataPair> | undefined): unknown =>
metadataPairsToObject([{ key: "value", value: pair?.value ?? "" }]).value;
const isBlankString = (value: unknown): boolean => typeof value === "string" && value.trim() === "";
const isBlank = (value: unknown): boolean => value === null || value === undefined || isBlankString(value);
const isUnsetDeclaredPair = (pair: Partial<MetadataPair> | undefined, context: TeamAdminMetadataContext): boolean => {
const key = pair?.key;
if (key === undefined || !context.declaredKeys.has(key)) return false;
return !(key in context.storedMetadata) && isBlank(pairValue(pair));
};
export const teamAdminDeclaredFields = <Field extends { readonly key: string }>(
schemaFields: readonly Field[],
proxyControlledKeys: ReadonlySet<string>,
): Field[] => schemaFields.filter((field) => !proxyControlledKeys.has(field.key));
export const teamAdminMetadataPairs = (
metadata: Readonly<Record<string, unknown>> | null | undefined,
proxyControlledKeys: ReadonlySet<string>,
declaredKeys: readonly string[],
): MetadataPair[] => {
const pairs = metadataObjectToPairs(metadata, proxyControlledKeys);
const declaredRank = (key: string): number => {
const rank = declaredKeys.indexOf(key);
return rank === -1 ? declaredKeys.length : rank;
};
return pairs.toSorted((a, b) => declaredRank(a.key) - declaredRank(b.key));
};
export const teamAdminMetadataChange = (
pairs: readonly (Partial<MetadataPair> | undefined)[] | undefined,
context: TeamAdminMetadataContext,
): { readonly metadata?: Record<string, unknown> } => {
const edited = metadataPairsToObject((pairs ?? []).filter((pair) => !isUnsetDeclaredPair(pair, context)));
const proxyControlled = Object.entries(context.storedMetadata).filter(([key]) =>
context.proxyControlledKeys.has(key),
);
const metadata = { ...Object.fromEntries(proxyControlled), ...edited };
return canonicalJson(metadata) === canonicalJson(context.storedMetadata) ? {} : { metadata };
};
export interface TeamAdminMetadataPairIssue {
readonly path: "key" | "value";
readonly message: string;
}
export const teamAdminMetadataPairIssue = (
pair: Partial<MetadataPair> | undefined,
context: TeamAdminMetadataContext,
): TeamAdminMetadataPairIssue | null => {
if (pair?.key === undefined) return null;
if (context.proxyControlledKeys.has(pair.key)) return { path: "key", message: "Only proxy admins can set this key" };
if (!context.declaredKeys.has(pair.key) || !(pair.key in context.storedMetadata)) return null;
const value = pairValue(pair);
const clearsStoredValue = isBlank(value) && canonicalJson(value) !== canonicalJson(context.storedMetadata[pair.key]);
return clearsStoredValue ? { path: "value", message: "This field cannot be left empty" } : null;
};