mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
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:
commit
43713f7508
24 changed files with 2428 additions and 264 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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()),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue