Merge pull request #39996 from BerriAI/litellm_team_admin_editable_fields

feat(proxy): let proxy admins choose which team fields team admins may edit
This commit is contained in:
ryan-crabbe-berri 2026-09-16 17:07:13 -07:00 • committed by GitHub
commit 43713f7508
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2428 additions and 264 deletions

View file

@ -844,6 +844,9 @@ class LiteLLMRoutes(enum.Enum):
)
self_managed_routes = [
# update_team resolves proxy/org/team admin itself and filters team admins
# through the team_admin_editable_team_fields setting
"/team/update",
"/team/member_add",
"/team/member_delete",
"/management/v1/teams/{team_id}/members/bulk_delete",
@ -4467,6 +4470,29 @@ class TeamInfoMember(Member):
user_alias: str | None = None
class TeamEditUnrestricted(BaseModel):
kind: Literal["unrestricted"] = "unrestricted"
class TeamEditAsTeamAdmin(BaseModel):
kind: Literal["team_admin"] = "team_admin"
editable_fields: tuple[str, ...]
class TeamEditAsTeamAdminDisabled(BaseModel):
kind: Literal["team_admin_disabled"] = "team_admin_disabled"
class TeamEditNone(BaseModel):
kind: Literal["none"] = "none"
TeamEditAccess = Annotated[
TeamEditUnrestricted | TeamEditAsTeamAdmin | TeamEditAsTeamAdminDisabled | TeamEditNone,
Field(discriminator="kind"),
]
class TeamInfoResponseObjectTeamTable(LiteLLM_TeamTable):
members_with_roles: tuple[TeamInfoMember, ...] = ()
team_member_budget_table: LiteLLM_BudgetTableFull | None = None
@ -4479,6 +4505,7 @@ class TeamInfoResponseObjectTeamTable(LiteLLM_TeamTable):
# None = no org or not a manager; [] or ["all-proxy-models"] = no ceiling.
organization_models: list[str] | None = None
model_max_budget_usage: Mapping[str, Mapping[str, object]] | None = None
caller_edit_access: TeamEditAccess = Field(default_factory=TeamEditNone)
class TeamInfoResponseObject(TypedDict):

View file

@ -0,0 +1,191 @@
"""Proxy-wide allow-list of team-settings fields a team admin may change on /team/update."""
from collections.abc import Mapping
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final, Literal, TypeAlias
from fastapi import HTTPException
from pydantic import TypeAdapter, ValidationError
from typing_extensions import assert_never
from litellm._logging import verbose_proxy_logger
from litellm.models.team import LiteLLM_TeamTable
from litellm.proxy._types import (
LiteLLM_ManagementEndpoint_MetadataFields,
LiteLLM_ManagementEndpoint_MetadataFields_Premium,
UpdateTeamRequest,
)
TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING: Final = "team_admin_editable_team_fields"
# TODO(LIT-5722): add the remaining team settings one per PR, each with its value-diff tests and dashboard field
SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS: Final[frozenset[str]] = frozenset({"tpm_limit"})
_FIELD_LIST: Final = TypeAdapter(list[str])
_JSON_OBJECT: Final = TypeAdapter(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"})
_NOT_COLUMNS: Final[frozenset[str]] = frozenset({"team_id", "metadata"})
_SETTINGS_LOCATION: Final = "Settings > UI > Team admin editable fields"
@dataclass(frozen=True, slots=True)
class TeamAdminEditAllowed:
request: UpdateTeamRequest
kind: Literal["allowed"] = "allowed"
@dataclass(frozen=True, slots=True)
class TeamAdminEditingDisabled:
kind: Literal["disabled"] = "disabled"
@dataclass(frozen=True, slots=True)
class TeamAdminFieldNotPermitted:
field: str
kind: Literal["field_not_permitted"] = "field_not_permitted"
TeamAdminEditVerdict: TypeAlias = TeamAdminEditAllowed | TeamAdminEditingDisabled | TeamAdminFieldNotPermitted
def resolve_team_admin_editable_fields(
general_settings: Mapping[str, object],
supported: frozenset[str],
) -> frozenset[str]:
raw: Final = general_settings.get(TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING)
if raw is None:
return frozenset()
try:
configured: Final = frozenset(_FIELD_LIST.validate_python(raw))
except ValidationError:
verbose_proxy_logger.warning(
"%s must be a list of field names; ignoring %r", TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING, raw
)
return frozenset()
unsupported: Final = configured - supported
if unsupported:
verbose_proxy_logger.warning(
"%s ignores unsupported field(s) %s; supported: %s",
TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING,
sorted(unsupported),
sorted(supported),
)
return configured & supported
def _as_object(value: object) -> Mapping[str, object]:
try:
return _JSON_OBJECT.validate_json(value) if isinstance(value, str) else _JSON_OBJECT.validate_python(value)
except ValidationError:
return _EMPTY
def _stored_metadata(existing: Mapping[str, object]) -> Mapping[str, object]:
return _as_object(existing.get("metadata"))
def _submitted_metadata(
data: UpdateTeamRequest, submitted: Mapping[str, object], existing: Mapping[str, object]
) -> Mapping[str, object]:
"""Metadata as it would be stored: the caller's dict (or the stored one) with top-level folded fields laid over."""
base: Final = (
_as_object(submitted.get("metadata")) if "metadata" in data.model_fields_set else _stored_metadata(existing)
)
folded: Final = data.model_fields_set & _METADATA_FOLDED_FIELDS
return MappingProxyType({key: submitted[key] if key in folded else base[key] for key in base.keys() | folded})
def _metadata_changes(
data: UpdateTeamRequest, submitted: Mapping[str, object], existing: Mapping[str, object]
) -> frozenset[str]:
merged: Final = _submitted_metadata(data, submitted, existing)
stored: Final = _stored_metadata(existing)
return frozenset(
key if key in _METADATA_FOLDED_FIELDS else "metadata"
for key in (merged.keys() | stored.keys()) - _SYSTEM_MANAGED_METADATA_KEYS
if merged.get(key) != stored.get(key)
)
def _stored_model_aliases(existing_row: LiteLLM_TeamTable) -> Mapping[str, object]:
table: Final = existing_row.litellm_model_table
return _as_object(_JSON_OBJECT.validate_json(table.model_dump_json()).get("model_aliases")) if table else _EMPTY
def _column_changed(
field: str, submitted: Mapping[str, object], existing: Mapping[str, object], existing_row: LiteLLM_TeamTable
) -> bool:
if field == "model_aliases":
return _as_object(submitted.get(field)) != _stored_model_aliases(existing_row)
if field in LiteLLM_TeamTable.model_fields:
return submitted.get(field) != existing.get(field)
return True
def changed_team_fields(data: UpdateTeamRequest, existing_row: LiteLLM_TeamTable) -> frozenset[str]:
"""Logical field names whose stored value the request would change.
Request and stored row are compared as JSON values so both sides share one representation. Fields the
server folds into metadata are attributed to their own name whether they arrive top-level or inside
``metadata``; anything else in ``metadata`` is attributed to ``metadata``. Fields with no stored
counterpart on the team row count as changed whenever they are sent.
"""
submitted: Final = _JSON_OBJECT.validate_json(data.model_dump_json(exclude_unset=True))
existing: Final = _JSON_OBJECT.validate_json(existing_row.model_dump_json())
column_fields: Final = frozenset(data.model_fields_set) - _NOT_COLUMNS - _METADATA_FOLDED_FIELDS
column_changes: Final = frozenset(
field for field in column_fields if _column_changed(field, submitted, existing, existing_row)
)
return column_changes | _metadata_changes(data, submitted, existing)
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."""
sent: Final = frozenset(data.model_fields_set)
via_metadata: Final = frozenset({"metadata"}) if changed - sent else frozenset()
kept: Final = frozenset({"team_id"}) | (changed & sent) | via_metadata
return UpdateTeamRequest.model_validate(data.model_dump(include=MappingProxyType({field: True for field in kept})))
def team_admin_edit_verdict(
data: UpdateTeamRequest,
existing: LiteLLM_TeamTable,
permitted: frozenset[str],
) -> TeamAdminEditVerdict:
if not permitted:
return TeamAdminEditingDisabled()
changed: Final = changed_team_fields(data, existing)
blocked: Final = sorted(changed - permitted)
if blocked:
return TeamAdminFieldNotPermitted(field=blocked[0])
return TeamAdminEditAllowed(request=_only_changes(data, changed))
def team_admin_request_or_raise(verdict: TeamAdminEditVerdict) -> UpdateTeamRequest:
match verdict:
case TeamAdminEditAllowed(request=request):
return request
case TeamAdminEditingDisabled():
raise HTTPException(
status_code=403,
detail=(
"Team admins on this proxy cannot edit team settings. "
f"Ask a proxy admin to enable fields under {_SETTINGS_LOCATION}."
),
)
case TeamAdminFieldNotPermitted(field=field):
raise HTTPException(
status_code=403,
detail=(
f"Team admins on this proxy do not have permission to update '{field}'. "
f"Ask a proxy admin to add it under {_SETTINGS_LOCATION}."
),
)
case _:
assert_never(verdict)

View file

@ -18,12 +18,23 @@ from collections.abc import Iterable, Mapping, Sequence
from collections.abc import Set as AbstractSet
from datetime import datetime, timezone
from types import MappingProxyType
from typing import TYPE_CHECKING, Annotated, Final, NamedTuple, NoReturn, Protocol, TypeVar, cast
from typing import (
TYPE_CHECKING,
Annotated,
Final,
Literal,
NamedTuple,
NoReturn,
Protocol,
TypeAlias,
TypeVar,
cast,
)
import fastapi
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from pydantic import BaseModel, JsonValue, ValidationError
from typing_extensions import ReadOnly, TypedDict
from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError
from typing_extensions import ReadOnly, TypedDict, assert_never
import litellm
from litellm._logging import verbose_proxy_logger
@ -62,6 +73,11 @@ from litellm.proxy._types import (
SpecialProxyStrings,
TeamAccessGroupModelGrant,
TeamAddMemberResponse,
TeamEditAccess,
TeamEditAsTeamAdmin,
TeamEditAsTeamAdminDisabled,
TeamEditNone,
TeamEditUnrestricted,
TeamInfoMember,
TeamInfoResponseObject,
TeamInfoResponseObjectTeamTable,
@ -122,6 +138,12 @@ from litellm.proxy.management_endpoints.router_weights import validate_router_se
from litellm.proxy.management_endpoints.tag_management_endpoints import (
get_daily_activity,
)
from litellm.proxy.management_endpoints.team_admin_field_permissions import (
SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS,
resolve_team_admin_editable_fields,
team_admin_edit_verdict,
team_admin_request_or_raise,
)
from litellm.proxy.management_helpers.access_group_team_sync import (
TEAM_ADVISORY_LOCK_SQL,
AccessGroupSyncTx,
@ -439,32 +461,70 @@ async def _refresh_cached_team(
)
async def _can_manage_team(
TeamAccessRole: TypeAlias = Literal["proxy_admin", "org_admin", "team_admin"]
def _raise_team_access_denied() -> NoReturn:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have access to this team",
)
async def _resolve_team_access(
team_obj: LiteLLM_TeamTable,
user_api_key_dict: UserAPIKeyAuth,
) -> bool:
"""True for a proxy admin, an admin of this team, or an org admin for the team's organization."""
) -> TeamAccessRole | None:
"""Strongest role the caller holds over ``team_obj``, or None when they hold none.
Org admin outranks team admin so a caller holding both keeps unrestricted edits.
"""
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN:
return True
return "proxy_admin"
if await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=team_obj):
return "org_admin"
if _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj):
return True
return "team_admin"
return await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=team_obj)
return None
async def _verify_team_access(
team_obj: LiteLLM_TeamTable,
user_api_key_dict: UserAPIKeyAuth,
) -> None:
"""Raise HTTPException(403) unless the caller can manage the given team."""
if await _can_manage_team(team_obj=team_obj, user_api_key_dict=user_api_key_dict):
return
"""Raise 403 unless the caller is a proxy admin, an org admin for the team's org, or a team admin."""
if await _resolve_team_access(team_obj=team_obj, user_api_key_dict=user_api_key_dict) is None:
_raise_team_access_denied()
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have access to this team",
)
_GENERAL_SETTINGS: Final = TypeAdapter(dict[str, object])
def _general_settings() -> Mapping[str, object]:
from litellm.proxy.proxy_server import general_settings
return _GENERAL_SETTINGS.validate_python(general_settings)
def _caller_edit_access(role: TeamAccessRole | None, general_settings: Mapping[str, object]) -> TeamEditAccess:
"""What the caller may change on /team/update, reported on /team/info so the dashboard never re-derives it."""
match role:
case "proxy_admin" | "org_admin":
return TeamEditUnrestricted()
case "team_admin":
permitted: Final = resolve_team_admin_editable_fields(
general_settings, SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS
)
if not permitted:
return TeamEditAsTeamAdminDisabled()
return TeamEditAsTeamAdmin(editable_fields=tuple(sorted(permitted)))
case None:
return TeamEditNone()
case _:
assert_never(role)
class TeamMemberBudgetHandler:
@ -2144,16 +2204,29 @@ async def update_team(
)
if existing_team_row is None:
# Non-proxy-admins get the same 403 as an access denial so /team/update
# cannot be used to probe which team ids exist
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
_raise_team_access_denied()
raise HTTPException(
status_code=404,
detail={"error": f"Team not found, passed team_id={data.team_id}"},
)
# Verify caller has access to manage this team
await _verify_team_access(
team_obj=LiteLLM_TeamTable.model_validate(existing_team_row.model_dump()),
user_api_key_dict=user_api_key_dict,
)
existing_team: Final = LiteLLM_TeamTable.model_validate(existing_team_row.model_dump())
access_role: Final = await _resolve_team_access(team_obj=existing_team, user_api_key_dict=user_api_key_dict)
if access_role is None:
_raise_team_access_denied()
if access_role == "team_admin":
data = team_admin_request_or_raise( # rebind-ok: resent values must not reach the derived writes below
team_admin_edit_verdict(
data=data,
existing=existing_team,
permitted=resolve_team_admin_editable_fields(
_general_settings(), SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS
),
)
)
await validate_router_settings_weights(
data.router_settings,
@ -2257,6 +2330,7 @@ async def update_team(
org_id=org_id_to_check,
user_api_key_cache=user_api_key_cache,
prisma_client=prisma_client,
include_budget_table=True,
)
if org_table is not None:
await _check_org_team_limits(
@ -4583,10 +4657,9 @@ async def team_info(
)
team_table: Final = LiteLLM_TeamTable.model_validate(team_info.model_dump())
await validate_membership(user_api_key_dict=user_api_key_dict, team_table=team_table)
access_role: Final = await _resolve_team_access(team_obj=team_table, user_api_key_dict=user_api_key_dict)
organization_models: Final[list[str] | None] = (
_parent_organization_models(team_info)
if await _can_manage_team(team_obj=team_table, user_api_key_dict=user_api_key_dict)
else None
_parent_organization_models(team_info) if access_role is not None else None
)
## GET ALL KEYS ##
@ -4655,6 +4728,7 @@ async def team_info(
model_max_budget=resolved_team_info.model_max_budget,
cache=model_max_budget_limiter.dual_cache,
),
"caller_edit_access": _caller_edit_access(access_role, _general_settings()),
}
)

View file

