mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-15 04:19:08 +00:00
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:
parent
01cec8f938
commit
9271edc734
14 changed files with 1120 additions and 37 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue