diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index 3d22923c0a8..0ecb9903bdd 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -831,6 +831,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",
"/team/member_update",
diff --git a/litellm/proxy/management_endpoints/team_admin_field_permissions.py b/litellm/proxy/management_endpoints/team_admin_field_permissions.py
new file mode 100644
index 00000000000..e26dd993e26
--- /dev/null
+++ b/litellm/proxy/management_endpoints/team_admin_field_permissions.py
@@ -0,0 +1,180 @@
+"""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): stays empty until each field's value-diff and dashboard wiring lands, one field per PR
+SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS: Final[frozenset[str]] = frozenset()
+
+_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:
+ 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 team_admin_edit_verdict(
+ data: UpdateTeamRequest,
+ existing: LiteLLM_TeamTable,
+ permitted: frozenset[str],
+) -> TeamAdminEditVerdict:
+ if not permitted:
+ return TeamAdminEditingDisabled()
+ blocked: Final = sorted(changed_team_fields(data, existing) - permitted)
+ if blocked:
+ return TeamAdminFieldNotPermitted(field=blocked[0])
+ return TeamAdminEditAllowed()
+
+
+def raise_for_team_admin_edit_verdict(verdict: TeamAdminEditVerdict) -> None:
+ match verdict:
+ case TeamAdminEditAllowed():
+ return
+ 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)
diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py
index e9e37540dd8..63ad9ad9037 100644
--- a/litellm/proxy/management_endpoints/team_endpoints.py
+++ b/litellm/proxy/management_endpoints/team_endpoints.py
@@ -18,7 +18,18 @@ from collections.abc import 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
@@ -115,6 +126,12 @@ from litellm.proxy.management_endpoints.organization_endpoints import (
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,
+ raise_for_team_admin_edit_verdict,
+ resolve_team_admin_editable_fields,
+ team_admin_edit_verdict,
+)
from litellm.proxy.management_helpers.access_group_team_sync import (
TEAM_ADVISORY_LOCK_SQL,
AccessGroupSyncTx,
@@ -429,35 +446,45 @@ async def _refresh_cached_team(
)
-async def _verify_team_access(
- team_obj: LiteLLM_TeamTable,
- user_api_key_dict: UserAPIKeyAuth,
-) -> None:
- """
- Verify the caller is authorized to manage the given team.
+TeamAccessRole: TypeAlias = Literal["proxy_admin", "org_admin", "team_admin"]
- Access is granted if:
- - Caller is a proxy admin, OR
- - Caller is an org admin for the team's organization, OR
- - Caller is a team admin of this team
-
- Raises HTTPException(403) otherwise.
- """
- if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN:
- return
-
- if _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj):
- return
-
- if await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=team_obj):
- return
+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,
+) -> 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 "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 "team_admin"
+
+ return None
+
+
+async def _verify_team_access(
+ team_obj: LiteLLM_TeamTable,
+ user_api_key_dict: UserAPIKeyAuth,
+) -> None:
+ """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()
+
+
class TeamMemberBudgetHandler:
"""Helper class to handle team member budget, RPM, and TPM limit operations"""
@@ -1984,6 +2011,7 @@ async def update_team(
try:
from litellm.proxy.management_helpers.audit_logs import is_audit_logging_enabled
from litellm.proxy.proxy_server import (
+ general_settings,
litellm_proxy_admin_name,
llm_router,
prisma_client,
@@ -2030,16 +2058,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":
+ raise_for_team_admin_edit_verdict(
+ team_admin_edit_verdict(
+ data=data,
+ existing=existing_team,
+ permitted=resolve_team_admin_editable_fields(
+ general_settings, SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS
+ ),
+ )
+ )
_existing_team_metadata: Final[object] = getattr(existing_team_row, "metadata", None)
enforce_output_token_estimates_are_admin_only(
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index ba1e2632489..ac4f8d90ebf 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -667,6 +667,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,
)
@@ -1722,10 +1725,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
@@ -7316,7 +7315,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)
@@ -9548,35 +9552,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(
@@ -12760,7 +12741,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
diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py
index c12d071dd36..de965aff889 100644
--- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py
+++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py
@@ -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
diff --git a/tests/proxy_behavior/management/test_team_update.py b/tests/proxy_behavior/management/test_team_update.py
index 23ea89fa74d..d6273e64132 100644
--- a/tests/proxy_behavior/management/test_team_update.py
+++ b/tests/proxy_behavior/management/test_team_update.py
@@ -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 ships 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),
]
diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py
index c8b3d789665..577ee851410 100644
--- a/tests/test_litellm/proxy/auth/test_route_checks.py
+++ b/tests/test_litellm/proxy/auth/test_route_checks.py
@@ -2890,45 +2890,53 @@ 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():
+ """/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."""
+ from litellm.proxy._types import LiteLLMRoutes
+
+ assert "/team/update" in LiteLLMRoutes.self_managed_routes.value
+
+ 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 ──
@@ -2991,10 +2999,11 @@ 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."""
+def test_patch_team_route_stays_out_of_self_managed_routes():
+ """Unlike POST /team/update, PATCH /team/{team_id} cannot be self-managed: its
+ template also matches /team/new (the collision footgun), so it stays reachable by
+ org admins (org_admin_allowed_routes) and proxy admins only, never by regular
+ internal users or through the role-agnostic self_managed_routes."""
from litellm.proxy._types import LiteLLMRoutes
assert RouteChecks.check_route_access(
@@ -3895,7 +3904,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",
],
)
diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_admin_field_permissions.py b/tests/test_litellm/proxy/management_endpoints/test_team_admin_field_permissions.py
new file mode 100644
index 00000000000..91479921c61
--- /dev/null
+++ b/tests/test_litellm/proxy/management_endpoints/test_team_admin_field_permissions.py
@@ -0,0 +1,125 @@
+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,
+ raise_for_team_admin_edit_verdict,
+ resolve_team_admin_editable_fields,
+ team_admin_edit_verdict,
+)
+
+_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_changes_within_permitted_fields_are_allowed(self):
+ data = UpdateTeamRequest(team_id="team-1", tpm_limit=6, team_alias="alpha")
+ verdict = team_admin_edit_verdict(data, _team(team_alias="alpha"), frozenset({"tpm_limit"}))
+ assert verdict == TeamAdminEditAllowed()
+
+ 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 TestRaiseForTeamAdminEditVerdict:
+ def test_allowed_does_not_raise(self):
+ assert raise_for_team_admin_edit_verdict(TeamAdminEditAllowed()) is None
+
+ def test_disabled_is_a_403_pointing_at_the_proxy_admin(self):
+ with pytest.raises(HTTPException) as exc:
+ raise_for_team_admin_edit_verdict(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:
+ raise_for_team_admin_edit_verdict(TeamAdminFieldNotPermitted(field="blocked"))
+ assert exc.value.status_code == 403
+ assert "'blocked'" in exc.value.detail
diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
index 2fb496d6231..90e85cfbdec 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
@@ -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 ships empty (LIT-5722 adds fields 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()`.
@@ -6099,6 +6124,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"),
@@ -6255,6 +6281,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"),
@@ -6324,6 +6351,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"),
@@ -6418,6 +6446,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"),
@@ -6516,6 +6545,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"),
@@ -6618,6 +6648,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"),
@@ -6698,6 +6730,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"),
@@ -6797,6 +6830,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"),
@@ -6908,6 +6943,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"),
@@ -7010,6 +7047,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"),
@@ -7099,6 +7138,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"),
@@ -7208,6 +7249,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"),
@@ -7290,6 +7332,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"),
@@ -7645,6 +7688,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"),
@@ -7731,6 +7776,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"),
@@ -7822,6 +7869,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"),
@@ -7950,6 +7999,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"),
@@ -10803,8 +10853,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(
@@ -12872,6 +12922,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),
@@ -12903,6 +12954,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",
@@ -12962,6 +13014,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}),
@@ -14111,3 +14164,178 @@ async def test_get_team_spend_by_user_rejects_bad_input(mock_db_client, team_ids
assert exc_info.value.status_code == 400
assert expected_error in str(exc_info.value.detail)
mock_db_client.db.query_raw.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# 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_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"
diff --git a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py
index e79448d0620..4bbc5c91d9f 100644
--- a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py
+++ b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py
@@ -3722,3 +3722,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
diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py
index 709447d23c0..bff2428d0b6 100644
--- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py
+++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py
@@ -3266,3 +3266,178 @@ 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)
+ monkeypatch.setattr(
+ "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS",
+ frozenset({"tpm_limit", "rpm_limit"}),
+ )
+ general_settings: dict = {"team_admin_editable_team_fields": ["rpm_limit"]}
+ 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 isinstance(field_schema["items"]["enum"], list)
+
+
+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}
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/TeamAdminEditableFieldsSettings.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/TeamAdminEditableFieldsSettings.test.tsx
new file mode 100644
index 00000000000..d1a44460190
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/TeamAdminEditableFieldsSettings.test.tsx
@@ -0,0 +1,91 @@
+import { describe, expect, it, vi } from "vitest";
+import userEvent from "@testing-library/user-event";
+
+import { renderWithProviders, screen } from "@/../tests/test-utils";
+
+import TeamAdminEditableFieldsSettings from "./TeamAdminEditableFieldsSettings";
+
+describe("TeamAdminEditableFieldsSettings", () => {
+ it("explains that nothing can be enabled when the proxy supports no fields", () => {
+ renderWithProviders(
+
Team admin editable fields
+{description}
} ++ This proxy version does not support enabling any team settings fields for team admins yet. +
+ ) : ( +