@ -689,6 +689,9 @@ from litellm.proxy.types_utils.utils import get_instance_fn
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
router as ui_crud_endpoints_router,
)
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
sync_ui_settings_to_general_settings,
)
from litellm.proxy.ui_crud_endpoints.user_banner_endpoints import (
router as user_banner_endpoints_router,
)
@ -1753,10 +1756,6 @@ class _SSOConfigRow(Protocol):
sso_settings: MutableMapping[str, object]
class _UISettingsRow(Protocol):
ui_settings: Mapping[str, object] | str | None
class _InvitationLinkRow(Protocol):
user_id: str
expires_at: datetime
@ -7412,7 +7411,12 @@ class ProxyConfig:
Returns what the reconcile saw, captured before the lock is released so a
caller's verdict cannot be corrupted by the next reconcile's own in-flight
window. See ReconcileOutcome.
Also re-reads the UI settings that back runtime flags. That runs before the lock, so a
setting written through one pod reaches the others without waiting on a model reconcile.
"""
await sync_ui_settings_to_general_settings(prisma_client)
async with MODEL_RECONCILE_LOCK:
return await self._add_deployment_locked(prisma_client=prisma_client, proxy_logging_obj=proxy_logging_obj)
@ -9648,35 +9652,12 @@ class ProxyStartupEvent:
@classmethod
async def _sync_ui_settings_to_general_settings(cls):
"""
Load persisted UI settings from the database and sync runtime flags
into general_settings so they take effect immediately after startup.
"""
try:
import json
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
_RUNTIME_GENERAL_SETTINGS_FLAGS,
)
if prisma_client is None:
return
db_record: Final[_UISettingsRow | None] = cast( # cast-ok: prisma Json stub is `str`, runtime is a dict
"_UISettingsRow | None",
await UISettingsRepository(prisma_client).table.find_unique(where={"id": "ui_settings"}),
)
if db_record and db_record.ui_settings:
raw: Final = db_record.ui_settings
ui_settings: Final = json.loads(raw) if isinstance(raw, str) else dict(raw)
flags_to_sync: Final = {k: ui_settings[k] for k in _RUNTIME_GENERAL_SETTINGS_FLAGS if k in ui_settings}
if flags_to_sync:
general_settings.update(flags_to_sync)
verbose_proxy_logger.info(
"Synced UI settings to general_settings on startup: %s",
list(flags_to_sync.keys()),
)
except Exception as e:
verbose_proxy_logger.debug("UI settings sync on startup skipped or failed: %s", e)
"""Apply the persisted UI settings to general_settings before this pod serves traffic."""
if prisma_client is None:
return
applied: Final = await sync_ui_settings_to_general_settings(prisma_client)
if applied:
verbose_proxy_logger.info("Synced UI settings to general_settings on startup: %s", list(applied))
@classmethod
async def _load_heuristic_v1_tuning_baselines(
@ -12879,7 +12860,6 @@ from litellm.repositories.table_repositories import (
InvitationLinkRepository,
PromptRepository,
SSOConfigRepository,
UISettingsRepository,
)
from litellm.repositories.team_repository import TeamRepository
from litellm.repositories.user_repository import UserRepository

View file

@ -14,7 +14,7 @@ from typing import (
from urllib.parse import urlparse
from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile
from pydantic import ConfigDict, JsonValue, ValidationError, create_model
from pydantic import ConfigDict, JsonValue, TypeAdapter, ValidationError, create_model
from pydantic.fields import FieldInfo
from typing_extensions import NotRequired, ReadOnly, TypedDict
@ -29,6 +29,10 @@ from litellm.proxy.config_resolvers.sso import (
SSO_SECRET_FIELDS,
resolve_sso_config,
)
from litellm.proxy.management_endpoints.team_admin_field_permissions import (
SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS,
TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING,
)
from litellm.proxy.spend_tracking.ptu_feature_flag import is_ptu_cost_attribution_enabled
from litellm.proxy.utils import invalidate_config_param
from litellm.repositories.config_repository import ConfigRepository
@ -212,6 +216,9 @@ class UIThemeSettingsResponse(SettingsResponse):
"""Response model for UI theme settings"""
_TEAM_ADMIN_FIELD_ENUM: Final = tuple(sorted(SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS))
class UISettings(BaseModel):
"""Configuration for UI-specific flags"""
@ -304,6 +311,18 @@ class UISettings(BaseModel):
description="If true, shows the Chat page in the UI sidebar, letting users chat with an LLM and connect their own MCP server credentials via OAuth.",
)
team_admin_editable_team_fields: Sequence[str] = Field(
default=(),
description=(
"Team settings fields a team admin may change on the teams they administer. "
"Empty means team admins cannot edit team settings at all. "
"Proxy admins and org admins are not affected."
),
json_schema_extra={ # mutable-ok: pydantic only merges json_schema_extra when it is a plain dict
"items": {"type": "string", "enum": [*_TEAM_ADMIN_FIELD_ENUM]}, # mutable-ok: nested in the dict above
},
)
class UISettingsResponse(SettingsResponse):
"""Response model for UI settings"""
@ -326,6 +345,7 @@ ALLOWED_UI_SETTINGS_FIELDS: Final = {
"disable_custom_api_keys",
"disable_key_generate_for_org_admin",
"enable_chat_ui",
TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING,
}
ENABLE_PTU_COST_ATTRIBUTION_UI_SETTING: Final = "enable_ptu_cost_attribution"
@ -360,6 +380,7 @@ _RUNTIME_GENERAL_SETTINGS_FLAGS: Final = [
"disable_vector_stores_for_internal_users",
"allow_vector_stores_for_team_admins",
"disable_key_generate_for_org_admin",
TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING,
]
# Extension point: packages outside OSS (e.g. litellm_enterprise) can
@ -1457,6 +1478,42 @@ async def get_ui_settings_cached() -> dict[str, JsonValue]:
return ui_settings
_UI_SETTINGS_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
def apply_runtime_general_settings_flags(ui_settings: Mapping[str, JsonValue]) -> Mapping[str, JsonValue]:
"""Copy the UI settings that gate runtime behavior into ``general_settings``. Returns what was applied."""
from litellm.proxy.proxy_server import general_settings
flags: Final = {k: ui_settings[k] for k in _RUNTIME_GENERAL_SETTINGS_FLAGS if k in ui_settings}
if flags:
general_settings.update(flags)
return MappingProxyType(flags)
async def sync_ui_settings_to_general_settings(prisma_client: object) -> Mapping[str, JsonValue]:
"""Re-read the persisted UI settings and apply the runtime flags to ``general_settings``.
Runs on startup and on every periodic config reload: the PATCH handler only updates the pod
that served it, so every other pod needs its own read to pick up a change without a restart.
Never raises. A read that fails leaves this pod on the flags it already had.
"""
try:
db_record: Final = await _ui_settings_db(UISettingsRepository(prisma_client)).find_unique(
where={"id": "ui_settings"}
)
stored: Final = (db_record.ui_settings if db_record else None) or "{}"
parsed: Final = (
_UI_SETTINGS_OBJECT.validate_json(stored)
if isinstance(stored, str)
else _UI_SETTINGS_OBJECT.validate_python(stored)
)
except Exception as e:
verbose_proxy_logger.warning("Could not refresh UI settings from the database: %s", e)
return MappingProxyType({})
return apply_runtime_general_settings_flags(parsed)
@router.get(
"/get/ui_settings",
tags=["UI Settings"],
@ -1485,13 +1542,7 @@ async def get_ui_settings():
# Sanitize any unexpected keys from persisted config before returning
ui_settings: Final = {k: v for k, v in parsed.items() if k in ALLOWED_UI_SETTINGS_FIELDS}
# Sync runtime flags into general_settings so the proxy picks them up
# at runtime (covers server restart scenarios).
_flags_to_sync: Final = {k: ui_settings[k] for k in _RUNTIME_GENERAL_SETTINGS_FLAGS if k in ui_settings}
if _flags_to_sync:
from litellm.proxy.proxy_server import general_settings
general_settings.update(_flags_to_sync)
apply_runtime_general_settings_flags(ui_settings)
# Refresh DualCache so other code paths (e.g. /user/filter/ui) see fresh values
from litellm.proxy.proxy_server import user_api_key_cache
@ -1571,6 +1622,20 @@ async def update_ui_settings(
except ValidationError as e:
raise HTTPException(status_code=422, detail=e.errors())
unsupported_team_fields: Final = sorted(
frozenset(settings.team_admin_editable_team_fields) - SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS
)
if unsupported_team_fields:
raise HTTPException(
status_code=400,
detail={ # mutable-ok: HTTPException detail must be a plain dict for FastAPI JSON serialization
"error": (
f"{TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING} does not support {unsupported_team_fields}. "
f"Supported fields: {sorted(SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS)}."
)
},
)
# Only include fields the caller actually sent (not Pydantic defaults).
settings_dict: Final[Mapping[str, JsonValue]] = settings.model_dump(exclude_unset=True)
@ -1616,13 +1681,7 @@ async def update_ui_settings(
},
)
# Sync runtime flags to general_settings so the proxy picks them up
# at runtime (general_settings is checked in pre-call utils).
_flags_to_sync: Final = {k: ui_settings[k] for k in _RUNTIME_GENERAL_SETTINGS_FLAGS if k in ui_settings}
if _flags_to_sync:
from litellm.proxy.proxy_server import general_settings
general_settings.update(_flags_to_sync)
apply_runtime_general_settings_flags(ui_settings)
# Invalidate + set DualCache so subsequent reads see the new values immediately
from litellm.proxy.proxy_server import user_api_key_cache

View file

@ -30,6 +30,9 @@
- {id: mgmt.key.health.happy_path, module: mgmt, tier: P1, surface: api, assertions: [happy_path], source: "key_management_endpoints.py:4292", rationale: "Key health endpoint"}
- {id: mgmt.key.bulk_update.happy_path, module: mgmt, tier: P1, surface: api, assertions: [happy_path], source: "key_management_endpoints.py:2677", rationale: "Batch key updates"}
- {id: mgmt.team.update.persists, module: mgmt, tier: P1, surface: api, assertions: [persists], source: "team_endpoints.py:1582", rationale: "Metadata/budget updates persist"}
- {id: mgmt.team.update.team_admin_forbidden_until_enabled, module: mgmt, tier: P0, surface: api, assertions: [team_admin_forbidden_until_enabled], source: "team_admin_field_permissions.py:156", rationale: "With no team admin editable fields enabled, a team admin's /team/update is 403 and /team/info reports editing disabled"}
- {id: mgmt.team.update.team_admin_limited_to_enabled_fields, module: mgmt, tier: P0, surface: api, assertions: [team_admin_limited_to_enabled_fields], source: "team_admin_field_permissions.py:156", rationale: "A team admin may change only the enabled fields; a request that also changes any other field is 403 and writes nothing"}
- {id: mgmt.team.update.team_admin_resend_keeps_budget_reset, module: mgmt, tier: P1, surface: api, assertions: [team_admin_resend_keeps_budget_reset], source: "team_admin_field_permissions.py:147", fail_before_fix: proven, rationale: "A team admin resending unchanged budget settings with an enabled field must not push the team's budget reset times back"}
- {id: mgmt.team.delete.persists, module: mgmt, tier: P1, surface: api, assertions: [persists], source: "team_endpoints.py:1750", rationale: "Deletion prevents key access"}
- {id: mgmt.team.block.persists, module: mgmt, tier: P1, surface: api, assertions: [persists], source: "team_endpoints.py", rationale: "Block suspends all members"}
- {id: mgmt.team.info.happy_path, module: mgmt, tier: P1, surface: api, assertions: [happy_path], source: "team_endpoints.py:2244", rationale: "Metadata+members+budgets"}

View file

@ -1,5 +1,6 @@
"""Live e2e: the /team/* management routes' block, membership, and admin-only
contract.
contract, plus the team settings a team admin may change on /team/update once a
proxy admin enables them under Settings > UI > Team admin editable fields.
Each test creates its team/user/key resources under unique names (deleted on
teardown) and asserts both halves of the contract: the recorded state (the info
@ -8,21 +9,25 @@ Team writes reach the read path once their db/cache entry propagates, so the
read-backs poll to a deadline instead of asserting once.
Everything the shared harness does not already model lives here: the local
request/response models for /team/block, /team/member_update, and the
/team/info fields (blocked flag and per-member budget) these tests assert on.
request/response models for /team/block, /team/member_update, the partial
/team/update, the UI settings allow-list, and the /team/info fields (blocked
flag, limits, budgets, per-member budget, the caller's edit access) these tests
assert on.
"""
from __future__ import annotations
import time
from collections.abc import Callable
from typing import Literal
from collections.abc import Callable, Generator
from contextlib import contextmanager
from datetime import UTC, datetime, timedelta
from typing import Final, Literal
import pytest
from pydantic import BaseModel
from e2e_config import unique_marker
from e2e_http import NoBody, StreamingResponse, unwrap
from e2e_config import settle_propagation, unique_marker
from e2e_http import NoBody, PartialBody, StreamingResponse, unwrap
from lifecycle import ResourceManager
from management_client import ManagementClient
from models import (
@ -39,6 +44,8 @@ pytestmark = pytest.mark.e2e
TeamRole = Literal["admin", "user"]
_TEAM_TPM_LIMIT: Final = 1000
class TeamBlockBody(BaseModel):
team_id: str
@ -66,11 +73,37 @@ class TeamMembership(BaseModel):
litellm_budget_table: MemberBudgetTable | None = None
class TeamInfoData(BaseModel):
class CallerEditAccess(BaseModel):
kind: Literal["unrestricted", "team_admin", "team_admin_disabled", "none"]
editable_fields: list[str] = []
class BudgetWindow(BaseModel):
budget_duration: str
max_budget: float
reset_at: str | None = None
class TeamCustomMetadata(BaseModel):
cost_center: str | None = None
class TeamSettings(BaseModel):
team_alias: str | None = None
models: list[str] = []
tpm_limit: int | None = None
rpm_limit: int | None = None
max_budget: float | None = None
budget_duration: str | None = None
budget_limits: list[BudgetWindow] | None = None
metadata: TeamCustomMetadata | None = None
class TeamInfoData(TeamSettings):
blocked: bool | None = None
members_with_roles: list[MemberRoleEntry] = []
budget_reset_at: datetime | None = None
caller_edit_access: CallerEditAccess | None = None
class TeamInfoRead(BaseModel):
@ -79,6 +112,27 @@ class TeamInfoRead(BaseModel):
team_memberships: list[TeamMembership] = []
class TeamWithAdminNewBody(TeamNewBody):
tpm_limit: int
members_with_roles: list[TeamMemberEntry]
class TeamSettingsChange(PartialBody, TeamSettings):
pass
class TeamSettingsUpdate(TeamSettingsChange):
team_id: str
class TeamAdminEditableFields(BaseModel):
team_admin_editable_team_fields: list[str] = []
class UiSettingsRead(BaseModel):
values: TeamAdminEditableFields
def _poll[T](client: ManagementClient, attempt: Callable[[], T | None], failure: str) -> T:
deadline = time.monotonic() + client.proxy.poll_timeout
while time.monotonic() < deadline:
@ -107,17 +161,27 @@ def _generate_key(client: ManagementClient, resources: ResourceManager, body: Ke
return key
def _read_team(client: ManagementClient, team_id: str) -> TeamInfoRead:
def _read_team(client: ManagementClient, team_id: str, caller_key: str | None = None) -> TeamInfoRead:
return unwrap(
client.proxy.transport.get(
"/team/info",
headers=client.proxy.transport.master,
headers=client.proxy.transport.master if caller_key is None else client.proxy.transport.bearer(caller_key),
params=TeamInfoParams(team_id=team_id),
response_type=TeamInfoRead,
)
)
def _poll_team(
client: ManagementClient, team_id: str, ready: Callable[[TeamInfoData], bool], failure: str
) -> TeamInfoData:
def read() -> TeamInfoData | None:
info = _read_team(client, team_id).team_info
return info if ready(info) else None
return _poll(client, read, failure)
def _set_blocked(client: ManagementClient, team_id: str, *, blocked: bool) -> None:
_ = unwrap(
client.proxy.transport.post(
@ -301,3 +365,218 @@ class TestTeamManagementRoutes:
client.add_team_member(team_id, member_id)
member_key = _generate_key(client, resources, KeyGenerateBody(user_id=member_id, team_id=team_id))
return member_id, other_id, member_key, team_id
def _team_admin_editable_fields(client: ManagementClient) -> list[str]:
return unwrap(
client.proxy.transport.get(
"/get/ui_settings",
headers=client.proxy.transport.master,
params=NoBody(),
response_type=UiSettingsRead,
)
).values.team_admin_editable_team_fields
def _set_team_admin_editable_fields(client: ManagementClient, fields: list[str]) -> None:
_ = unwrap(
client.proxy.transport.patch(
"/update/ui_settings",
headers=client.proxy.transport.master,
json=TeamAdminEditableFields(team_admin_editable_team_fields=fields),
response_type=NoBody,
)
)
@contextmanager
def _team_admins_may_edit(client: ManagementClient, fields: list[str]) -> Generator[None]:
"""The allow-list is proxy-wide, so restore whatever was there. Other replicas pick a change up on their
config reload, which the wait covers before any team admin call lands on one of them."""
original = _team_admin_editable_fields(client)
_set_team_admin_editable_fields(client, fields)
settle_propagation(time.monotonic())
try:
yield
finally:
_set_team_admin_editable_fields(client, original)
@pytest.fixture(scope="class")
def no_team_admin_editable_fields(client: ManagementClient) -> Generator[None]:
with _team_admins_may_edit(client, []):
yield
@pytest.fixture(scope="class")
def tpm_limit_editable_by_team_admins(client: ManagementClient) -> Generator[None]:
with _team_admins_may_edit(client, ["tpm_limit"]):
yield
def _team_with_admin(client: ManagementClient, resources: ResourceManager) -> tuple[str, str]:
"""A team with a tpm_limit, and the key of a user who is an admin of that team."""
admin_id = _create_user(client, resources, f"e2e-team-admin-{unique_marker()}@example.com")
team_id = client.create_team(
TeamWithAdminNewBody(
team_alias=f"e2e-team-admin-{unique_marker()}",
tpm_limit=_TEAM_TPM_LIMIT,
members_with_roles=[TeamMemberEntry(role="admin", user_id=admin_id)],
)
)
resources.defer(lambda: client.delete_team(team_id))
return team_id, _generate_key(client, resources, KeyGenerateBody(user_id=admin_id))
def _update_team_as(client: ManagementClient, caller_key: str, body: TeamSettingsUpdate) -> StreamingResponse:
return client.proxy.transport.send("/team/update", headers=client.proxy.transport.bearer(caller_key), json=body)
@pytest.mark.usefixtures("no_team_admin_editable_fields")
class TestTeamAdminWithNoEditableFields:
"""No proxy admin has enabled a team field for team admins, which is how every proxy starts."""
@pytest.mark.covers("mgmt.team.update.team_admin_forbidden_until_enabled")
def test_team_admin_cannot_change_any_team_setting(
self, client: ManagementClient, resources: ResourceManager
) -> None:
team_id, admin_key = _team_with_admin(client, resources)
access = _read_team(client, team_id, admin_key).team_info.caller_edit_access
assert access == CallerEditAccess(kind="team_admin_disabled"), (
f"/team/info should tell the team admin that editing is disabled, got {access}"
)
outcome = _update_team_as(client, admin_key, TeamSettingsUpdate(team_id=team_id, tpm_limit=5000))
assert outcome.status_code == 403, (
f"/team/update by a team admin must be 403 while nothing is enabled, got {outcome.status_code}: "
f"{outcome.body[:300]}"
)
assert "cannot edit team settings" in outcome.body, f"403 body should say why, got: {outcome.body[:300]}"
tpm_limit = _read_team(client, team_id).team_info.tpm_limit
assert tpm_limit == _TEAM_TPM_LIMIT, f"the refused update still changed tpm_limit to {tpm_limit}"
@pytest.mark.usefixtures("tpm_limit_editable_by_team_admins")
class TestTeamAdminWithTpmLimitEnabled:
"""A proxy admin has enabled tpm_limit, so a team admin may change that setting and no other."""
@pytest.mark.covers("mgmt.team.update.team_admin_limited_to_enabled_fields")
def test_team_admin_saves_the_settings_form_with_a_new_tpm_limit(
self, client: ManagementClient, resources: ResourceManager
) -> None:
team_id, admin_key = _team_with_admin(client, resources)
access = _read_team(client, team_id, admin_key).team_info.caller_edit_access
assert access == CallerEditAccess(kind="team_admin", editable_fields=["tpm_limit"]), (
f"/team/info should list tpm_limit as the team admin's only editable field, got {access}"
)
before = _read_team(client, team_id).team_info
outcome = _update_team_as(
client,
admin_key,
TeamSettingsUpdate(team_id=team_id, team_alias=before.team_alias, models=before.models, tpm_limit=5000),
)
assert outcome.status_code == 200, (
f"a team admin resending the form with only tpm_limit changed must succeed, got {outcome.status_code}: "
f"{outcome.body[:300]}"
)
after = _poll_team(
client, team_id, lambda info: info.tpm_limit == 5000, "/team/info never reflected tpm_limit=5000"
)
assert after.model_copy(update={"tpm_limit": _TEAM_TPM_LIMIT}) == before, (
f"the update changed more than tpm_limit: before {before}, after {after}"
)
@pytest.mark.covers("mgmt.team.update.team_admin_limited_to_enabled_fields")
@pytest.mark.parametrize(
"change",
[
pytest.param(TeamSettingsChange(rpm_limit=10), id="rpm_limit"),
pytest.param(TeamSettingsChange(max_budget=0.5), id="max_budget"),
pytest.param(TeamSettingsChange(team_alias="renamed-by-team-admin"), id="team_alias"),
pytest.param(TeamSettingsChange(models=["gemini-2.5-flash"]), id="models"),
pytest.param(TeamSettingsChange(budget_duration="1d"), id="budget_duration"),
pytest.param(TeamSettingsChange(metadata=TeamCustomMetadata(cost_center="team-admin")), id="metadata"),
],
)
def test_team_admin_cannot_change_a_setting_that_is_not_enabled(
self, client: ManagementClient, resources: ResourceManager, change: TeamSettingsChange
) -> None:
(field,) = change.model_fields_set
team_id, admin_key = _team_with_admin(client, resources)
before = _read_team(client, team_id).team_info
outcome = _update_team_as(
client,
admin_key,
TeamSettingsUpdate.model_validate(
{**change.model_dump(exclude_unset=True), "team_id": team_id, "tpm_limit": 5000}
),
)
assert outcome.status_code == 403, (
f"a team admin changing {field} must be 403, got {outcome.status_code}: {outcome.body[:300]}"
)
assert f"'{field}'" in outcome.body, f"403 body should name {field}, got: {outcome.body[:300]}"
after = _read_team(client, team_id).team_info
assert after == before, (
f"the refused update still wrote to the team, the enabled tpm_limit included: before {before}, "
f"after {after}"
)
@pytest.mark.covers("mgmt.team.update.team_admin_resend_keeps_budget_reset")
def test_team_admin_resending_the_budget_settings_keeps_the_next_budget_reset(
self, client: ManagementClient, resources: ResourceManager
) -> None:
"""A 120s budget resets at the start of the minute after next. Resending it once the next minute has
started would push that reset a minute later, while the stored reset is still a minute out, so the
proxy's budget reset job cannot be what moves it."""
team_id, admin_key = _team_with_admin(client, resources)
_ = unwrap(
client.proxy.transport.post(
"/team/update",
headers=client.proxy.transport.master,
json=TeamSettingsUpdate(
team_id=team_id,
budget_duration="120s",
budget_limits=[BudgetWindow(budget_duration="120s", max_budget=5.0)],
),
response_type=NoBody,
)
)
budgeted = _poll_team(
client,
team_id,
lambda info: info.budget_reset_at is not None and bool(info.budget_limits),
"/team/info never reflected the 120s budget the proxy admin set",
)
assert budgeted.budget_reset_at is not None
next_minute = budgeted.budget_reset_at - timedelta(seconds=58)
time.sleep(max(0.0, (next_minute - datetime.now(UTC)).total_seconds()))
outcome = _update_team_as(
client,
admin_key,
TeamSettingsUpdate(
team_id=team_id,
tpm_limit=5000,
budget_duration=budgeted.budget_duration,
budget_limits=budgeted.budget_limits,
),
)
assert outcome.status_code == 200, (
f"resending unchanged budget settings with a new tpm_limit must succeed, got {outcome.status_code}: "
f"{outcome.body[:300]}"
)
after = _poll_team(
client, team_id, lambda info: info.tpm_limit == 5000, "/team/info never reflected tpm_limit=5000"
)
assert after.budget_reset_at == budgeted.budget_reset_at, (
f"the team admin pushed the budget reset from {budgeted.budget_reset_at} to {after.budget_reset_at}"
)
assert after.budget_limits == budgeted.budget_limits, (
f"the team admin pushed the budget window resets from {budgeted.budget_limits} to {after.budget_limits}"
)

View file

@ -10,12 +10,11 @@ Pins the five helpers
Driven through /team/new + /team/update.
Structural finding, updated: /team/new loads the org via `get_org_object`
WITH `include_budget_table=True`, so the org max_budget / org tpm / org rpm
guards inside `_check_org_team_limits` are live there and are pinned as
enforced below. /team/update still loads the org without the budget
relation, so its budget guards remain no-ops. The `models` subset guard IS
reachable on both because it reads `org_table.models` directly. The
Structural finding, updated: /team/new and /team/update both load the org
via `get_org_object` WITH `include_budget_table=True`, so the org max_budget /
org tpm / org rpm guards inside `_check_org_team_limits` are live on both and
are pinned as enforced below. The `models` subset guard reads
`org_table.models` directly. The
`_check_user_team_limits` guards reach all branches through
`user_api_key_dict`, no relation include needed.
"""
@ -139,9 +138,8 @@ async def test_check_org_team_limits_models_subset(
# ---------------------------------------------------------------------------
# _check_org_team_limits — budget / tpm / rpm live on /team/new since its
# get_org_object call passes include_budget_table=True. (/team/update still
# loads the org without the budget relation, so its guards remain no-ops.)
# _check_org_team_limits — budget / tpm / rpm live on /team/new and
# /team/update since both get_org_object calls pass include_budget_table=True.
# ---------------------------------------------------------------------------
_ORG_BUDGET_ENFORCED_SCENARIOS = [
@ -216,6 +214,35 @@ async def test_check_org_team_limits_budget_enforced(
assert len(rows) == (1 if expected_status == 200 else 0)
@pytest.mark.parametrize(
"org_budget,body_extras,expected_status",
[(b, c, d) for (_id, b, c, d) in _ORG_BUDGET_ENFORCED_SCENARIOS],
ids=[s[0] for s in _ORG_BUDGET_ENFORCED_SCENARIOS],
)
async def test_check_org_team_limits_budget_enforced_on_update(
org_budget,
body_extras: Dict[str, Any],
expected_status: int,
proxy_client,
prisma,
scratch,
world,
):
org_id = await create_scratch_org(prisma, scratch.prefix, **org_budget)
team_id = await create_scratch_team(prisma, scratch.tag("team"), organization_id=org_id)
seeder = world.keys[Actor.PROXY_ADMIN].cleartext
resp = await proxy_client.post(
"/team/update",
headers={"Authorization": f"Bearer {seeder}"},
json={"team_id": team_id, **body_extras},
)
assert resp.status_code == expected_status, f"{body_extras!r} → {resp.status_code}: {resp.text}"
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": team_id})
assert row is not None
persisted = {field: getattr(row, field) for field in body_extras}
assert (persisted == body_extras) == (expected_status == 200)
# ---------------------------------------------------------------------------
# _check_user_team_limits — fires for standalone (no-org) teams created by
# a non-admin caller. Each guard reads from user_api_key_dict / user_obj.
@ -310,65 +337,40 @@ async def test_check_user_team_limits(
# /team/update path — budget authority.
#
# The caller's PERSONAL limits are never applied on update (that compared the
# wrong thing). But raising a team's spend ceiling is reserved for proxy admins:
# a team admin may keep or LOWER the budget, only a proxy admin may RAISE it.
# _check_user_team_limits() only runs on /team/new.
# wrong thing). Raising a team's spend ceiling is reserved for proxy admins.
# max_budget is not on the team-admin allow-list yet (LIT-5722), so a team
# admin is refused in either direction; the raise-only guard underneath the
# allow-list is pinned in the unit tests. _check_user_team_limits() only runs
# on /team/new.
# ---------------------------------------------------------------------------
async def test_team_admin_raise_budget_blocked(proxy_client, prisma, scratch):
"""A team admin cannot raise the team's budget; the block is NOT based on
their personal budget (which here is higher than the requested value)."""
caller_cleartext = await _seed_scratch_actor_with_caps(
prisma,
scratch.prefix,
max_budget=100000.0, # generous personal budget; must not matter
)
creator_user_id = f"{scratch.prefix}-team-creator"
@pytest.mark.parametrize(
"personal_budget,requested_budget",
[(100000.0, 999.0), (10.0, 300.0)],
ids=["raise_with_generous_personal_budget", "lower_with_tiny_personal_budget"],
)
async def test_team_admin_cannot_change_budget_while_max_budget_is_not_editable(
proxy_client, prisma, scratch, personal_budget: float, requested_budget: float
):
caller_cleartext = await _seed_scratch_actor_with_caps(prisma, scratch.prefix, max_budget=personal_budget)
team_id = await create_scratch_team(
prisma,
team_id=scratch.tag("team"),
admin_user_ids=[creator_user_id],
max_budget=50.0,
)
# Raise the team budget 50 -> 999 as a team admin.
resp = await proxy_client.post(
"/team/update",
headers={"Authorization": f"Bearer {caller_cleartext}"},
json={"team_id": team_id, "max_budget": 999.0},
)
assert resp.status_code == 403, resp.text
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": team_id})
assert row is not None
assert row.max_budget == 50.0, "team budget must not change on a blocked raise"
async def test_team_admin_lower_budget_allowed(proxy_client, prisma, scratch):
"""A team admin may freely lower (or keep) the team's budget."""
caller_cleartext = await _seed_scratch_actor_with_caps(
prisma,
scratch.prefix,
max_budget=10.0, # below both the old and new team budget; must not matter
)
creator_user_id = f"{scratch.prefix}-team-creator"
team_id = await create_scratch_team(
prisma,
team_id=scratch.tag("team"),
admin_user_ids=[creator_user_id],
admin_user_ids=[f"{scratch.prefix}-team-creator"],
max_budget=500.0,
)
# Lower the team budget 500 -> 300 as a team admin.
resp = await proxy_client.post(
"/team/update",
headers={"Authorization": f"Bearer {caller_cleartext}"},
json={"team_id": team_id, "max_budget": 300.0},
json={"team_id": team_id, "max_budget": requested_budget},
)
assert resp.status_code == 200, resp.text
assert resp.status_code == 403, resp.text
assert "Team admin editable fields" in resp.text, resp.text
row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": team_id})
assert row is not None
assert row.max_budget == 300.0, "team admin should be able to lower the budget"
assert row.max_budget == 500.0, "a refused update must leave the team budget unchanged"
async def test_proxy_admin_raise_budget_allowed(proxy_client, prisma, scratch):

View file

@ -9,31 +9,31 @@ pytestmark = pytest.mark.asyncio(loop_scope="session")
# POST /team/update — actor x team-shape matrix (shapes built by _seed_target).
# Each request carries the team's own organization_id so a non-proxy-admin can
# reach the org-scoped branch of the route-permission gate (401 on denial),
# which fronts the handler's _verify_team_access. Only PROXY_ADMIN and an
# ORG_ADMIN of the team's org pass: an internal_user team admin is filtered by
# the route gate before _verify_team_access's team-admin branch is reached.
# The route is self-managed (LIT-5722), so every authenticated caller reaches
# update_team and denials are the handler's 403, never the route gate's 401.
# Only PROXY_ADMIN and an ORG_ADMIN of the team's org pass: a team admin is
# admitted by _resolve_team_access but then refused because no team field is
# enabled for team admins (team_admin_editable_team_fields defaults to empty).
MARKER_ALIAS = "behavior-pin-update-marker-alias"
_MATRIX = [
("alpha/proxy_admin", Actor.PROXY_ADMIN, "alpha", 200),
("alpha/org_admin", Actor.ORG_ADMIN, "alpha", 200),
("alpha/team_admin", Actor.TEAM_ADMIN, "alpha", 401),
("alpha/internal_user", Actor.INTERNAL_USER, "alpha", 401),
("alpha/owner", Actor.OWNER, "alpha", 401),
("alpha/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "alpha", 401),
("alpha/cross_org_user", Actor.CROSS_ORG_USER, "alpha", 401),
("alpha/service_account", Actor.SERVICE_ACCOUNT, "alpha", 401),
("alpha/org_b_admin", Actor.ORG_B_ADMIN, "alpha", 401),
("alpha/team_admin", Actor.TEAM_ADMIN, "alpha", 403),
("alpha/internal_user", Actor.INTERNAL_USER, "alpha", 403),
("alpha/owner", Actor.OWNER, "alpha", 403),
("alpha/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "alpha", 403),
("alpha/cross_org_user", Actor.CROSS_ORG_USER, "alpha", 403),
("alpha/service_account", Actor.SERVICE_ACCOUNT, "alpha", 403),
("alpha/org_b_admin", Actor.ORG_B_ADMIN, "alpha", 403),
("beta/proxy_admin", Actor.PROXY_ADMIN, "beta", 200),
("beta/org_admin", Actor.ORG_ADMIN, "beta", 401),
("beta/team_admin", Actor.TEAM_ADMIN, "beta", 401),
("beta/internal_user", Actor.INTERNAL_USER, "beta", 401),
("beta/owner", Actor.OWNER, "beta", 401),
("beta/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "beta", 401),
("beta/cross_org_user", Actor.CROSS_ORG_USER, "beta", 401),
("beta/service_account", Actor.SERVICE_ACCOUNT, "beta", 401),
("beta/org_admin", Actor.ORG_ADMIN, "beta", 403),
("beta/team_admin", Actor.TEAM_ADMIN, "beta", 403),
("beta/internal_user", Actor.INTERNAL_USER, "beta", 403),
("beta/owner", Actor.OWNER, "beta", 403),
("beta/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "beta", 403),
("beta/cross_org_user", Actor.CROSS_ORG_USER, "beta", 403),
("beta/service_account", Actor.SERVICE_ACCOUNT, "beta", 403),
("beta/org_b_admin", Actor.ORG_B_ADMIN, "beta", 200),
]
@ -110,8 +110,9 @@ async def test_team_update_org_admin_resolved_from_team_without_org_context(
):
"""With no organization_id in the body the route gate resolves the target
team's org from team_id, so an org admin of the team's own org is allowed
(200), same as PROXY_ADMIN. A team admin of that same team stays denied
(401): the resolution grants org admins access, not team admins."""
(200), same as PROXY_ADMIN. A team admin of that same team reaches the
handler but is refused (403) until a proxy admin enables fields for team
admins, and the response says so."""
await _seed_target(prisma, world, "alpha", scratch.prefix)
allowed_org_admin = await proxy_client.post(
@ -133,21 +134,25 @@ async def test_team_update_org_admin_resolved_from_team_without_org_context(
headers={"Authorization": f"Bearer {world.keys[Actor.TEAM_ADMIN].cleartext}"},
json={"team_id": scratch.prefix, "team_alias": MARKER_ALIAS},
)
assert denied_team_admin.status_code == 401, denied_team_admin.text
assert denied_team_admin.status_code == 403, denied_team_admin.text
assert "cannot edit team settings" in denied_team_admin.text, denied_team_admin.text
assert "Team admin editable fields" in denied_team_admin.text, denied_team_admin.text
# Relocation gate — moving a team to a different org. The scratch team starts
# in ORG_A; each scenario relocates it to ORG_B. PROXY_ADMIN bypasses;
# ORG_B_ADMIN clears the route gate (dest-org admin) but fails
# _verify_team_access on the source team (403); the rest fail the route gate
# (401). The relocation-*allowed* branch (caller is org admin of both orgs) is
# covered by test_team_update_org_relocation_allowed_for_dual_org_admin below.
# ORG_B_ADMIN reaches the handler but holds no role on the source team (403);
# ORG_ADMIN holds the source team but not the destination org (403 from the
# relocation gate); the team admin is refused by the empty field allow-list and
# the internal user holds no role at all (403). The relocation-*allowed* branch
# (caller is org admin of both orgs) is covered by
# test_team_update_org_relocation_allowed_for_dual_org_admin below.
_RELOCATION = [
("proxy_admin", Actor.PROXY_ADMIN, 200),
("org_b_admin", Actor.ORG_B_ADMIN, 403),
("org_admin", Actor.ORG_ADMIN, 401),
("team_admin", Actor.TEAM_ADMIN, 401),
("internal_user", Actor.INTERNAL_USER, 401),
("org_admin", Actor.ORG_ADMIN, 403),
("team_admin", Actor.TEAM_ADMIN, 403),
("internal_user", Actor.INTERNAL_USER, 403),
]

View file

@ -2892,45 +2892,49 @@ def test_team_update_gate_allows_org_admin_with_resolved_org():
)
def test_team_update_gate_rejects_without_org_context():
"""Without organization_id (i.e. resolution found no org, or a non-org-admin),
the gate still rejects /team/update — the fix adds no blanket allow. Guards
against re-widening the route (e.g. dropping it into self_managed_routes)."""
def test_team_update_gate_admits_internal_user_without_org_context(): # test-quality-ok: the gate's only success signal is not raising; the handler's team-admin 403s are pinned in test_team_endpoints
"""/team/update is self-managed (LIT-5722): the coarse gate admits any authenticated
caller and update_team resolves proxy, org or team admin itself, then filters team admins
through the team_admin_editable_team_fields setting. Before that the gate 401'd every
team admin, which left the handler's team-admin branch unreachable."""
user_obj = LiteLLM_UserTable(
user_id="team-admin-user",
user_role=LitellmUserRoles.INTERNAL_USER.value,
organization_memberships=None,
)
valid_token = UserAPIKeyAuth(user_id="team-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value)
request = MagicMock(spec=Request)
request.method = "POST"
request.query_params = {}
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
route="/team/update",
request=request,
valid_token=valid_token,
request_data={"team_id": "team-1", "max_budget": 42},
)
def test_team_update_gate_defers_cross_org_admin_to_the_handler(): # test-quality-ok: the gate's only success signal is not raising; the handler's 403 it defers to is pinned in test_team_endpoints
"""An org admin of a DIFFERENT org clears the coarse gate like any internal user;
update_team's _resolve_team_access finds no role on the team and 403s (pinned in
test_team_endpoints), so there is still no cross-org escalation."""
user_obj = _make_org_admin_user("org-1")
valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value)
request = MagicMock(spec=Request)
request.method = "POST"
request.query_params = {}
with pytest.raises(Exception, match="Only proxy admin can be used to generate"):
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
route="/team/update",
request=request,
valid_token=valid_token,
request_data={"team_id": "team-1", "max_budget": 42},
)
def test_team_update_gate_rejects_cross_org_admin_with_resolved_org():
"""Even after the target team's org is resolved, an org admin of a DIFFERENT
org is rejected at the gate (no cross-org escalation)."""
user_obj = _make_org_admin_user("org-1")
valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value)
request = MagicMock(spec=Request)
request.method = "POST"
request.query_params = {}
with pytest.raises(Exception, match="Only proxy admin can be used to generate"):
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
route="/team/update",
request=request,
valid_token=valid_token,
request_data={"team_id": "team-1", "organization_id": "org-2"},
)
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
route="/team/update",
request=request,
valid_token=valid_token,
request_data={"team_id": "team-1", "organization_id": "org-2"},
)
# ── PATCH /team/{team_id}: same org-context + role reach as POST /team/update ──
@ -2993,23 +2997,6 @@ async def test_add_team_org_context_noop_for_static_team_route():
assert out == body
def test_patch_team_route_has_same_reach_as_team_update():
"""/team/{team_id} is reachable by org admins (in org_admin_allowed_routes) but
NOT by regular internal users or the role-agnostic self_managed_routes — the
latter would open /team/new (the collision footgun) to any authenticated user."""
from litellm.proxy._types import LiteLLMRoutes
assert RouteChecks.check_route_access(
route="/team/abc-123", allowed_routes=LiteLLMRoutes.org_admin_allowed_routes.value
)
assert not RouteChecks.check_route_access(
route="/team/abc-123", allowed_routes=LiteLLMRoutes.internal_user_routes.value
)
assert not RouteChecks.check_route_access(
route="/team/abc-123", allowed_routes=LiteLLMRoutes.self_managed_routes.value
)
def _patch_team_request() -> MagicMock:
request = MagicMock(spec=Request)
request.method = "PATCH"
@ -3897,7 +3884,6 @@ def test_team_disable_logging_stays_proxy_admin_only():
"route",
[
"/team/06bda574-5ca9-43d3-beb8-3b23c2f17112",
"/team/update",
"/team/06bda574-5ca9-43d3-beb8-3b23c2f17112/model/add",
],
)

View file

@ -0,0 +1,138 @@
import pytest
from fastapi import HTTPException
from litellm.proxy._types import LiteLLM_ModelTable, LiteLLM_TeamTable, UpdateTeamRequest
from litellm.proxy.management_endpoints.team_admin_field_permissions import (
TeamAdminEditAllowed,
TeamAdminEditingDisabled,
TeamAdminFieldNotPermitted,
changed_team_fields,
resolve_team_admin_editable_fields,
team_admin_edit_verdict,
team_admin_request_or_raise,
)
_SUPPORTED = frozenset({"tpm_limit", "rpm_limit", "team_alias"})
def _team(**overrides):
return LiteLLM_TeamTable(team_id="team-1", **overrides)
class TestResolveTeamAdminEditableFields:
def test_missing_setting_means_nothing_editable(self):
assert resolve_team_admin_editable_fields({}, _SUPPORTED) == frozenset()
def test_keeps_only_supported_names(self):
configured = {"team_admin_editable_team_fields": ["tpm_limit", "blocked", "organization_id"]}
assert resolve_team_admin_editable_fields(configured, _SUPPORTED) == frozenset({"tpm_limit"})
@pytest.mark.parametrize("raw", ["tpm_limit", 7, {"tpm_limit": True}, [1, 2]])
def test_malformed_setting_fails_closed(self, raw):
assert resolve_team_admin_editable_fields({"team_admin_editable_team_fields": raw}, _SUPPORTED) == frozenset()
class TestChangedTeamFields:
def test_team_id_alone_changes_nothing(self):
assert changed_team_fields(UpdateTeamRequest(team_id="team-1"), _team()) == frozenset()
def test_column_echoing_stored_value_is_not_a_change(self):
data = UpdateTeamRequest(team_id="team-1", tpm_limit=5, team_alias="alpha", max_budget=None)
assert changed_team_fields(data, _team(tpm_limit=5, team_alias="alpha")) == frozenset()
def test_column_with_different_value_is_a_change(self):
data = UpdateTeamRequest(team_id="team-1", tpm_limit=6, team_alias="alpha")
assert changed_team_fields(data, _team(tpm_limit=5, team_alias="alpha")) == frozenset({"tpm_limit"})
def test_explicit_null_clearing_a_stored_column_is_a_change(self):
data = UpdateTeamRequest(team_id="team-1", max_budget=None)
assert changed_team_fields(data, _team(max_budget=30.0)) == frozenset({"max_budget"})
def test_folded_field_sent_top_level_is_named_not_metadata(self):
data = UpdateTeamRequest(team_id="team-1", guardrails=["b"])
assert changed_team_fields(data, _team(metadata={"guardrails": ["a"]})) == frozenset({"guardrails"})
def test_folded_field_sent_inside_metadata_is_named_not_metadata(self):
data = UpdateTeamRequest(team_id="team-1", metadata={"guardrails": ["b"]})
assert changed_team_fields(data, _team(metadata={"guardrails": ["a"]})) == frozenset({"guardrails"})
def test_custom_metadata_key_change_is_attributed_to_metadata(self):
data = UpdateTeamRequest(team_id="team-1", metadata={"guardrails": ["a"], "cost_center": "b"})
existing = _team(metadata={"guardrails": ["a"], "cost_center": "a"})
assert changed_team_fields(data, existing) == frozenset({"metadata"})
def test_metadata_echo_with_top_level_override_only_names_the_override(self):
data = UpdateTeamRequest(team_id="team-1", guardrails=["b"], metadata={"guardrails": ["a"], "cost_center": "a"})
existing = _team(metadata={"guardrails": ["a"], "cost_center": "a"})
assert changed_team_fields(data, existing) == frozenset({"guardrails"})
def test_dropping_a_stored_key_from_submitted_metadata_is_a_change(self):
data = UpdateTeamRequest(team_id="team-1", metadata={"cost_center": "a"})
existing = _team(metadata={"cost_center": "a", "tags": ["x"], "logging": [{"callback": "langfuse"}]})
assert changed_team_fields(data, existing) == frozenset({"tags", "logging"})
def test_server_managed_metadata_key_is_ignored(self):
data = UpdateTeamRequest(team_id="team-1", metadata={"cost_center": "a"})
existing = _team(metadata={"cost_center": "a", "team_member_budget_id": "budget-1"})
assert changed_team_fields(data, existing) == frozenset()
def test_model_aliases_compare_against_the_model_table(self):
table = LiteLLM_ModelTable(model_aliases='{"fast": "gpt-4o-mini"}', created_by="a", updated_by="a")
same = UpdateTeamRequest(team_id="team-1", model_aliases={"fast": "gpt-4o-mini"})
different = UpdateTeamRequest(team_id="team-1", model_aliases={"fast": "gpt-4o"})
assert changed_team_fields(same, _team(litellm_model_table=table)) == frozenset()
assert changed_team_fields(different, _team(litellm_model_table=table)) == frozenset({"model_aliases"})
def test_empty_model_aliases_against_no_model_table_is_not_a_change(self):
assert changed_team_fields(UpdateTeamRequest(team_id="team-1", model_aliases={}), _team()) == frozenset()
def test_field_without_a_stored_counterpart_counts_as_changed_when_sent(self):
data = UpdateTeamRequest(team_id="team-1", team_member_budget=10.0)
assert changed_team_fields(data, _team()) == frozenset({"team_member_budget"})
class TestTeamAdminEditVerdict:
def test_no_permitted_fields_disables_editing_even_for_a_no_op(self):
verdict = team_admin_edit_verdict(UpdateTeamRequest(team_id="team-1"), _team(), frozenset())
assert verdict == TeamAdminEditingDisabled()
def test_allowed_request_keeps_only_the_changed_fields(self):
data = UpdateTeamRequest(team_id="team-1", tpm_limit=6, team_alias="alpha", budget_duration="30d")
existing = _team(team_alias="alpha", budget_duration="30d")
verdict = team_admin_edit_verdict(data, existing, frozenset({"tpm_limit"}))
assert isinstance(verdict, TeamAdminEditAllowed)
assert verdict.request.model_dump(exclude_unset=True) == {"team_id": "team-1", "tpm_limit": 6}
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"}))
assert isinstance(verdict, TeamAdminEditAllowed)
assert verdict.request.model_dump(exclude_unset=True) == {
"team_id": "team-1",
"metadata": {"guardrails": ["b"]},
}
def test_first_blocked_field_in_sorted_order_is_reported(self):
data = UpdateTeamRequest(team_id="team-1", tpm_limit=6, rpm_limit=6, blocked=True)
verdict = team_admin_edit_verdict(data, _team(), frozenset({"tpm_limit"}))
assert verdict == TeamAdminFieldNotPermitted(field="blocked")
class TestTeamAdminRequestOrRaise:
def test_allowed_hands_back_its_request(self):
request = UpdateTeamRequest(team_id="team-1", tpm_limit=6)
assert team_admin_request_or_raise(TeamAdminEditAllowed(request=request)) is request
def test_disabled_is_a_403_pointing_at_the_proxy_admin(self):
with pytest.raises(HTTPException) as exc:
team_admin_request_or_raise(TeamAdminEditingDisabled())
assert exc.value.status_code == 403
assert "cannot edit team settings" in exc.value.detail
assert "Settings > UI > Team admin editable fields" 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"))
assert exc.value.status_code == 403
assert "'blocked'" in exc.value.detail

View file

@ -1,6 +1,6 @@
import asyncio
import json
from contextlib import asynccontextmanager
from contextlib import asynccontextmanager, contextmanager
from datetime import datetime, timezone
from types import SimpleNamespace
from typing import Final, Optional, cast
@ -76,6 +76,31 @@ from litellm.types.proxy.management_endpoints.team_endpoints import (
client = TestClient(app)
@contextmanager
def _team_admin_may_edit(*fields: str):
"""Let team admins change ``fields`` on /team/update for the duration of the block.
The registry only lists the fields shipped so far (LIT-5722 adds them one PR at a time), so tests that
exercise the gates layered underneath the allow-list widen it here instead of asserting the early 403."""
with (
patch( # test-quality-ok: the registry is a module constant update_team reads directly; no seam to inject
"litellm.proxy.management_endpoints.team_endpoints.SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS",
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
def _not_org_admin():
"""update_team asks whether the caller administers the team's org before it settles for team admin;
a MagicMock prisma cannot answer that lookup, so pin it to False."""
return patch( # test-quality-ok: the org-admin lookup needs a real prisma client this file's MagicMock cannot provide
"litellm.proxy.management_endpoints.team_endpoints._is_user_org_admin_for_team",
AsyncMock(return_value=False),
)
def _wire_team_create_tx(prisma_client):
"""`/team/new` inserts the team and mirrors it onto the access groups in one transaction,
so a mocked client has to hand its team table back out of `db.tx()`.
@ -6393,6 +6418,7 @@ async def test_update_team_standalone_budget_raise_blocked_for_team_admin():
dummy_request = MagicMock(spec=Request)
with (
_team_admin_may_edit("max_budget"),
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
@ -6549,6 +6575,7 @@ async def test_update_team_standalone_budget_removal_blocked_for_team_admin():
dummy_request = MagicMock(spec=Request)
with (
_team_admin_may_edit("max_budget"),
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
@ -6618,6 +6645,7 @@ async def test_update_team_standalone_uncapped_team_admin_sets_finite_allowed(
dummy_request = MagicMock(spec=Request)
with (
_team_admin_may_edit("max_budget"),
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
@ -6712,6 +6740,7 @@ async def test_update_team_standalone_unchanged_budget_allowed(
dummy_request = MagicMock(spec=Request)
with (
_team_admin_may_edit("max_budget", "tpm_limit"),
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
@ -6810,6 +6839,7 @@ async def test_update_team_standalone_lower_budget_allowed(
dummy_request = MagicMock(spec=Request)
with (
_team_admin_may_edit("max_budget"),
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
@ -6912,6 +6942,8 @@ async def test_update_team_org_scoped_budget_exceeds_org_limit():
mock_org.litellm_budget_table = mock_budget_table
with (
_team_admin_may_edit("max_budget"),
_not_org_admin(),
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
@ -6992,6 +7024,7 @@ async def test_update_team_standalone_models_not_gated_by_user_limit(
dummy_request = MagicMock(spec=Request)
with (
_team_admin_may_edit("models"),
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
@ -7091,6 +7124,8 @@ async def test_update_team_org_scoped_budget_bypasses_user_limit(
mock_org.litellm_budget_table = mock_budget_table
with (
_team_admin_may_edit("max_budget"),
_not_org_admin(),
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
@ -7202,6 +7237,8 @@ async def test_update_team_org_scoped_models_bypasses_user_limit(
mock_org.litellm_budget_table = None
with (
_team_admin_may_edit("models"),
_not_org_admin(),
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
@ -7304,6 +7341,8 @@ async def test_update_team_org_scoped_models_not_in_org_models():
mock_org.litellm_budget_table = None
with (
_team_admin_may_edit("models"),
_not_org_admin(),
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
@ -7393,6 +7432,8 @@ async def test_update_team_org_scoped_models_with_all_proxy_models(
mock_org.litellm_budget_table = None
with (
_team_admin_may_edit("models"),
_not_org_admin(),
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
@ -7502,6 +7543,7 @@ async def test_update_team_tpm_limit_not_gated_by_user_limit(
dummy_request = MagicMock(spec=Request)
with (
_team_admin_may_edit("tpm_limit"),
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
@ -7584,6 +7626,7 @@ async def test_update_team_rpm_limit_not_gated_by_user_limit(
dummy_request = MagicMock(spec=Request)
with (
_team_admin_may_edit("rpm_limit"),
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
@ -7981,6 +8024,8 @@ async def test_update_team_org_scoped_tpm_exceeds_org_limit():
mock_org.litellm_budget_table = mock_budget_table
with (
_team_admin_may_edit("tpm_limit"),
_not_org_admin(),
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
@ -8067,6 +8112,8 @@ async def test_update_team_org_scoped_rpm_exceeds_org_limit():
mock_org.litellm_budget_table = mock_budget_table
with (
_team_admin_may_edit("rpm_limit"),
_not_org_admin(),
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
@ -8158,6 +8205,8 @@ async def test_update_team_org_scoped_tpm_rpm_bypasses_user_limit(
mock_org.litellm_budget_table = mock_budget_table
with (
_team_admin_may_edit("tpm_limit", "rpm_limit"),
_not_org_admin(),
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
@ -8286,6 +8335,7 @@ async def test_update_team_guardrails_with_org_id(
}
with (
_team_admin_may_edit("guardrails", "organization_id"),
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
@ -11177,8 +11227,8 @@ async def test_update_team_blocks_non_admin_passthrough_routes(mock_db_client):
mock_db_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=existing)
with patch(
"litellm.proxy.management_endpoints.team_endpoints._verify_team_access",
AsyncMock(return_value=None),
"litellm.proxy.management_endpoints.team_endpoints._resolve_team_access",
AsyncMock(return_value="org_admin"),
):
with pytest.raises(ProxyException) as exc:
await update_team(
@ -13246,6 +13296,7 @@ async def test_update_team_output_token_estimate_lowered_rejected_for_team_admin
with contextlib.ExitStack() as stack:
_wire_update_team(stack, {_TEAM_ESTIMATE: 4000})
stack.enter_context(_team_admin_may_edit("default_estimated_output_tokens"))
with pytest.raises(ProxyException) as exc:
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", default_estimated_output_tokens=1),
@ -13277,6 +13328,7 @@ async def test_update_team_output_token_estimate_unchanged_allows_team_admin_edi
with contextlib.ExitStack() as stack:
prisma = _wire_update_team(stack, {_TEAM_ESTIMATE: 4000})
stack.enter_context(_team_admin_may_edit("team_alias"))
await update_team(
data=UpdateTeamRequest(
team_id="test_team_id",
@ -13336,6 +13388,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"))
with pytest.raises(ProxyException) as exc:
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", metadata={_TEAM_BATCH_LIMIT: 10**12}),
@ -14894,3 +14947,369 @@ async def test_update_team_model_max_budget_raise_blocked_for_team_admin():
assert exc.value.code == "403"
assert "proxy admin" in str(exc.value.message).lower()
mock_prisma.db.litellm_teamtable.update.assert_not_awaited()
# ---------------------------------------------------------------------------
# LIT-5722: team admins reach update_team through self_managed_routes and are
# filtered by the team_admin_editable_team_fields setting.
# ---------------------------------------------------------------------------
_TEAM_ADMIN_CALLER = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER, api_key="sk-team-admin", user_id="team-admin"
)
_PROXY_ADMIN_CALLER = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin", user_id="admin")
def _update_request_stub():
from unittest.mock import Mock
from fastapi import Request
return Mock(spec=Request)
@pytest.mark.asyncio
async def test_update_team_team_admin_is_refused_before_any_write_when_no_fields_are_enabled():
import contextlib
with contextlib.ExitStack() as stack:
prisma = _wire_update_team(stack, {})
stack.enter_context(_team_admin_may_edit())
with pytest.raises(ProxyException) as exc:
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", team_alias="renamed"),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
assert str(exc.value.code) == "403"
assert "cannot edit team settings" in str(exc.value.message)
assert not prisma.db.litellm_teamtable.update.called
@pytest.mark.asyncio
async def test_update_team_configured_but_unsupported_field_does_not_open_editing():
"""Only fields in SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS count, whatever general_settings says."""
import contextlib
with contextlib.ExitStack() as stack:
prisma = _wire_update_team(stack, {})
stack.enter_context(
patch("litellm.proxy.proxy_server.general_settings", {"team_admin_editable_team_fields": ["team_alias"]}) # test-quality-ok: update_team reads general_settings as a proxy_server module global
)
with pytest.raises(ProxyException) as exc:
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", team_alias="renamed"),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
assert str(exc.value.code) == "403"
assert "cannot edit team settings" in str(exc.value.message)
assert not prisma.db.litellm_teamtable.update.called
@pytest.mark.asyncio
async def test_update_team_team_admin_changing_an_unpermitted_field_is_refused_by_name():
import contextlib
with contextlib.ExitStack() as stack:
prisma = _wire_update_team(stack, {})
stack.enter_context(_team_admin_may_edit("team_alias"))
with pytest.raises(ProxyException) as exc:
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", team_alias="renamed", tpm_limit=10),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
assert str(exc.value.code) == "403"
assert "'tpm_limit'" in str(exc.value.message)
assert not prisma.db.litellm_teamtable.update.called
@pytest.mark.asyncio
async def test_update_team_team_admin_echoing_unpermitted_fields_unchanged_is_allowed(
disable_audit_logging_for_mocked_team,
):
"""The dashboard resends the whole form, so only a value that differs from what is stored counts."""
import contextlib
with contextlib.ExitStack() as stack:
prisma = _wire_update_team(stack, {})
stack.enter_context(_team_admin_may_edit("team_alias"))
result = await update_team(
data=UpdateTeamRequest(team_id="test_team_id", team_alias="renamed", tpm_limit=None, models=[]),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
assert result["data"].team_id == "test_team_id"
assert prisma.db.litellm_teamtable.update.called
@pytest.mark.asyncio
async def test_update_team_team_admin_changes_tpm_limit_once_a_proxy_admin_enables_it(
disable_audit_logging_for_mocked_team,
):
"""tpm_limit is the first field a proxy admin can open to team admins; every other field stays admin-only."""
import contextlib
with contextlib.ExitStack() as stack:
prisma = _wire_update_team(stack, {})
stack.enter_context(
patch("litellm.proxy.proxy_server.general_settings", {"team_admin_editable_team_fields": ["tpm_limit"]}) # test-quality-ok: update_team reads general_settings as a proxy_server module global
)
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", tpm_limit=5000),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
with pytest.raises(ProxyException) as refused:
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", tpm_limit=6000, rpm_limit=10),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
assert prisma.db.litellm_teamtable.update.await_count == 1
assert prisma.db.litellm_teamtable.update.call_args.kwargs["data"]["tpm_limit"] == 5000
assert str(refused.value.code) == "403"
assert "'rpm_limit'" in str(refused.value.message)
@pytest.mark.asyncio
async def test_update_team_team_admin_resending_budget_settings_does_not_push_back_budget_resets(
disable_audit_logging_for_mocked_team,
):
"""A resent budget_duration or budget_limits would otherwise recompute the reset timestamps from now."""
import contextlib
stored_windows = [{"budget_duration": "7d", "max_budget": 5.0, "reset_at": "2026-09-20T00:00:00Z"}]
budgeted_team = MagicMock()
budgeted_team.metadata = {}
budgeted_team.model_dump.return_value = {
"team_id": "test_team_id",
"team_alias": "test_team",
"metadata": {},
"budget_duration": "30d",
"budget_limits": stored_windows,
"members_with_roles": [{"user_id": "team-admin", "role": "admin"}],
}
with contextlib.ExitStack() as stack:
prisma = _wire_update_team(stack, {})
prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=budgeted_team)
stack.enter_context(_team_admin_may_edit("tpm_limit"))
await update_team(
data=UpdateTeamRequest(
team_id="test_team_id", tpm_limit=5000, budget_duration="30d", budget_limits=stored_windows
),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
written = prisma.db.litellm_teamtable.update.call_args.kwargs["data"]
assert written["tpm_limit"] == 5000
assert not {"budget_duration", "budget_reset_at", "budget_limits"} & written.keys()
@pytest.mark.asyncio
async def test_update_team_holds_a_team_admin_to_the_org_tpm_limit(disable_audit_logging_for_mocked_team):
"""The org ceiling lives on the org's budget row, so /team/update must load it to enforce the cap."""
import contextlib
capped_org = LiteLLM_OrganizationTable(
organization_id="capped-org",
budget_id="capped-budget",
created_by="admin",
updated_by="admin",
litellm_budget_table=LiteLLM_BudgetTable(tpm_limit=10000),
)
async def org_lookup(**kwargs):
return capped_org if kwargs.get("include_budget_table") else capped_org.model_copy(
update={"litellm_budget_table": None}
)
org_team = MagicMock()
org_team.metadata = {}
org_team.organization_id = "capped-org"
org_team.model_dump.return_value = {
"team_id": "test_team_id",
"team_alias": "test_team",
"organization_id": "capped-org",
"metadata": {},
"members_with_roles": [{"user_id": "team-admin", "role": "admin"}],
}
with contextlib.ExitStack() as stack:
prisma = _wire_update_team(stack, {})
prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=org_team)
stack.enter_context(_team_admin_may_edit("tpm_limit"))
stack.enter_context(
patch( # test-quality-ok: the org-admin lookup needs a real prisma client this file's MagicMock cannot provide
"litellm.proxy.management_endpoints.team_endpoints._is_user_org_admin_for_team",
AsyncMock(return_value=False),
)
)
stack.enter_context(
patch( # test-quality-ok: update_team reads orgs through this module-level import; no seam to inject
"litellm.proxy.management_endpoints.team_endpoints.get_org_object",
AsyncMock(side_effect=org_lookup),
)
)
with pytest.raises(ProxyException) as over_cap:
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", tpm_limit=20000),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", tpm_limit=8000),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
assert str(over_cap.value.code) == "400"
assert "exceeds organization's tpm_limit (10000)" in str(over_cap.value.message)
assert prisma.db.litellm_teamtable.update.await_count == 1
assert prisma.db.litellm_teamtable.update.call_args.kwargs["data"]["tpm_limit"] == 8000
@pytest.mark.asyncio
async def test_update_team_org_admin_is_not_filtered_by_the_team_admin_field_list(
disable_audit_logging_for_mocked_team,
):
"""A caller who is both org admin and roster admin keeps unrestricted edits."""
import contextlib
with contextlib.ExitStack() as stack:
prisma = _wire_update_team(stack, {})
stack.enter_context(_team_admin_may_edit())
stack.enter_context(
patch( # test-quality-ok: the org-admin lookup needs a real prisma client this file's MagicMock cannot provide
"litellm.proxy.management_endpoints.team_endpoints._is_user_org_admin_for_team",
AsyncMock(return_value=True),
)
)
result = await update_team(
data=UpdateTeamRequest(team_id="test_team_id", team_alias="renamed"),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
assert result["data"].team_id == "test_team_id"
assert prisma.db.litellm_teamtable.update.called
@pytest.mark.asyncio
async def test_update_team_unknown_team_is_403_for_non_proxy_admins_and_404_for_proxy_admins():
"""Now that any authenticated caller reaches the handler, 'team not found' must not leak team ids."""
import contextlib
with contextlib.ExitStack() as stack:
prisma = _wire_update_team(stack, {})
prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=None)
with pytest.raises(ProxyException) as denied:
await update_team(
data=UpdateTeamRequest(team_id="no-such-team", team_alias="renamed"),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
with pytest.raises(ProxyException) as missing:
await update_team(
data=UpdateTeamRequest(team_id="no-such-team", team_alias="renamed"),
http_request=_update_request_stub(),
user_api_key_dict=_PROXY_ADMIN_CALLER,
)
assert str(denied.value.code) == "403"
assert "do not have access to this team" in str(denied.value.message)
assert "no-such-team" not in str(denied.value.message)
assert str(missing.value.code) == "404"
@pytest.mark.asyncio
async def test_resolve_team_access_ranks_proxy_admin_then_org_admin_then_team_admin():
from litellm.proxy.management_endpoints.team_endpoints import _resolve_team_access
team = LiteLLM_TeamTable(
team_id="team-1",
organization_id="org-1",
members_with_roles=[Member(user_id="team-admin", role="admin")],
)
roster_admin = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="team-admin")
outsider = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="someone-else")
org_lookup = AsyncMock(return_value=False)
with patch("litellm.proxy.management_endpoints.team_endpoints._is_user_org_admin_for_team", org_lookup): # test-quality-ok: the org-admin lookup needs a real prisma client this file's MagicMock cannot provide
assert await _resolve_team_access(team_obj=team, user_api_key_dict=_PROXY_ADMIN_CALLER) == "proxy_admin"
assert org_lookup.await_count == 0
assert await _resolve_team_access(team_obj=team, user_api_key_dict=roster_admin) == "team_admin"
assert await _resolve_team_access(team_obj=team, user_api_key_dict=outsider) is None
org_lookup.return_value = True
assert await _resolve_team_access(team_obj=team, user_api_key_dict=roster_admin) == "org_admin"
_ROSTER_ADMIN_CALLER = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="admin-1")
_MEMBER_CALLER = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="member-1")
@pytest.mark.parametrize(
"caller, org_admin, enabled_fields, expected",
[
pytest.param(_PROXY_ADMIN_CALLER, False, (), {"kind": "unrestricted"}, id="proxy-admin"),
pytest.param(
UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, user_id="viewer"),
False,
("tpm_limit",),
{"kind": "none"},
id="proxy-admin-viewer",
),
pytest.param(_ROSTER_ADMIN_CALLER, True, (), {"kind": "unrestricted"}, id="org-admin-who-is-also-team-admin"),
pytest.param(_ROSTER_ADMIN_CALLER, False, (), {"kind": "team_admin_disabled"}, id="team-admin-nothing-enabled"),
pytest.param(
_ROSTER_ADMIN_CALLER,
False,
("tpm_limit",),
{"kind": "team_admin", "editable_fields": ["tpm_limit"]},
id="team-admin-field-enabled",
),
pytest.param(_MEMBER_CALLER, False, ("tpm_limit",), {"kind": "none"}, id="plain-member"),
],
)
@pytest.mark.asyncio
async def test_team_info_reports_what_the_caller_may_edit(caller, org_admin, enabled_fields, expected):
"""The dashboard gates its edit form on this field instead of guessing the caller's role from the org list,
which is premium-gated and can be empty for a dual-role org admin."""
from fastapi import Request
from litellm.proxy.management_endpoints import team_endpoints
team_row = LiteLLM_TeamTable(
team_id="team-1",
organization_id="org-1",
members_with_roles=[Member(user_id="admin-1", role="admin"), Member(user_id="member-1", role="user")],
)
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(team_endpoints, "get_all_team_memberships", AsyncMock(return_value=[])), # test-quality-ok: no seam on team_info
patch.object( # test-quality-ok: the org-admin lookup needs a real prisma client this file's MagicMock cannot provide
team_endpoints, "_is_user_org_admin_for_team", AsyncMock(return_value=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=caller,
)
assert response["team_info"].caller_edit_access.model_dump(mode="json") == expected

View file

@ -3938,3 +3938,58 @@ async def test_ProxyConfig__init_guardrails_in_db_skips_only_the_unloadable_row(
assert sorted(handler.IN_MEMORY_GUARDRAILS) == ["first", "last"]
assert handler.reconciled_with == [{"first", "broken", "last"}]
# ---------------------------------------------------------------------------
# add_deployment: UI settings convergence
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_add_deployment_re_reads_ui_settings_so_other_pods_converge(monkeypatch):
"""The periodic config reload picks up a UI setting written through another pod.
Startup used to be the only read, so a proxy admin flipping a runtime flag reached the pod
that served the PATCH and nowhere else until every other pod restarted.
"""
general_settings: Dict[str, Any] = {"allow_agents_for_team_admins": False}
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings)
prisma_client = MagicMock()
prisma_client.db.litellm_config.find_many = AsyncMock(return_value=[])
prisma_client.db.litellm_config.find_first = AsyncMock(return_value=None)
prisma_client.db.litellm_credentialstable.find_many = AsyncMock(return_value=[])
prisma_client.db.litellm_uisettings.find_unique = AsyncMock(
return_value=SimpleNamespace(
ui_settings=json.dumps({"allow_agents_for_team_admins": True, "enable_chat_ui": False})
)
)
config = ProxyConfig()
config._should_load_db_object = MagicMock(return_value=False)
config._init_non_llm_objects_in_db = AsyncMock()
await config.add_deployment(prisma_client=prisma_client, proxy_logging_obj=MagicMock())
prisma_client.db.litellm_uisettings.find_unique.assert_awaited_once_with(where={"id": "ui_settings"})
assert general_settings["allow_agents_for_team_admins"] is True
assert "enable_chat_ui" not in general_settings
@pytest.mark.asyncio
async def test_add_deployment_syncs_ui_settings_even_when_the_model_reconcile_fails(monkeypatch):
"""A broken model reconcile must not strand every pod on stale settings."""
general_settings: Dict[str, Any] = {"allow_agents_for_team_admins": False}
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings)
prisma_client = MagicMock()
prisma_client.db.litellm_uisettings.find_unique = AsyncMock(
return_value=SimpleNamespace(ui_settings={"allow_agents_for_team_admins": True})
)
config = ProxyConfig()
config._should_load_db_object = MagicMock(side_effect=RuntimeError("db down"))
await config.add_deployment(prisma_client=prisma_client, proxy_logging_obj=MagicMock())
assert general_settings["allow_agents_for_team_admins"] is True

View file

@ -3266,3 +3266,174 @@ class TestPtuCostAttributionUISetting:
assert response.status_code == 400
assert "enable_ptu_cost_attribution" in str(response.json()["detail"])
assert not mock_prisma.db.litellm_uisettings.upsert.called
class TestTeamAdminEditableTeamFieldsSetting:
"""team_admin_editable_team_fields: the proxy-wide allow-list update_team applies to team admins."""
def _as_proxy_admin(self, monkeypatch):
from unittest.mock import AsyncMock, MagicMock
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_id="test-user-123",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
mock_prisma = MagicMock()
mock_prisma.db.litellm_uisettings.upsert = AsyncMock()
mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=None)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
return mock_prisma
def test_patch_rejects_field_names_the_proxy_does_not_support(self, monkeypatch):
mock_prisma = self._as_proxy_admin(monkeypatch)
monkeypatch.setattr(
"litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS",
frozenset({"tpm_limit"}),
)
try:
response = client.patch(
"/update/ui_settings",
json={"team_admin_editable_team_fields": ["tpm_limit", "blocked", "organization_id"]},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 400
detail = response.json()["detail"]["error"]
assert "['blocked', 'organization_id']" in detail
assert "['tpm_limit']" in detail
assert not mock_prisma.db.litellm_uisettings.upsert.called
def test_patch_rejects_a_non_list_value(self, monkeypatch):
self._as_proxy_admin(monkeypatch)
try:
response = client.patch("/update/ui_settings", json={"team_admin_editable_team_fields": "tpm_limit"})
finally:
app.dependency_overrides.clear()
assert response.status_code == 422
def test_patch_persists_and_syncs_the_list_to_general_settings(self, monkeypatch):
mock_prisma = self._as_proxy_admin(monkeypatch)
general_settings: dict = {"team_admin_editable_team_fields": []}
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings)
try:
response = client.patch("/update/ui_settings", json={"team_admin_editable_team_fields": ["tpm_limit"]})
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
stored = json.loads(mock_prisma.db.litellm_uisettings.upsert.call_args.kwargs["data"]["create"]["ui_settings"])
assert stored["team_admin_editable_team_fields"] == ["tpm_limit"]
assert general_settings["team_admin_editable_team_fields"] == ["tpm_limit"]
def test_patch_with_an_empty_list_turns_team_admin_editing_off_again(self, monkeypatch):
mock_prisma = self._as_proxy_admin(monkeypatch)
general_settings: dict = {"team_admin_editable_team_fields": ["tpm_limit"]}
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings)
try:
response = client.patch("/update/ui_settings", json={"team_admin_editable_team_fields": []})
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
stored = json.loads(mock_prisma.db.litellm_uisettings.upsert.call_args.kwargs["data"]["create"]["ui_settings"])
assert stored["team_admin_editable_team_fields"] == []
assert general_settings["team_admin_editable_team_fields"] == []
def test_get_reports_the_stored_list_and_advertises_supported_fields(self, mock_auth, monkeypatch):
from unittest.mock import AsyncMock, MagicMock
mock_prisma = MagicMock()
mock_db_record = MagicMock()
mock_db_record.ui_settings = {"team_admin_editable_team_fields": ["tpm_limit"]}
mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=mock_db_record)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
general_settings: dict = {}
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings)
response = client.get("/get/ui_settings")
assert response.status_code == 200
data = response.json()
assert data["values"]["team_admin_editable_team_fields"] == ["tpm_limit"]
assert general_settings["team_admin_editable_team_fields"] == ["tpm_limit"]
field_schema = data["field_schema"]["properties"]["team_admin_editable_team_fields"]
assert field_schema["type"] == "array"
assert field_schema["items"]["type"] == "string"
assert "tpm_limit" in field_schema["items"]["enum"]
class TestSyncUiSettingsToGeneralSettings:
"""The DB re-read each pod runs on startup and on every config reload."""
def _sync(self):
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
sync_ui_settings_to_general_settings,
)
return sync_ui_settings_to_general_settings
@pytest.mark.asyncio
async def test_applies_runtime_flags_and_leaves_other_ui_settings_alone(self, monkeypatch):
from unittest.mock import AsyncMock, MagicMock
general_settings: dict = {"allow_agents_for_team_admins": False}
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings)
mock_prisma = MagicMock()
record = MagicMock()
record.ui_settings = json.dumps(
{
"allow_agents_for_team_admins": True,
"team_admin_editable_team_fields": ["tpm_limit"],
"enable_chat_ui": False,
}
)
mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=record)
applied = await self._sync()(mock_prisma)
assert dict(applied) == {
"allow_agents_for_team_admins": True,
"team_admin_editable_team_fields": ["tpm_limit"],
}
assert general_settings["allow_agents_for_team_admins"] is True
assert general_settings["team_admin_editable_team_fields"] == ["tpm_limit"]
assert "enable_chat_ui" not in general_settings
@pytest.mark.asyncio
async def test_reads_a_row_the_prisma_client_already_deserialized(self, monkeypatch):
from unittest.mock import AsyncMock, MagicMock
general_settings: dict = {}
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings)
mock_prisma = MagicMock()
record = MagicMock()
record.ui_settings = {"team_admin_editable_team_fields": ["rpm_limit"]}
mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=record)
await self._sync()(mock_prisma)
assert general_settings["team_admin_editable_team_fields"] == ["rpm_limit"]
@pytest.mark.asyncio
async def test_without_a_stored_row_general_settings_is_left_untouched(self, monkeypatch):
from unittest.mock import AsyncMock, MagicMock
general_settings: dict = {"allow_agents_for_team_admins": True}
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings)
mock_prisma = MagicMock()
mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=None)
applied = await self._sync()(mock_prisma)
assert dict(applied) == {}
assert general_settings == {"allow_agents_for_team_admins": True}

View file

@ -17,6 +17,7 @@ import SCIMConfig from "@/components/SCIM";
import LoggingSettings from "@/components/Settings/AdminSettings/LoggingSettings/LoggingSettings";
import SSOSettings from "@/components/Settings/AdminSettings/SSOSettings/SSOSettings";
import UISettings from "@/components/Settings/AdminSettings/UISettings/UISettings";
import TeamAdminEditableFieldsSettings from "@/components/Settings/AdminSettings/UISettings/TeamAdminEditableFieldsSettings";
import UserBannerSettings from "@/components/Settings/AdminSettings/UserBannerSettings/UserBannerSettings";
import CyberArk from "@/components/Settings/AdminSettings/CyberArk/CyberArk";
import HashicorpVault from "@/components/Settings/AdminSettings/HashicorpVault/HashicorpVault";
@ -382,6 +383,7 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
children: (
<div className="flex flex-col gap-4">
<UISettings />
<TeamAdminEditableFieldsSettings />
<UserBannerSettings />
</div>
),

View file

@ -0,0 +1,175 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, renderWithProviders, screen, waitFor } from "@/../tests/test-utils";
import { toast } from "@/lib/toast";
import TeamAdminEditableFieldsSettings from "./TeamAdminEditableFieldsSettings";
const mockUseUISettings = vi.hoisted(() => vi.fn());
const mockUseUpdateUISettings = vi.hoisted(() => vi.fn());
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({ accessToken: "test-token" }),
}));
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
useUISettings: mockUseUISettings,
}));
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUpdateUISettings", () => ({
useUpdateUISettings: mockUseUpdateUISettings,
}));
const TPM_LABEL = "Tokens per minute Limit (TPM)";
const mockSettings = (supported: readonly string[], enabled: readonly string[]) =>
mockUseUISettings.mockReturnValue({
isLoading: false,
data: {
field_schema: {
properties: {
team_admin_editable_team_fields: {
description: "Fields a team admin may change",
items: { type: "string", enum: supported },
},
},
},
values: { team_admin_editable_team_fields: enabled },
},
});
const mockSave = ({
isPending = false,
outcome = "success",
}: {
isPending?: boolean;
outcome?: "success" | "error";
}) => {
const mutate = vi.fn((_settings: unknown, options: { onSuccess: () => void; onError: (error: Error) => void }) =>
outcome === "success" ? options.onSuccess() : options.onError(new Error("save failed")),
);
mockUseUpdateUISettings.mockReturnValue({ mutate, isPending });
return mutate;
};
const saveButton = () => screen.getByRole("button", { name: "Save" });
describe("TeamAdminEditableFieldsSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("explains that nothing can be enabled when the proxy supports no fields", () => {
mockSettings([], []);
mockSave({});
renderWithProviders(<TeamAdminEditableFieldsSettings />);
expect(screen.getByText("Team admins cannot edit team settings")).toBeInTheDocument();
expect(screen.getByText(/does not support enabling any team settings fields/)).toBeInTheDocument();
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument();
});
it("renders one checkbox per supported field, checked for the saved ones, with Save disabled until something changes", () => {
mockSettings(["max_budget", "tpm_limit"], ["tpm_limit"]);
mockSave({});
renderWithProviders(<TeamAdminEditableFieldsSettings />);
expect(screen.getByText("Team admin editable fields")).toBeInTheDocument();
expect(screen.getByText("1 field enabled")).toBeInTheDocument();
expect(screen.getByText("Fields a team admin may change")).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: "max_budget" })).not.toBeChecked();
expect(screen.getByRole("checkbox", { name: TPM_LABEL })).toBeChecked();
expect(saveButton()).toBeDisabled();
});
it("only saves a ticked field once Save is clicked", async () => {
mockSettings(["max_budget", "tpm_limit"], ["tpm_limit"]);
const mutate = mockSave({});
renderWithProviders(<TeamAdminEditableFieldsSettings />);
fireEvent.click(screen.getByRole("checkbox", { name: "max_budget" }));
expect(screen.getByRole("checkbox", { name: "max_budget" })).toBeChecked();
expect(mutate).not.toHaveBeenCalled();
fireEvent.click(saveButton());
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Team admin editable fields updated successfully"));
expect(mutate).toHaveBeenCalledWith(
{ team_admin_editable_team_fields: ["max_budget", "tpm_limit"] },
expect.anything(),
);
expect(saveButton()).toBeDisabled();
});
it("saves the list without an unticked field", async () => {
mockSettings(["max_budget", "tpm_limit"], ["max_budget", "tpm_limit"]);
const mutate = mockSave({});
renderWithProviders(<TeamAdminEditableFieldsSettings />);
fireEvent.click(screen.getByRole("checkbox", { name: TPM_LABEL }));
fireEvent.click(saveButton());
await waitFor(() => expect(mutate).toHaveBeenCalledTimes(1));
expect(mutate).toHaveBeenCalledWith({ team_admin_editable_team_fields: ["max_budget"] }, expect.anything());
});
it("disables Save again when the draft is ticked back to the saved list", () => {
mockSettings(["tpm_limit"], []);
mockSave({});
renderWithProviders(<TeamAdminEditableFieldsSettings />);
fireEvent.click(screen.getByRole("checkbox", { name: TPM_LABEL }));
expect(saveButton()).toBeEnabled();
fireEvent.click(screen.getByRole("checkbox", { name: TPM_LABEL }));
expect(screen.getByRole("checkbox", { name: TPM_LABEL })).not.toBeChecked();
expect(saveButton()).toBeDisabled();
});
it("treats a saved list in another order, or with fields this proxy dropped, as the same selection", () => {
mockSettings(["max_budget", "tpm_limit"], ["tpm_limit", "retired_field", "max_budget"]);
mockSave({});
renderWithProviders(<TeamAdminEditableFieldsSettings />);
expect(screen.getByText("2 fields enabled")).toBeInTheDocument();
fireEvent.click(screen.getByRole("checkbox", { name: TPM_LABEL }));
fireEvent.click(screen.getByRole("checkbox", { name: TPM_LABEL }));
expect(saveButton()).toBeDisabled();
});
it("keeps the draft and shows the error when the save fails", async () => {
mockSettings(["tpm_limit"], []);
const mutate = mockSave({ outcome: "error" });
renderWithProviders(<TeamAdminEditableFieldsSettings />);
fireEvent.click(screen.getByRole("checkbox", { name: TPM_LABEL }));
fireEvent.click(saveButton());
await waitFor(() => expect(toast.fromError).toHaveBeenCalledTimes(1));
expect(mutate).toHaveBeenCalledTimes(1);
expect(toast.success).not.toHaveBeenCalled();
expect(screen.getByRole("checkbox", { name: TPM_LABEL })).toBeChecked();
expect(saveButton()).toBeEnabled();
});
it("blocks ticking and saving while a save is in flight", () => {
mockSettings(["tpm_limit"], []);
const mutate = mockSave({ isPending: true });
renderWithProviders(<TeamAdminEditableFieldsSettings />);
fireEvent.click(screen.getByRole("checkbox", { name: TPM_LABEL }));
expect(screen.getByRole("checkbox", { name: TPM_LABEL })).not.toBeChecked();
expect(screen.getByRole("button", { name: "Saving..." })).toBeDisabled();
expect(mutate).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,138 @@
"use client";
import { Controller } from "react-hook-form";
import { z } from "zod/v4";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import { useUpdateUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUpdateUISettings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import {
parseSupportedTeamAdminEditableFields,
parseTeamAdminEditableFields,
teamAdminFieldLabel,
} from "@/components/team/teamAdminEditAccess";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Skeleton } from "@/components/ui/skeleton";
import { useZodForm } from "@/lib/forms/useZodForm";
import { toast } from "@/lib/toast";
const editableFieldsSchema = z.object({ team_admin_editable_team_fields: z.array(z.string()) });
type SaveEditableFields = ReturnType<typeof useUpdateUISettings>["mutate"];
export default function TeamAdminEditableFieldsSettings() {
const { accessToken } = useAuthorized();
const { data, isLoading } = useUISettings();
const { mutate: saveSettings, isPending } = useUpdateUISettings(accessToken);
const supportedFields = parseSupportedTeamAdminEditableFields(data?.field_schema);
const savedFields = parseTeamAdminEditableFields(data?.values);
const enabledFields = supportedFields.filter((field) => savedFields.includes(field));
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<CardTitle>Team admin editable fields</CardTitle>
<Badge variant={enabledFields.length > 0 ? "secondary" : "outline"}>
{enabledFields.length > 0
? `${enabledFields.length} field${enabledFields.length !== 1 ? "s" : ""} enabled`
: "Team admins cannot edit team settings"}
</Badge>
</div>
<CardDescription>
{data?.field_schema?.properties?.team_admin_editable_team_fields?.description ??
"Team settings fields a team admin may change on the teams they administer."}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-16 w-full" />
) : (
<TeamAdminEditableFieldsForm
key={enabledFields.join(",")}
enabledFields={enabledFields}
supportedFields={supportedFields}
isPending={isPending}
saveSettings={saveSettings}
/>
)}
</CardContent>
</Card>
);
}
interface TeamAdminEditableFieldsFormProps {
enabledFields: readonly string[];
supportedFields: readonly string[];
isPending: boolean;
saveSettings: SaveEditableFields;
}
function TeamAdminEditableFieldsForm({
enabledFields,
supportedFields,
isPending,
saveSettings,
}: TeamAdminEditableFieldsFormProps) {
const form = useZodForm(editableFieldsSchema, {
defaultValues: { team_admin_editable_team_fields: [...enabledFields] },
});
const submit = form.handleSubmit((values) =>
saveSettings(values, {
onSuccess: () => {
form.reset(values);
toast.success("Team admin editable fields updated successfully");
},
onError: (error) => {
toast.fromError(error);
},
}),
);
if (supportedFields.length === 0) {
return (
<p className="text-sm italic text-muted-foreground">
This proxy version does not support enabling any team settings fields for team admins yet.
</p>
);
}
return (
<form onSubmit={(event) => void submit(event)} className="space-y-4">
<Controller
control={form.control}
name="team_admin_editable_team_fields"
render={({ field }) => (
<div className="space-y-2">
{supportedFields.map((name) => {
const checkboxId = `team-admin-editable-${name}`;
return (
<label key={name} htmlFor={checkboxId} className="flex cursor-pointer items-center gap-2">
<Checkbox
id={checkboxId}
checked={field.value.includes(name)}
disabled={isPending}
onCheckedChange={(checked) =>
field.onChange(
supportedFields.filter((item) => (item === name ? checked : field.value.includes(item))),
)
}
/>
<span className="text-sm text-foreground">{teamAdminFieldLabel(name)}</span>
</label>
);
})}
</div>
)}
/>
<div className="flex justify-end">
<Button type="submit" disabled={isPending || !form.formState.isDirty}>
{isPending ? "Saving..." : "Save"}
</Button>
</div>
</form>
);
}

View file

@ -145,7 +145,7 @@ function UserBannerSettingsForm({ persisted, isLoading, isPending, saveBanner }:
</div>
)}
<div>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={isPending || messageMissing}>
{isPending ? "Saving..." : "Save banner"}
</Button>

View file

@ -0,0 +1,85 @@
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { fireEvent, renderWithProviders, screen, waitFor } from "@/../tests/test-utils";
import TeamAdminSettingsForm from "./TeamAdminSettingsForm";
const renderForm = (editableFields: ReadonlySet<string>, overrides: { isSaving?: boolean } = {}) => {
const onSave = vi.fn().mockResolvedValue(undefined);
const onCancel = vi.fn();
renderWithProviders(
<TeamAdminSettingsForm
initialValues={{ tpm_limit: 1000 }}
editableFields={editableFields}
isSaving={overrides.isSaving ?? false}
onCancel={onCancel}
onSave={onSave}
/>,
);
return { onSave, onCancel };
};
describe("TeamAdminSettingsForm", () => {
it("shows the team's current TPM limit when the proxy lets team admins edit it", () => {
renderForm(new Set(["tpm_limit"]));
expect(screen.getByLabelText("Tokens per minute Limit (TPM)")).toHaveValue(1000);
});
it("hides the TPM limit when the proxy has not enabled it for team admins", () => {
renderForm(new Set(["max_budget"]));
expect(screen.queryByLabelText("Tokens per minute Limit (TPM)")).not.toBeInTheDocument();
});
it("saves the new TPM limit and nothing else", async () => {
const user = userEvent.setup();
const { onSave } = renderForm(new Set(["tpm_limit"]));
fireEvent.change(screen.getByLabelText("Tokens per minute Limit (TPM)"), { target: { value: "5000" } });
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ tpm_limit: 5000 }));
});
it("saves a cleared TPM limit as no limit", async () => {
const user = userEvent.setup();
const { onSave } = renderForm(new Set(["tpm_limit"]));
fireEvent.change(screen.getByLabelText("Tokens per minute Limit (TPM)"), { target: { value: "" } });
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ tpm_limit: null }));
});
it("keeps Save disabled until the TPM limit differs from the team's", () => {
renderForm(new Set(["tpm_limit"]));
const tpmInput = screen.getByLabelText("Tokens per minute Limit (TPM)");
const save = screen.getByRole("button", { name: /save changes/i });
expect(save).toBeDisabled();
fireEvent.change(tpmInput, { target: { value: "5000" } });
expect(save).toBeEnabled();
fireEvent.change(tpmInput, { target: { value: "1000" } });
expect(save).toBeDisabled();
});
it("closes without saving on cancel", async () => {
const user = userEvent.setup();
const { onSave, onCancel } = renderForm(new Set(["tpm_limit"]));
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onSave).not.toHaveBeenCalled();
});
it("locks both buttons while a save is in flight", () => {
renderForm(new Set(["tpm_limit"]), { isSaving: true });
fireEvent.change(screen.getByLabelText("Tokens per minute Limit (TPM)"), { target: { value: "5000" } });
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
expect(screen.getByRole("button", { name: /save changes/i })).toBeDisabled();
});
});

View file

@ -0,0 +1,69 @@
"use client";
import { Save } from "lucide-react";
import { useWatch } from "react-hook-form";
import { z } from "zod/v4";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { FieldGroup } from "@/components/ui/field";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { useZodForm } from "@/lib/forms/useZodForm";
import NumericalInput from "../shared/numerical_input";
import {
teamAdminFieldLabel,
teamAdminSettingsChanges,
type TeamAdminSettingsChanges,
type TeamAdminSettingsValues,
} from "./teamAdminEditAccess";
const teamAdminSettingsSchema = z.object({
tpm_limit: z.union([z.string(), z.number()]).nullish(),
});
interface TeamAdminSettingsFormProps {
initialValues: TeamAdminSettingsValues;
editableFields: ReadonlySet<string>;
isSaving: boolean;
onCancel: () => void;
onSave: (changes: TeamAdminSettingsChanges) => Promise<void>;
}
export default function TeamAdminSettingsForm({
initialValues,
editableFields,
isSaving,
onCancel,
onSave,
}: TeamAdminSettingsFormProps) {
const form = useZodForm(teamAdminSettingsSchema, { defaultValues: initialValues });
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)));
return (
<form onSubmit={(event) => void submit(event)}>
<FieldGroup>
<p className="text-sm text-muted-foreground">
A proxy admin chose which settings team admins can change. Ask a proxy admin to change anything else.
</p>
{editableFields.has("tpm_limit") && (
<FormField control={form.control} name="tpm_limit" label={teamAdminFieldLabel("tpm_limit")}>
{({ ref, value, ...field }) => <NumericalInput {...field} ref={ref} value={value ?? ""} step={1} />}
</FormField>
)}
</FieldGroup>
<div className="mt-6 flex items-center justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSaving}>
Cancel
</Button>
<Button type="submit" disabled={isSaving || !hasChanges}>
{isSaving ? <UiLoadingSpinner className="size-4" /> : <Save className="size-4" />}
Save Changes
</Button>
</div>
</form>
);
}

View file

@ -69,6 +69,10 @@ vi.mock("@/app/(dashboard)/hooks/teams/useTeamMetadataSchema", () => ({
useTeamMetadataSchema: vi.fn(() => ({ data: [], isLoading: false })),
}));
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
useUISettings: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
useAllProxyModels: vi.fn(),
}));
@ -228,6 +232,7 @@ import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets";
import { useAccessGroups } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
const mockUseAllProxyModels = vi.mocked(useAllProxyModels);
const mockUseKeys = vi.mocked(useKeys);
@ -237,6 +242,7 @@ const mockUseCurrentUser = vi.mocked(useCurrentUser);
const mockUseMCPServers = vi.mocked(useMCPServers);
const mockUseMCPToolsets = vi.mocked(useMCPToolsets);
const mockUseAccessGroups = vi.mocked(useAccessGroups);
const mockUseUISettings = vi.mocked(useUISettings);
const createMockTeamData = (overrides = {}) => ({
team_id: "123",
@ -305,6 +311,10 @@ const seedDefaultMocks = () => {
isLoading: false,
isError: false,
} as any);
mockUseUISettings.mockReturnValue({
data: { values: {} },
isLoading: false,
} as any);
mockUseKeys.mockReturnValue({
data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 },
isPending: false,
@ -656,19 +666,9 @@ describe("TeamInfoView", () => {
});
});
it("shows edit tabs when the fetched team data marks the session user as team admin, even without the is_team_admin prop", async () => {
it("shows edit tabs when the proxy reports the session user may edit, even without the is_team_admin prop", async () => {
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({
members_with_roles: [
{
user_id: "user-1",
user_email: "admin@test.com",
role: "admin",
spend: 0,
budget_id: "budget1",
},
],
}),
createMockTeamData({ caller_edit_access: { kind: "team_admin_disabled" } }),
);
renderWithProviders(<TeamInfoView {...defaultProps} is_team_admin={false} is_proxy_admin={false} />);
@ -1863,6 +1863,92 @@ describe("TeamInfoView", () => {
});
});
});
describe("team admin edit access", () => {
const teamAdminProps = { ...defaultProps, is_proxy_admin: false, is_team_admin: true };
beforeEach(() => {
authState.userRole = "Internal User";
});
it("tells a team admin to ask a proxy admin when the proxy reports no team field is enabled for them", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({ caller_edit_access: { kind: "team_admin_disabled" } }),
);
renderWithProviders(<TeamInfoView {...teamAdminProps} />);
await user.click(await screen.findByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
expect(toast.error).toHaveBeenCalledWith("Team admins cannot edit team settings on this proxy", {
description: "Ask a proxy admin to enable fields under Settings > UI > Team admin editable fields.",
});
expect(screen.queryByLabelText("Team Name")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument();
});
it("gives a team admin only the fields the proxy enabled and sends only those on save", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({
tpm_limit: 1000,
caller_edit_access: { kind: "team_admin", editable_fields: ["tpm_limit"] },
}),
);
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
renderWithProviders(<TeamInfoView {...teamAdminProps} />);
await user.click(await screen.findByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
const tpmInput = await screen.findByLabelText("Tokens per minute Limit (TPM)");
expect(tpmInput).toHaveValue(1000);
expect(screen.queryByLabelText("Team Name")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Requests per minute Limit (RPM)")).not.toBeInTheDocument();
fireEvent.change(tpmInput, { target: { value: "5000" } });
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalledTimes(1));
expect(vi.mocked(networking.teamUpdateCall).mock.calls[0][1]).toStrictEqual({ team_id: "123", tpm_limit: 5000 });
expect(toast.success).toHaveBeenCalledWith("Team settings updated successfully");
expect(toast.error).not.toHaveBeenCalled();
});
it("opens the form when the proxy reports unrestricted access although the props only mark a team admin", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({ caller_edit_access: { kind: "unrestricted" } }),
);
renderWithProviders(<TeamInfoView {...teamAdminProps} />);
await user.click(await screen.findByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
expect(await screen.findByLabelText("Team Name")).toBeInTheDocument();
expect(toast.error).not.toHaveBeenCalled();
});
it("never gates a proxy admin on the team admin field list", async () => {
authState.userRole = "Admin";
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({ caller_edit_access: { kind: "unrestricted" } }),
);
renderWithProviders(<TeamInfoView {...defaultProps} />);
await user.click(await screen.findByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
expect(await screen.findByLabelText("Team Name")).toBeInTheDocument();
expect(toast.error).not.toHaveBeenCalled();
});
});
});
describe("TeamInfoView - which team member fields reach the update payload depends on the open sections", () => {

View file

@ -48,6 +48,14 @@ import React, { useEffect, useMemo, useState } from "react";
import { useFieldArray } from "react-hook-form";
import { z } from "zod/v4";
import GuardrailsSelect from "./GuardrailsSelect";
import {
type CallerEditAccess,
parseTeamEditAccess,
TEAM_ADMIN_EDITING_DISABLED_DESCRIPTION,
TEAM_ADMIN_EDITING_DISABLED_TITLE,
type TeamAdminSettingsChanges,
} from "./teamAdminEditAccess";
import TeamAdminSettingsForm from "./TeamAdminSettingsForm";
import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils";
import AccessGroupSelector from "../common_components/AccessGroupSelector";
import BudgetDurationDropdown, { NEVER_RESETS_BUDGET_DURATION } from "../common_components/budget_duration_dropdown";
@ -297,6 +305,7 @@ export interface TeamData {
guardrails?: string[];
policies?: string[];
object_permission?: ObjectPermission | null;
caller_edit_access?: CallerEditAccess;
team_member_budget_table: {
max_budget: number;
budget_duration: string | null;
@ -315,7 +324,6 @@ export interface TeamInfoProps {
accessToken: string | null;
is_team_admin: boolean;
is_proxy_admin: boolean;
is_org_admin?: boolean;
userModels: string[];
editTeam: boolean;
premiumUser?: boolean;
@ -531,7 +539,6 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
accessToken,
is_team_admin,
is_proxy_admin,
is_org_admin = false,
userModels,
editTeam,
premiumUser = false,
@ -575,7 +582,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const [teamModelMaxBudget, setTeamModelMaxBudget] = useState<ModelMaxBudget>({});
const routerSettingsRef = React.useRef<RouterSettingsAccordionRef>(null);
const [organization, setOrganization] = useState<Organization | null>(null);
const { userRole, userId } = useAuthorized();
const { userRole } = useAuthorized();
const { data: allMcpServers = [], isError: mcpServersFailed, isLoading: mcpServersLoading } = useMCPServers();
const { data: allMcpToolsets = [], isError: mcpToolsetsFailed, isLoading: mcpToolsetsLoading } = useMCPToolsets();
const { data: allAccessGroups = [], isError: accessGroupsFailed, isLoading: accessGroupsLoading } = useAccessGroups();
@ -585,14 +592,6 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const { data: teamMetadataSchemaFields = [], isLoading: isTeamMetadataSchemaLoading } = useTeamMetadataSchema();
const queryClient = useQueryClient();
// Check if user is org admin for this team's organization
const isOrgAdminForTeam = useMemo(() => {
const teamOrgId = teamData?.team_info?.organization_id;
if (!teamOrgId || !userId) return false;
const org = userOrganizations.find((o) => o.organization_id === teamOrgId);
return org?.members?.some((m: any) => m.user_id === userId && m.user_role === "org_admin") ?? false;
}, [teamData, userOrganizations, userId]);
// Models currently selected in the team edit form, used to scope the per-model
// rate limit dropdown to models this team actually has access to.
const watchedModels = form.watch("models");
@ -616,15 +615,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
return unfurlWildcardModelsInList(selected, userModels);
}, [watchedModels, teamData, userModels]);
const isTeamAdminFromTeamData = useMemo(
() =>
teamData?.team_info?.members_with_roles?.some(
(member) => member.user_id != null && member.user_id === userId && member.role === "admin",
) ?? false,
[teamData, userId],
);
const canEditTeam = is_team_admin || is_proxy_admin || is_org_admin || isOrgAdminForTeam || isTeamAdminFromTeamData;
const teamEditAccess = useMemo(() => parseTeamEditAccess(teamData?.team_info?.caller_edit_access), [teamData]);
const canEditTeam = is_team_admin || is_proxy_admin || teamEditAccess.kind !== "none";
const visibleTabs = useMemo(() => getTeamInfoVisibleTabs(canEditTeam), [canEditTeam]);
const defaultTabKey = useMemo(() => getTeamInfoDefaultTab(editTeam, canEditTeam), [editTeam, canEditTeam]);
const { onTabChange, hasVisited } = useVisitedTabs(defaultTabKey);
@ -644,6 +636,15 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
setIsEditing(true);
};
const openSettingsEditor = (modelAliases: Record<string, string>) => {
if (teamEditAccess.kind === "team_admin_disabled") {
toast.error(TEAM_ADMIN_EDITING_DISABLED_TITLE, { description: TEAM_ADMIN_EDITING_DISABLED_DESCRIPTION });
return;
}
setTeamModelAliases(modelAliases);
startEditing();
};
const applyKillSwitchToGuardrails = (checked: boolean) => {
const current = form.getValues("guardrails") ?? [];
const nonGlobals = current.filter((name) => !globalGuardrailNames.has(name));
@ -863,6 +864,27 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
setMemberToDelete(null);
};
const persistTeamUpdate = async (token: string, updateData: Record<string, unknown>) => {
await teamUpdateCall(token, updateData);
queryClient.invalidateQueries({ queryKey: organizationKeys.all });
toast.success("Team settings updated successfully");
setIsEditing(false);
fetchTeamInfo();
};
const saveTeamAdminSettings = async (changes: TeamAdminSettingsChanges) => {
if (!accessToken) return;
setIsTeamSaving(true);
try {
await persistTeamUpdate(accessToken, { team_id: teamId, ...changes });
} catch (error) {
console.error("Error updating team:", error);
} finally {
setIsTeamSaving(false);
}
};
const handleTeamUpdate = async (values: any) => {
try {
if (!accessToken) return;
@ -1113,12 +1135,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
}
}
await teamUpdateCall(accessToken, updateData);
queryClient.invalidateQueries({ queryKey: organizationKeys.all });
toast.success("Team settings updated successfully");
setIsEditing(false);
fetchTeamInfo();
await persistTeamUpdate(accessToken, updateData);
} catch (error) {
console.error("Error updating team:", error);
} finally {
@ -1136,6 +1153,17 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const { team_info: info } = teamData;
const teamAdminSettingsEditor =
teamEditAccess.kind === "team_admin" ? (
<TeamAdminSettingsForm
initialValues={{ tpm_limit: info.tpm_limit }}
editableFields={teamEditAccess.editableFields}
isSaving={isTeamSaving}
onCancel={() => setIsEditing(false)}
onSave={saveTeamAdminSettings}
/>
) : null;
const inheritedMcpServers = computeInheritedGrants(
info.access_group_mcp_server_ids,
info.access_group_details,
@ -1340,10 +1368,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
{canEditTeam && !isEditing && (
<Button
variant="outline"
onClick={() => {
setTeamModelAliases(info.litellm_model_table?.model_aliases ?? {});
startEditing();
}}
onClick={() => openSettingsEditor(info.litellm_model_table?.model_aliases ?? {})}
>
<Pencil />
Edit Settings
@ -1351,8 +1376,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
)}
</div>
{isEditing && isGuardrailsLoading ? (
<div className="p-4">Loading...</div>
{isEditing && (teamAdminSettingsEditor !== null || isGuardrailsLoading) ? (
teamAdminSettingsEditor ?? <div className="p-4">Loading...</div>
) : isEditing ? (
<TooltipProvider>
<form onSubmit={(event) => void form.handleSubmit(onTeamUpdateSubmit)(event)}>

View file

@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import {
parseSupportedTeamAdminEditableFields,
parseTeamAdminEditableFields,
parseTeamEditAccess,
teamAdminFieldLabel,
teamAdminSettingsChanges,
} from "./teamAdminEditAccess";
describe("teamAdminFieldLabel", () => {
it("names tpm_limit the way the team settings form does", () => {
expect(teamAdminFieldLabel("tpm_limit")).toBe("Tokens per minute Limit (TPM)");
});
it("falls back to the raw field name for a field the dashboard has no label for", () => {
expect(teamAdminFieldLabel("max_budget")).toBe("max_budget");
});
});
describe("teamAdminSettingsChanges", () => {
const tpmEnabled = new Set(["tpm_limit"]);
const stored = { tpm_limit: 1000 };
it.each([
["a typed number string", "5000", 5000],
["a number", 1200, 1200],
["zero", "0", 0],
["an emptied input", "", null],
["whitespace", " ", null],
["no limit", null, null],
["an unset value", undefined, null],
])("sends tpm_limit changed to %s", (_label, tpm_limit, expected) => {
expect(teamAdminSettingsChanges({ tpm_limit }, stored, tpmEnabled)).toStrictEqual({ tpm_limit: expected });
});
it.each([
["the stored number", 1000, { tpm_limit: 1000 }],
["the stored number typed back in", "1000", { tpm_limit: 1000 }],
["an emptied input over no stored limit", "", { tpm_limit: null }],
["an unset value over no stored limit", undefined, { tpm_limit: null }],
])("sends nothing for %s", (_label, tpm_limit, initialValues) => {
expect(teamAdminSettingsChanges({ tpm_limit }, initialValues, tpmEnabled)).toStrictEqual({});
});
it("leaves tpm_limit out when the proxy did not enable it for team admins", () => {
expect(teamAdminSettingsChanges({ tpm_limit: "5000" }, stored, new Set(["max_budget"]))).toStrictEqual({});
});
});
describe("parseTeamAdminEditableFields", () => {
it("returns the configured list", () => {
expect(parseTeamAdminEditableFields({ team_admin_editable_team_fields: ["tpm_limit", "rpm_limit"] })).toEqual([
"tpm_limit",
"rpm_limit",
]);
});
it.each([
["no values yet", undefined],
["setting missing", {}],
["setting is null", { team_admin_editable_team_fields: null }],
["setting is a string", { team_admin_editable_team_fields: "tpm_limit" }],
["list holds a non-string", { team_admin_editable_team_fields: ["tpm_limit", 7] }],
])("fails closed to an empty list when %s", (_label, values) => {
expect(parseTeamAdminEditableFields(values)).toEqual([]);
});
});
describe("parseSupportedTeamAdminEditableFields", () => {
it("reads the enum the proxy advertises on the setting's items schema", () => {
const schema = {
properties: {
team_admin_editable_team_fields: {
type: "array",
items: { type: "string", enum: ["max_budget", "tpm_limit"] },
},
},
};
expect(parseSupportedTeamAdminEditableFields(schema)).toEqual(["max_budget", "tpm_limit"]);
});
it.each([
["schema not loaded", undefined],
["property absent", { properties: {} }],
["items has no enum", { properties: { team_admin_editable_team_fields: { items: { type: "string" } } } }],
["enum is not a string list", { properties: { team_admin_editable_team_fields: { items: { enum: [1] } } } }],
])("returns no supported fields when %s", (_label, schema) => {
expect(parseSupportedTeamAdminEditableFields(schema)).toEqual([]);
});
});
describe("parseTeamEditAccess", () => {
it.each([
["unrestricted", { kind: "unrestricted" }],
["team_admin_disabled", { kind: "team_admin_disabled" }],
["none", { kind: "none" }],
])("passes the proxy's %s verdict through", (_kind, verdict) => {
expect(parseTeamEditAccess(verdict)).toEqual(verdict);
});
it("hands a team admin the fields the proxy enabled", () => {
expect(parseTeamEditAccess({ kind: "team_admin", editable_fields: ["tpm_limit"] })).toEqual({
kind: "team_admin",
editableFields: new Set(["tpm_limit"]),
});
});
it.each([
["the proxy sent nothing", undefined],
["the kind is unknown", { kind: "owner" }],
["a team admin verdict lacks its field list", { kind: "team_admin" }],
["the field list holds a non-string", { kind: "team_admin", editable_fields: [7] }],
])("fails closed to no access when %s", (_label, value) => {
expect(parseTeamEditAccess(value)).toEqual({ kind: "none" });
});
});

View file

@ -0,0 +1,78 @@
import { z } from "zod/v4";
export const TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING = "team_admin_editable_team_fields";
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 callerEditAccessSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("unrestricted") }),
z.object({ kind: z.literal("team_admin"), editable_fields: z.array(z.string()) }),
z.object({ kind: z.literal("team_admin_disabled") }),
z.object({ kind: z.literal("none") }),
]);
export type CallerEditAccess = z.infer<typeof callerEditAccessSchema>;
export type TeamEditAccess =
| { readonly kind: "unrestricted" }
| { readonly kind: "team_admin"; readonly editableFields: ReadonlySet<string> }
| { readonly kind: "team_admin_disabled" }
| { readonly kind: "none" };
const fieldListSchema = z.array(z.string()).catch([]);
export const parseTeamAdminEditableFields = (uiSettingsValues: unknown): readonly string[] => {
const values = z.record(z.string(), z.unknown()).catch({}).parse(uiSettingsValues);
return fieldListSchema.parse(values[TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING]);
};
export const parseSupportedTeamAdminEditableFields = (uiSettingsFieldSchema: unknown): readonly string[] => {
const property = z
.object({ properties: z.object({ [TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING]: z.object({ items: z.unknown() }) }) })
.safeParse(uiSettingsFieldSchema);
if (!property.success) return [];
const items = z
.object({ enum: z.unknown() })
.safeParse(property.data.properties[TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING].items);
return items.success ? fieldListSchema.parse(items.data.enum) : [];
};
const TEAM_ADMIN_FIELD_LABELS: ReadonlyMap<string, string> = new Map([["tpm_limit", "Tokens per minute Limit (TPM)"]]);
export const teamAdminFieldLabel = (field: string): string => TEAM_ADMIN_FIELD_LABELS.get(field) ?? field;
export interface TeamAdminSettingsValues {
readonly tpm_limit?: string | number | null;
}
export interface TeamAdminSettingsChanges {
readonly tpm_limit?: number | null;
}
const numberOrNull = (value: string | number | null | undefined): number | null => {
if (value === null || value === undefined || String(value).trim() === "") return null;
const parsed = Number(value);
return Number.isNaN(parsed) ? null : parsed;
};
export const teamAdminSettingsChanges = (
values: TeamAdminSettingsValues,
initialValues: TeamAdminSettingsValues,
editableFields: ReadonlySet<string>,
): TeamAdminSettingsChanges => {
const tpmLimit = numberOrNull(values.tpm_limit);
return editableFields.has("tpm_limit") && tpmLimit !== numberOrNull(initialValues.tpm_limit)
? { tpm_limit: tpmLimit }
: {};
};
export const parseTeamEditAccess = (callerEditAccess: unknown): TeamEditAccess => {
const parsed = callerEditAccessSchema.safeParse(callerEditAccess);
if (!parsed.success) return { kind: "none" };
if (parsed.data.kind === "team_admin") {
return { kind: "team_admin", editableFields: new Set(parsed.data.editable_fields) };
}
return parsed.data;
};