Merge pull request #23614 from BerriAI/litellm_team_default_permissions

[Feature] UI - Default Team Settings: Modernize page and fix defaults application
This commit is contained in:
yuneng-jiang
2026-03-14 11:12:07 -07:00
committed by GitHub
13 changed files with 1266 additions and 620 deletions
+1
View File
@@ -1417,6 +1417,7 @@ SECRET_MANAGER_REFRESH_INTERVAL = int(
)
LITELLM_SETTINGS_SAFE_DB_OVERRIDES = [
"default_internal_user_params",
"default_team_params",
"public_mcp_servers",
"public_agent_groups",
"public_model_groups",
@@ -252,6 +252,28 @@ class TeamMemberBudgetHandler:
data_dict.pop("team_member_tpm_limit", None)
def _get_default_team_param(field: str) -> Any:
"""
Returns a default value for the given field from litellm.default_team_params config.
Returns None if no default is configured.
For list fields containing enums (e.g. team_member_permissions), converts enum values to strings.
"""
default_params = litellm.default_team_params
if default_params is None:
return None
if isinstance(default_params, dict):
value = default_params.get(field)
else:
value = getattr(default_params, field, None)
if value is None:
return None
# Convert enum values in lists to strings
if isinstance(value, list):
return [v.value if hasattr(v, "value") else v for v in value]
return value
def _is_available_team(team_id: str, user_api_key_dict: UserAPIKeyAuth) -> bool:
if litellm.default_internal_user_params is None:
return False
@@ -833,16 +855,23 @@ async def new_team( # noqa: PLR0915
prisma_client=prisma_client,
)
# If max_budget is not explicitly provided in the request,
# check for a default value in the proxy configuration.
# Apply defaults from litellm.default_team_params for any fields
# not explicitly provided in the request.
for field in ("max_budget", "budget_duration", "tpm_limit", "rpm_limit", "team_member_permissions"):
if getattr(data, field, None) is None:
default_value = _get_default_team_param(field)
if default_value is not None:
setattr(data, field, default_value)
# Legacy fallback: apply max_budget from default_team_settings (YAML config)
# if still not set after checking default_team_params.
if data.max_budget is None:
if (
isinstance(litellm.default_team_settings, list)
and len(litellm.default_team_settings) > 0
and isinstance(litellm.default_team_settings[0], dict)
):
default_settings = litellm.default_team_settings[0]
default_budget = default_settings.get("max_budget")
default_budget = litellm.default_team_settings[0].get("max_budget")
if default_budget is not None:
data.max_budget = default_budget
@@ -16,11 +16,13 @@ from litellm.proxy.auth.auth_checks import get_team_object
from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.utils import PrismaClient
DEFAULT_TEAM_MEMBER_PERMISSIONS = [
BASELINE_TEAM_MEMBER_PERMISSIONS = [
KeyManagementRoutes.KEY_INFO,
KeyManagementRoutes.KEY_HEALTH,
]
DEFAULT_TEAM_MEMBER_PERMISSIONS = BASELINE_TEAM_MEMBER_PERMISSIONS
class TeamMemberPermissionChecks:
@staticmethod
@@ -29,15 +31,23 @@ class TeamMemberPermissionChecks:
team_table: LiteLLM_TeamTableCachedObj,
) -> List[KeyManagementRoutes]:
"""
Returns the permissions for a team member
Returns the permissions for a team member.
- If team has explicit permissions set (including []), use those
plus baseline permissions (/key/info, /key/health).
- If team has no permissions set (None), fall back to
DEFAULT_TEAM_MEMBER_PERMISSIONS.
"""
if team_table.team_member_permissions and isinstance(
if team_table.team_member_permissions is not None and isinstance(
team_table.team_member_permissions, list
):
return [
permissions = {
KeyManagementRoutes(permission)
for permission in team_table.team_member_permissions
]
}
# Always include baseline permissions
permissions.update(BASELINE_TEAM_MEMBER_PERMISSIONS)
return list(permissions)
return DEFAULT_TEAM_MEMBER_PERMISSIONS
@@ -328,11 +328,34 @@ async def _get_settings_with_schema(
}
# Add property descriptions
defs = schema.get("$defs", schema.get("definitions", {}))
for field_name, field_info in schema["properties"].items():
result["field_schema"]["properties"][field_name] = {
# For Optional fields, Pydantic v2 uses anyOf with [actual_type, null].
# Resolve the non-null variant to get the real type and items.
resolved = field_info
if "anyOf" in field_info:
for variant in field_info["anyOf"]:
if variant.get("type") != "null":
resolved = variant
break
prop_entry: dict = {
"description": field_info.get("description", ""),
"type": field_info.get("type", "string"),
"type": resolved.get("type", "string"),
}
# Pass through items info (including enum values) for array fields
# so the UI can render a multi-select dropdown
if "items" in resolved:
items = resolved["items"]
# Resolve $ref to enum definitions if needed
if "$ref" in items:
ref_name = items["$ref"].split("/")[-1]
ref_def = defs.get(ref_name, {})
if "enum" in ref_def:
prop_entry["items"] = {"enum": ref_def["enum"]}
else:
prop_entry["items"] = items
result["field_schema"]["properties"][field_name] = prop_entry
# Add nested object descriptions
for def_name, def_schema in schema.get("definitions", {}).items():
@@ -427,7 +450,6 @@ async def _update_litellm_setting(
DefaultInternalUserParams, DefaultTeamSSOParams, MCPSemanticFilterSettings
],
settings_key: str,
in_memory_var: Any,
success_message: str,
):
"""
@@ -436,7 +458,6 @@ async def _update_litellm_setting(
Args:
settings: The settings object to update
settings_key: The key in litellm_settings to update
in_memory_var: The in-memory variable to update
success_message: Message to return on success
"""
from litellm.proxy.proxy_server import proxy_config, store_model_in_db
@@ -449,13 +470,16 @@ async def _update_litellm_setting(
},
)
# Update the in-memory settings
in_memory_var = settings.model_dump(exclude_none=True)
setattr(litellm, settings_key, in_memory_var)
# Load existing config
# Load existing config first, then set in-memory value after,
# because get_config() may overwrite litellm.<key> with stale DB values
# via LITELLM_SETTINGS_SAFE_DB_OVERRIDES.
config = await proxy_config.get_config()
# Update the in-memory settings (after get_config to avoid stale override)
setattr(litellm, settings_key, in_memory_var)
# Update config with new settings
if "litellm_settings" not in config:
config["litellm_settings"] = {}
@@ -495,7 +519,6 @@ async def update_internal_user_settings(
return await _update_litellm_setting(
settings=settings,
settings_key="default_internal_user_params",
in_memory_var=litellm.default_internal_user_params,
success_message="Internal user settings updated successfully",
)
@@ -513,7 +536,6 @@ async def update_default_team_settings(settings: DefaultTeamSSOParams):
return await _update_litellm_setting(
settings=settings,
settings_key="default_team_params",
in_memory_var=litellm.default_team_params,
success_message="Default team settings updated successfully",
)
@@ -935,7 +957,6 @@ async def update_mcp_semantic_filter_settings(
result = await _update_litellm_setting(
settings=settings,
settings_key="mcp_semantic_tool_filter",
in_memory_var=None,
success_message="MCP Semantic Filter settings updated successfully. Changes will be applied across all pods within 10 seconds.",
)
try:
@@ -3,7 +3,7 @@ from typing import Dict, List, Literal, Optional, Union
from pydantic import BaseModel, Field
from typing_extensions import TypedDict
from litellm.proxy._types import LitellmUserRoles
from litellm.proxy._types import KeyManagementRoutes, LitellmUserRoles
from litellm.types.utils import LiteLLMPydanticObjectBase
@@ -205,6 +205,10 @@ class DefaultTeamSSOParams(LiteLLMPydanticObjectBase):
default=None,
description="Default rpm limit for new automatically created teams",
)
team_member_permissions: Optional[List[KeyManagementRoutes]] = Field(
default=None,
description="Default permissions granted to members of newly created teams (e.g. /key/generate, /key/update, /key/delete). /key/info and /key/health are always included.",
)
class InProductNudgeResponse(BaseModel):
@@ -202,7 +202,6 @@ async def test_scim_create_user_respects_default_role_set_via_ui(mocker, monkeyp
await _update_litellm_setting(
settings=settings,
settings_key="default_internal_user_params",
in_memory_var=litellm.default_internal_user_params,
success_message="ok",
)
@@ -0,0 +1,487 @@
"""
Tests for applying default team params during team creation
and loading default_team_params from DB on startup.
"""
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
sys.path.insert(
0, os.path.abspath("../../../")
) # Adds the parent directory to the system path
import litellm
from litellm.proxy._types import (
NewTeamRequest,
UserAPIKeyAuth,
LitellmUserRoles,
)
from litellm.proxy.management_endpoints.team_endpoints import (
_get_default_team_param,
)
from litellm.proxy.proxy_server import ProxyConfig
# ---------------------------------------------------------------------------
# _update_config_fields: default_team_params loaded from DB on startup
# ---------------------------------------------------------------------------
class TestConfigFieldsDefaultTeamParams:
"""Tests that _update_config_fields applies default_team_params from DB."""
def _make_proxy_config(self) -> ProxyConfig:
return ProxyConfig()
def test_default_team_params_applied_from_db(self, monkeypatch):
"""default_team_params in DB is set on litellm module during config load."""
monkeypatch.setattr(litellm, "default_team_params", None)
pc = self._make_proxy_config()
db_settings = {
"default_team_params": {
"max_budget": 500.0,
"budget_duration": "30d",
"tpm_limit": 1000,
"rpm_limit": 200,
"team_member_permissions": ["/key/generate", "/key/delete"],
}
}
pc._update_config_fields(
current_config={},
param_name="litellm_settings",
db_param_value=db_settings,
)
assert litellm.default_team_params == db_settings["default_team_params"]
def test_default_team_params_merged_into_config_dict(self):
"""DB default_team_params ends up in the returned config dict."""
pc = self._make_proxy_config()
config = {"litellm_settings": {"cache": False}}
db_settings = {
"default_team_params": {
"max_budget": 100.0,
}
}
result = pc._update_config_fields(
current_config=config,
param_name="litellm_settings",
db_param_value=db_settings,
)
assert result["litellm_settings"]["default_team_params"] == {"max_budget": 100.0}
# Existing keys preserved
assert result["litellm_settings"]["cache"] is False
def test_default_team_params_not_applied_when_absent(self, monkeypatch):
"""When DB litellm_settings has no default_team_params, it stays None."""
monkeypatch.setattr(litellm, "default_team_params", None)
pc = self._make_proxy_config()
pc._update_config_fields(
current_config={},
param_name="litellm_settings",
db_param_value={"cache": True},
)
assert litellm.default_team_params is None
def test_default_team_params_overrides_yaml_value(self, monkeypatch):
"""DB value for default_team_params overrides YAML value via deep merge."""
monkeypatch.setattr(litellm, "default_team_params", None)
pc = self._make_proxy_config()
config = {
"litellm_settings": {
"default_team_params": {
"max_budget": 50.0,
"tpm_limit": 100,
}
}
}
db_settings = {
"default_team_params": {
"max_budget": 200.0,
"rpm_limit": 500,
}
}
result = pc._update_config_fields(
current_config=config,
param_name="litellm_settings",
db_param_value=db_settings,
)
merged = result["litellm_settings"]["default_team_params"]
# DB value wins for max_budget
assert merged["max_budget"] == 200.0
# DB adds rpm_limit
assert merged["rpm_limit"] == 500
# YAML tpm_limit preserved (not in DB)
assert merged["tpm_limit"] == 100
# setattr should have applied the DB value
assert litellm.default_team_params == db_settings["default_team_params"]
# ---------------------------------------------------------------------------
# new_team: default params applied to team creation
#
# We test the defaults-application logic by calling new_team with
# prisma_client patched at the proxy_server module level (where the
# endpoint imports it from).
# ---------------------------------------------------------------------------
class TestNewTeamDefaultParamsApplied:
"""Tests that /team/new applies defaults from litellm.default_team_params."""
@pytest.fixture(autouse=True)
def setup_mocks(self, monkeypatch):
"""Set up common mocks for team creation tests."""
mock_prisma = AsyncMock()
mock_prisma.insert_data = AsyncMock(
return_value=MagicMock(
team_id="test-team-id",
team_alias="test-team",
)
)
mock_prisma.get_generic_data = AsyncMock(return_value=None)
mock_prisma.db = MagicMock()
mock_prisma.db.litellm_teamtable = MagicMock()
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=None)
mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0)
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client", mock_prisma
)
# Reset default_team_settings to avoid legacy fallback interference
monkeypatch.setattr(litellm, "default_team_settings", None)
def _make_admin_auth(self) -> UserAPIKeyAuth:
return UserAPIKeyAuth(
user_id="admin-user",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
@pytest.mark.asyncio
async def test_all_defaults_applied_when_not_provided(self, monkeypatch):
"""When no budget/rate/permission fields are in the request, all defaults apply."""
from litellm.proxy.management_endpoints.team_endpoints import new_team
monkeypatch.setattr(
litellm,
"default_team_params",
{
"max_budget": 100.0,
"budget_duration": "30d",
"tpm_limit": 200,
"rpm_limit": 500,
"team_member_permissions": ["/key/generate", "/key/update"],
},
)
data = NewTeamRequest(team_alias="my-team")
auth = self._make_admin_auth()
try:
await new_team(
data=data,
user_api_key_dict=auth,
http_request=MagicMock(),
)
except Exception:
pass # May fail on downstream mocks, that's OK
# Verify defaults were set on the data object
assert data.max_budget == 100.0
assert data.budget_duration == "30d"
assert data.tpm_limit == 200
assert data.rpm_limit == 500
assert data.team_member_permissions == ["/key/generate", "/key/update"]
@pytest.mark.asyncio
async def test_explicit_values_not_overridden(self, monkeypatch):
"""When request provides explicit values, defaults do not override them."""
from litellm.proxy.management_endpoints.team_endpoints import new_team
monkeypatch.setattr(
litellm,
"default_team_params",
{
"max_budget": 100.0,
"budget_duration": "30d",
"tpm_limit": 200,
"rpm_limit": 500,
"team_member_permissions": ["/key/generate"],
},
)
data = NewTeamRequest(
team_alias="my-team",
max_budget=50.0,
budget_duration="7d",
tpm_limit=999,
rpm_limit=888,
team_member_permissions=["/key/delete"],
)
auth = self._make_admin_auth()
try:
await new_team(
data=data,
user_api_key_dict=auth,
http_request=MagicMock(),
)
except Exception:
pass
# Explicit values preserved
assert data.max_budget == 50.0
assert data.budget_duration == "7d"
assert data.tpm_limit == 999
assert data.rpm_limit == 888
assert data.team_member_permissions == ["/key/delete"]
@pytest.mark.asyncio
async def test_partial_defaults_applied(self, monkeypatch):
"""Only missing fields get defaults; provided fields are untouched."""
from litellm.proxy.management_endpoints.team_endpoints import new_team
monkeypatch.setattr(
litellm,
"default_team_params",
{
"max_budget": 100.0,
"budget_duration": "30d",
"tpm_limit": 200,
"rpm_limit": 500,
},
)
data = NewTeamRequest(
team_alias="my-team",
max_budget=75.0, # explicit
# budget_duration, tpm_limit, rpm_limit not set → defaults apply
)
auth = self._make_admin_auth()
try:
await new_team(
data=data,
user_api_key_dict=auth,
http_request=MagicMock(),
)
except Exception:
pass
assert data.max_budget == 75.0 # explicit, not overridden
assert data.budget_duration == "30d" # default applied
assert data.tpm_limit == 200 # default applied
assert data.rpm_limit == 500 # default applied
@pytest.mark.asyncio
async def test_no_defaults_when_config_is_none(self, monkeypatch):
"""When default_team_params is None, no defaults applied."""
from litellm.proxy.management_endpoints.team_endpoints import new_team
monkeypatch.setattr(litellm, "default_team_params", None)
data = NewTeamRequest(team_alias="my-team")
auth = self._make_admin_auth()
try:
await new_team(
data=data,
user_api_key_dict=auth,
http_request=MagicMock(),
)
except Exception:
pass
assert data.max_budget is None
assert data.budget_duration is None
assert data.tpm_limit is None
assert data.rpm_limit is None
assert data.team_member_permissions is None
@pytest.mark.asyncio
async def test_legacy_default_team_settings_fallback(self, monkeypatch):
"""Legacy default_team_settings YAML config applies max_budget as fallback."""
from litellm.proxy.management_endpoints.team_endpoints import new_team
monkeypatch.setattr(litellm, "default_team_params", None)
monkeypatch.setattr(
litellm,
"default_team_settings",
[{"team_id": "default", "max_budget": 999.0}],
)
data = NewTeamRequest(team_alias="my-team")
auth = self._make_admin_auth()
try:
await new_team(
data=data,
user_api_key_dict=auth,
http_request=MagicMock(),
)
except Exception:
pass
assert data.max_budget == 999.0
@pytest.mark.asyncio
async def test_default_team_params_takes_priority_over_legacy(self, monkeypatch):
"""default_team_params max_budget takes priority over legacy default_team_settings."""
from litellm.proxy.management_endpoints.team_endpoints import new_team
monkeypatch.setattr(
litellm,
"default_team_params",
{"max_budget": 100.0},
)
monkeypatch.setattr(
litellm,
"default_team_settings",
[{"team_id": "default", "max_budget": 999.0}],
)
data = NewTeamRequest(team_alias="my-team")
auth = self._make_admin_auth()
try:
await new_team(
data=data,
user_api_key_dict=auth,
http_request=MagicMock(),
)
except Exception:
pass
# default_team_params wins (100.0), legacy fallback (999.0) not used
assert data.max_budget == 100.0
# ---------------------------------------------------------------------------
# _update_litellm_setting: setattr ordering
# ---------------------------------------------------------------------------
class TestUpdateLitellmSettingOrdering:
"""Tests that _update_litellm_setting sets in-memory value AFTER get_config,
so stale DB values from LITELLM_SETTINGS_SAFE_DB_OVERRIDES don't overwrite it."""
@pytest.mark.asyncio
async def test_setattr_not_overwritten_by_get_config(self, monkeypatch):
"""The new in-memory value survives get_config() which may load stale DB values."""
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
_update_litellm_setting,
)
from litellm.types.proxy.management_endpoints.ui_sso import (
DefaultTeamSSOParams,
)
# Simulate stale DB state: get_config returns old default_team_params
stale_value = {"max_budget": 50.0}
monkeypatch.setattr(litellm, "default_team_params", stale_value)
# get_config will overwrite litellm.default_team_params with stale DB value
async def mock_get_config():
# Simulate what _update_config_from_db does for safe overrides
litellm.default_team_params = stale_value
return {
"litellm_settings": {
"default_team_params": stale_value,
}
}
saved_configs = []
async def mock_save_config(new_config=None):
saved_configs.append(new_config)
from litellm.proxy.proxy_server import proxy_config
monkeypatch.setattr(proxy_config, "get_config", mock_get_config)
monkeypatch.setattr(proxy_config, "save_config", mock_save_config)
monkeypatch.setattr(
"litellm.proxy.proxy_server.store_model_in_db", True
)
# New settings to save
new_settings = DefaultTeamSSOParams(
max_budget=200.0,
budget_duration="7d",
rpm_limit=1000,
)
result = await _update_litellm_setting(
settings=new_settings,
settings_key="default_team_params",
success_message="Updated",
)
# In-memory value should be the NEW value, not the stale one
expected = new_settings.model_dump(exclude_none=True)
assert litellm.default_team_params == expected
# Saved config should contain the new value
assert len(saved_configs) == 1
saved_settings = saved_configs[0]["litellm_settings"]["default_team_params"]
assert saved_settings == expected
# Return value should reflect the new settings
assert result["settings"] == expected
@pytest.mark.asyncio
async def test_requires_store_model_in_db(self, monkeypatch):
"""Raises HTTPException when store_model_in_db is not True."""
from fastapi import HTTPException
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
_update_litellm_setting,
)
from litellm.types.proxy.management_endpoints.ui_sso import (
DefaultTeamSSOParams,
)
monkeypatch.setattr(
"litellm.proxy.proxy_server.store_model_in_db", False
)
with pytest.raises(HTTPException) as exc_info:
await _update_litellm_setting(
settings=DefaultTeamSSOParams(max_budget=100.0),
settings_key="default_team_params",
success_message="Updated",
)
assert exc_info.value.status_code == 500
# ---------------------------------------------------------------------------
# LITELLM_SETTINGS_SAFE_DB_OVERRIDES contains default_team_params
# ---------------------------------------------------------------------------
class TestSafeDbOverrides:
"""Verify default_team_params is in the safe overrides list."""
def test_default_team_params_in_safe_overrides(self):
from litellm.constants import LITELLM_SETTINGS_SAFE_DB_OVERRIDES
assert "default_team_params" in LITELLM_SETTINGS_SAFE_DB_OVERRIDES
def test_default_internal_user_params_in_safe_overrides(self):
"""Sanity: default_internal_user_params was already in the list."""
from litellm.constants import LITELLM_SETTINGS_SAFE_DB_OVERRIDES
assert "default_internal_user_params" in LITELLM_SETTINGS_SAFE_DB_OVERRIDES
@@ -0,0 +1,190 @@
import os
import sys
from unittest.mock import MagicMock
import pytest
sys.path.insert(
0, os.path.abspath("../../..")
) # Adds the parent directory to the system path
from litellm.proxy._types import KeyManagementRoutes, Member
from litellm.proxy.management_helpers.team_member_permission_checks import (
BASELINE_TEAM_MEMBER_PERMISSIONS,
TeamMemberPermissionChecks,
)
def _make_team_table(team_member_permissions):
"""Create a mock team table object with given permissions."""
team = MagicMock()
team.team_member_permissions = team_member_permissions
return team
class TestGetPermissionsForTeamMember:
def test_none_permissions_returns_defaults(self):
"""When team_member_permissions is None, return DEFAULT_TEAM_MEMBER_PERMISSIONS."""
team = _make_team_table(None)
member = MagicMock(spec=Member)
result = TeamMemberPermissionChecks.get_permissions_for_team_member(
team_member_object=member, team_table=team
)
assert set(result) == set(BASELINE_TEAM_MEMBER_PERMISSIONS)
def test_empty_list_includes_baseline(self):
"""When team_member_permissions is [], baseline permissions are still included."""
team = _make_team_table([])
member = MagicMock(spec=Member)
result = TeamMemberPermissionChecks.get_permissions_for_team_member(
team_member_object=member, team_table=team
)
assert KeyManagementRoutes.KEY_INFO in result
assert KeyManagementRoutes.KEY_HEALTH in result
def test_explicit_permissions_include_baseline(self):
"""When explicit permissions are set, baseline is always included."""
team = _make_team_table(["/key/generate", "/key/delete"])
member = MagicMock(spec=Member)
result = TeamMemberPermissionChecks.get_permissions_for_team_member(
team_member_object=member, team_table=team
)
assert KeyManagementRoutes.KEY_GENERATE in result
assert KeyManagementRoutes.KEY_DELETE in result
assert KeyManagementRoutes.KEY_INFO in result
assert KeyManagementRoutes.KEY_HEALTH in result
def test_explicit_permissions_with_baseline_no_duplicates(self):
"""When explicit permissions already include baseline, no duplicates."""
team = _make_team_table(["/key/info", "/key/generate"])
member = MagicMock(spec=Member)
result = TeamMemberPermissionChecks.get_permissions_for_team_member(
team_member_object=member, team_table=team
)
# Using set ensures no duplicates from the implementation
assert KeyManagementRoutes.KEY_INFO in result
assert KeyManagementRoutes.KEY_GENERATE in result
assert KeyManagementRoutes.KEY_HEALTH in result
class TestGetDefaultTeamParam:
def test_returns_none_when_no_config(self, monkeypatch):
"""Returns None when litellm.default_team_params is None."""
import litellm
from litellm.proxy.management_endpoints.team_endpoints import (
_get_default_team_param,
)
monkeypatch.setattr(litellm, "default_team_params", None)
assert _get_default_team_param("team_member_permissions") is None
assert _get_default_team_param("max_budget") is None
def test_returns_none_when_field_not_set(self, monkeypatch):
"""Returns None when default_team_params exists but the field is not set."""
import litellm
from litellm.proxy.management_endpoints.team_endpoints import (
_get_default_team_param,
)
monkeypatch.setattr(litellm, "default_team_params", {"models": ["gpt-4"]})
assert _get_default_team_param("team_member_permissions") is None
assert _get_default_team_param("max_budget") is None
def test_returns_permissions_from_dict_config(self, monkeypatch):
"""Returns permissions when default_team_params is a dict."""
import litellm
from litellm.proxy.management_endpoints.team_endpoints import (
_get_default_team_param,
)
monkeypatch.setattr(
litellm,
"default_team_params",
{"team_member_permissions": ["/key/generate", "/key/update"]},
)
result = _get_default_team_param("team_member_permissions")
assert result == ["/key/generate", "/key/update"]
def test_returns_scalar_fields_from_dict_config(self, monkeypatch):
"""Returns scalar fields (max_budget, tpm_limit, etc.) from dict config."""
import litellm
from litellm.proxy.management_endpoints.team_endpoints import (
_get_default_team_param,
)
monkeypatch.setattr(
litellm,
"default_team_params",
{
"max_budget": 100.0,
"budget_duration": "30d",
"tpm_limit": 200,
"rpm_limit": 500,
},
)
assert _get_default_team_param("max_budget") == 100.0
assert _get_default_team_param("budget_duration") == "30d"
assert _get_default_team_param("tpm_limit") == 200
assert _get_default_team_param("rpm_limit") == 500
def test_returns_permissions_from_pydantic_config(self, monkeypatch):
"""Returns permissions when default_team_params is a DefaultTeamSSOParams object."""
import litellm
from litellm.proxy.management_endpoints.team_endpoints import (
_get_default_team_param,
)
from litellm.types.proxy.management_endpoints.ui_sso import (
DefaultTeamSSOParams,
)
params = DefaultTeamSSOParams(
team_member_permissions=[
KeyManagementRoutes.KEY_GENERATE,
KeyManagementRoutes.KEY_DELETE,
]
)
monkeypatch.setattr(litellm, "default_team_params", params)
result = _get_default_team_param("team_member_permissions")
assert result == ["/key/generate", "/key/delete"]
def test_returns_scalar_fields_from_pydantic_config(self, monkeypatch):
"""Returns scalar fields from DefaultTeamSSOParams object."""
import litellm
from litellm.proxy.management_endpoints.team_endpoints import (
_get_default_team_param,
)
from litellm.types.proxy.management_endpoints.ui_sso import (
DefaultTeamSSOParams,
)
params = DefaultTeamSSOParams(
max_budget=250.0,
budget_duration="7d",
tpm_limit=1000,
rpm_limit=100,
)
monkeypatch.setattr(litellm, "default_team_params", params)
assert _get_default_team_param("max_budget") == 250.0
assert _get_default_team_param("budget_duration") == "7d"
assert _get_default_team_param("tpm_limit") == 1000
assert _get_default_team_param("rpm_limit") == 100
@@ -231,6 +231,56 @@ class TestProxySettingEndpoints:
# Verify save_config was called exactly once
assert mock_proxy_config["save_call_count"]() == 1
def test_get_default_team_settings_includes_team_member_permissions_schema(
self, mock_proxy_config, mock_auth
):
"""Test that team_member_permissions field appears in schema with enum items"""
response = client.get("/get/default_team_settings")
assert response.status_code == 200
data = response.json()
# Check that team_member_permissions is in the schema
props = data["field_schema"]["properties"]
assert "team_member_permissions" in props
perm_schema = props["team_member_permissions"]
assert perm_schema["type"] == "array"
assert "items" in perm_schema
assert "enum" in perm_schema["items"]
# Verify some known enum values are present
enum_values = perm_schema["items"]["enum"]
assert "/key/generate" in enum_values
assert "/key/info" in enum_values
assert "/key/delete" in enum_values
def test_update_default_team_settings_with_permissions(
self, mock_proxy_config, mock_auth, monkeypatch
):
"""Test updating default team settings with team_member_permissions"""
import litellm
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
monkeypatch.setattr(litellm, "default_team_params", {})
new_settings = {
"models": ["gpt-4"],
"team_member_permissions": ["/key/generate", "/key/update", "/key/delete"],
}
response = client.patch("/update/default_team_settings", json=new_settings)
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
settings = data["settings"]
assert settings["team_member_permissions"] == [
"/key/generate",
"/key/update",
"/key/delete",
]
def test_get_sso_settings(self, mock_proxy_config, mock_auth, monkeypatch):
"""Test getting the SSO settings from the dedicated database table"""
from unittest.mock import AsyncMock, MagicMock
@@ -18,14 +18,18 @@ vi.mock("./common_components/budget_duration_dropdown", () => {
aria-label="Budget duration"
>
<option value="">Select duration</option>
<option value="daily">Daily</option>
<option value="monthly">Monthly</option>
<option value="24h">Daily</option>
<option value="7d">Weekly</option>
<option value="30d">Monthly</option>
</select>
);
BudgetDurationDropdown.displayName = "BudgetDurationDropdown";
return {
default: BudgetDurationDropdown,
getBudgetDurationLabel: vi.fn((value: string) => `Budget: ${value}`),
getBudgetDurationLabel: vi.fn((value: string) => {
const map: Record<string, string> = { "24h": "daily", "7d": "weekly", "30d": "monthly" };
return map[value] || value;
}),
};
});
@@ -56,6 +60,7 @@ vi.mock("./ModelSelect/ModelSelect", () => {
vi.mock("antd", async (importOriginal) => {
const actual = await importOriginal<typeof import("antd")>();
const React = await import("react");
const SelectComponent = ({
value,
onChange,
@@ -88,37 +93,53 @@ vi.mock("antd", async (importOriginal) => {
);
};
SelectComponent.displayName = "Select";
const SelectOption = ({ value: optionValue, children: optionChildren }: { value: string; children: React.ReactNode }) =>
React.createElement("option", { value: optionValue }, optionChildren);
SelectOption.displayName = "SelectOption";
SelectComponent.Option = SelectOption;
const Spin = ({ size }: { size?: string }) => React.createElement("div", { "data-testid": "spinner", "data-size": size });
const Spin = ({ size }: { size?: string }) =>
React.createElement("div", { "data-testid": "spinner", "data-size": size });
Spin.displayName = "Spin";
const Switch = ({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }) =>
const InputNumber = ({
value,
onChange,
placeholder,
prefix,
}: {
value: number | null;
onChange: (value: number | null) => void;
placeholder?: string;
prefix?: string;
min?: number;
className?: string;
style?: React.CSSProperties;
}) =>
React.createElement("input", {
type: "checkbox",
role: "switch",
checked: checked,
onChange: (e) => onChange(e.target.checked),
"aria-label": "Toggle switch",
type: "number",
value: value ?? "",
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value === "" ? null : Number(e.target.value);
onChange(v);
},
placeholder,
"data-prefix": prefix,
"aria-label": "number input",
});
Switch.displayName = "Switch";
const Paragraph = ({ children }: { children: React.ReactNode }) => React.createElement("p", {}, children);
Paragraph.displayName = "Paragraph";
InputNumber.displayName = "InputNumber";
return {
...actual,
Spin,
Switch,
Select: SelectComponent,
Typography: {
Paragraph,
},
InputNumber,
};
});
const mockGetDefaultTeamSettings = vi.mocked(networking.getDefaultTeamSettings);
const mockUpdateDefaultTeamSettings = vi.mocked(networking.updateDefaultTeamSettings);
const mockModelAvailableCall = vi.mocked(networking.modelAvailableCall);
const mockNotificationsManager = vi.mocked(NotificationsManager);
describe("TeamSSOSettings", () => {
@@ -128,77 +149,33 @@ describe("TeamSSOSettings", () => {
userRole: "admin",
};
const mockSettings = {
const mockSettingsResponse = {
values: {
budget_duration: "monthly",
max_budget: 1000,
enabled: true,
allowed_models: ["gpt-4", "claude-3"],
budget_duration: "30d",
tpm_limit: 500,
rpm_limit: 100,
models: ["gpt-4"],
status: "active",
},
field_schema: {
description: "Default team settings schema",
properties: {
budget_duration: {
type: "string",
description: "Budget duration setting",
},
max_budget: {
type: "number",
description: "Maximum budget amount",
},
enabled: {
type: "boolean",
description: "Enable feature",
},
allowed_models: {
type: "array",
items: {
enum: ["gpt-4", "claude-3", "gpt-3.5-turbo"],
},
description: "Allowed models",
},
models: {
type: "array",
description: "Selected models",
},
status: {
type: "string",
enum: ["active", "inactive", "pending"],
description: "Status",
},
},
team_member_permissions: ["/key/generate", "/key/update"],
},
};
beforeEach(() => {
vi.clearAllMocks();
mockModelAvailableCall.mockResolvedValue({
data: [{ id: "gpt-4" }, { id: "claude-3" }],
});
});
it("should render", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Default Team Settings")).toBeInTheDocument();
});
});
// --- Loading & Error States ---
it("should show loading spinner while fetching settings", () => {
mockGetDefaultTeamSettings.mockImplementation(() => new Promise(() => { }));
mockGetDefaultTeamSettings.mockImplementation(() => new Promise(() => {}));
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
expect(screen.getByTestId("spinner")).toBeInTheDocument();
});
it("should display message when no settings are available", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(null as any);
it("should display error message when fetch fails", async () => {
mockGetDefaultTeamSettings.mockRejectedValue(new Error("Fetch failed"));
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
@@ -207,6 +184,7 @@ describe("TeamSSOSettings", () => {
screen.getByText("No team settings available or you do not have permission to view them."),
).toBeInTheDocument();
});
expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to fetch team settings");
});
it("should not fetch settings when access token is null", async () => {
@@ -217,432 +195,273 @@ describe("TeamSSOSettings", () => {
});
});
it("should display settings fields with correct values", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
// --- View Mode ---
it("should render title and subtitle", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Default Team Settings")).toBeInTheDocument();
expect(screen.getByText("These settings will be applied by default when creating new teams.")).toBeInTheDocument();
});
});
it("should render section headers", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Budget & Rate Limits")).toBeInTheDocument();
expect(screen.getByText("Access & Permissions")).toBeInTheDocument();
});
});
it("should display all field labels and descriptions", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Budget Duration")).toBeInTheDocument();
expect(screen.getByText("Max Budget")).toBeInTheDocument();
expect(screen.getByText("Budget Duration")).toBeInTheDocument();
expect(screen.getByText("TPM Limit")).toBeInTheDocument();
expect(screen.getByText("RPM Limit")).toBeInTheDocument();
expect(screen.getByText("Models")).toBeInTheDocument();
expect(screen.getByText("Team Member Permissions")).toBeInTheDocument();
});
expect(screen.getByText("Budget: monthly")).toBeInTheDocument();
expect(screen.getByText("1000")).toBeInTheDocument();
const enabledTexts = screen.getAllByText("Enabled");
expect(enabledTexts.length).toBeGreaterThan(0);
// Descriptions
expect(screen.getByText("Maximum budget (in USD) for new automatically created teams.")).toBeInTheDocument();
expect(screen.getByText("How frequently the team's budget resets.")).toBeInTheDocument();
});
it("should display formatted values in view mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
// max_budget displayed with $
expect(screen.getByText("$1,000")).toBeInTheDocument();
// budget_duration through getBudgetDurationLabel
expect(screen.getByText("monthly")).toBeInTheDocument();
// tpm_limit formatted
expect(screen.getByText("500")).toBeInTheDocument();
// rpm_limit formatted
expect(screen.getByText("100")).toBeInTheDocument();
});
});
it("should display models as tags in view mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("gpt-4")).toBeInTheDocument();
});
});
it("should display permissions as tags in view mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("/key/generate")).toBeInTheDocument();
expect(screen.getByText("/key/update")).toBeInTheDocument();
});
});
it("should display 'Not set' for null values", async () => {
const settingsWithNulls = {
...mockSettings,
mockGetDefaultTeamSettings.mockResolvedValue({
values: {
...mockSettings.values,
max_budget: null,
budget_duration: null,
tpm_limit: null,
rpm_limit: null,
models: [],
team_member_permissions: [],
},
};
mockGetDefaultTeamSettings.mockResolvedValue(settingsWithNulls);
});
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Not set")).toBeInTheDocument();
const notSetElements = screen.getAllByText("Not set");
// max_budget, budget_duration, tpm_limit, rpm_limit, models (empty), permissions (empty)
expect(notSetElements.length).toBeGreaterThanOrEqual(4);
});
});
it("should toggle edit mode when edit button is clicked", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
// --- Edit Mode Toggle ---
it("should toggle to edit mode when Edit Settings is clicked", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
});
const editButton = screen.getByRole("button", { name: "Edit Settings" });
await userEvent.click(editButton);
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save Changes" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Edit Settings" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /Cancel/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Save Changes/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Edit Settings/i })).not.toBeInTheDocument();
});
it("should cancel edit mode and reset values", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
});
const editButton = screen.getByRole("button", { name: "Edit Settings" });
await userEvent.click(editButton);
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
await userEvent.click(screen.getByRole("button", { name: /Cancel/i }));
const cancelButton = screen.getByRole("button", { name: "Cancel" });
await userEvent.click(cancelButton);
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Cancel/i })).not.toBeInTheDocument();
});
it("should save settings when save button is clicked", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
mockUpdateDefaultTeamSettings.mockResolvedValue({
settings: mockSettings.values,
});
// --- Edit Mode Fields ---
it("should show budget duration dropdown in edit mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
});
const editButton = screen.getByRole("button", { name: "Edit Settings" });
await userEvent.click(editButton);
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Save Changes" })).toBeInTheDocument();
});
const saveButton = screen.getByRole("button", { name: "Save Changes" });
await userEvent.click(saveButton);
await waitFor(() => {
expect(mockUpdateDefaultTeamSettings).toHaveBeenCalledWith("test-token", mockSettings.values);
});
expect(mockNotificationsManager.success).toHaveBeenCalledWith("Default team settings updated successfully");
});
it("should show error notification when save fails", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
mockUpdateDefaultTeamSettings.mockRejectedValue(new Error("Save failed"));
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
});
const editButton = screen.getByRole("button", { name: "Edit Settings" });
await userEvent.click(editButton);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Save Changes" })).toBeInTheDocument();
});
const saveButton = screen.getByRole("button", { name: "Save Changes" });
await userEvent.click(saveButton);
await waitFor(() => {
expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to update team settings");
expect(screen.getByTestId("budget-duration-dropdown")).toBeInTheDocument();
});
});
it("should render boolean field as switch in edit mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
it("should show ModelSelect in edit mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
});
const editButton = screen.getByRole("button", { name: "Edit Settings" });
await userEvent.click(editButton);
await waitFor(() => {
const switchElement = screen.getByRole("switch");
expect(switchElement).toBeInTheDocument();
expect(switchElement).toBeChecked();
});
});
it("should update boolean value when switch is toggled", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
});
const editButton = screen.getByRole("button", { name: "Edit Settings" });
await userEvent.click(editButton);
await waitFor(() => {
expect(screen.getByRole("switch")).toBeInTheDocument();
});
const switchElement = screen.getByRole("switch");
await userEvent.click(switchElement);
expect(switchElement).not.toBeChecked();
});
it("should render budget duration dropdown in edit mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
});
const editButton = screen.getByRole("button", { name: "Edit Settings" });
await userEvent.click(editButton);
await waitFor(() => {
expect(screen.getByLabelText("Budget duration")).toBeInTheDocument();
});
});
it("should update budget duration when dropdown value changes", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
});
const editButton = screen.getByRole("button", { name: "Edit Settings" });
await userEvent.click(editButton);
await waitFor(() => {
expect(screen.getByLabelText("Budget duration")).toBeInTheDocument();
});
const dropdown = screen.getByLabelText("Budget duration");
await userEvent.selectOptions(dropdown, "daily");
expect(dropdown).toHaveValue("daily");
});
it("should render text input for string fields in edit mode", async () => {
const settingsWithString = {
...mockSettings,
field_schema: {
...mockSettings.field_schema,
properties: {
...mockSettings.field_schema.properties,
team_name: {
type: "string",
description: "Team name",
},
},
},
values: {
...mockSettings.values,
team_name: "Test Team",
},
};
mockGetDefaultTeamSettings.mockResolvedValue(settingsWithString);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
});
const editButton = screen.getByRole("button", { name: "Edit Settings" });
await userEvent.click(editButton);
await waitFor(() => {
const textInput = screen.getByDisplayValue("Test Team");
expect(textInput).toBeInTheDocument();
});
});
it("should render enum select for string enum fields in edit mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
});
const editButton = screen.getByRole("button", { name: "Edit Settings" });
await userEvent.click(editButton);
await waitFor(() => {
const statusSelect = screen.getAllByRole("listbox")[0];
expect(statusSelect).toBeInTheDocument();
});
});
it("should render multi-select for array enum fields in edit mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
});
const editButton = screen.getByRole("button", { name: "Edit Settings" });
await userEvent.click(editButton);
await waitFor(() => {
const multiSelects = screen.getAllByRole("listbox");
expect(multiSelects.length).toBeGreaterThan(0);
});
});
it("should render ModelSelect for models field in edit mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
});
const editButton = screen.getByRole("button", { name: "Edit Settings" });
await userEvent.click(editButton);
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
await waitFor(() => {
expect(screen.getByTestId("model-select")).toBeInTheDocument();
});
});
it("should display models as badges in view mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
it("should show number inputs for budget and rate limits in edit mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
const gpt4Elements = screen.getAllByText("gpt-4");
expect(gpt4Elements.length).toBeGreaterThan(0);
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
});
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
await waitFor(() => {
const numberInputs = screen.getAllByLabelText("number input");
// max_budget, tpm_limit, rpm_limit
expect(numberInputs.length).toBe(3);
});
});
it("should display 'None' for empty arrays in view mode", async () => {
const settingsWithEmptyArray = {
...mockSettings,
values: {
...mockSettings.values,
models: [],
},
};
mockGetDefaultTeamSettings.mockResolvedValue(settingsWithEmptyArray);
it("should show permissions multi-select in edit mode", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
const noneTexts = screen.getAllByText("None");
expect(noneTexts.length).toBeGreaterThan(0);
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
});
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
await waitFor(() => {
const listboxes = screen.getAllByRole("listbox");
expect(listboxes.length).toBeGreaterThan(0);
});
});
it("should display schema description when available", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
// --- Save ---
it("should save settings and show success notification", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
mockUpdateDefaultTeamSettings.mockResolvedValue({
settings: mockSettingsResponse.values,
});
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Default team settings schema")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
});
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
await userEvent.click(screen.getByRole("button", { name: /Save Changes/i }));
await waitFor(() => {
expect(mockUpdateDefaultTeamSettings).toHaveBeenCalledWith("test-token", expect.any(Object));
});
expect(mockNotificationsManager.success).toHaveBeenCalledWith("Default team settings updated successfully");
// Should exit edit mode after save
await waitFor(() => {
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
});
});
it("should show error notification when fetching settings fails", async () => {
mockGetDefaultTeamSettings.mockRejectedValue(new Error("Fetch failed"));
it("should show error notification when save fails", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
mockUpdateDefaultTeamSettings.mockRejectedValue(new Error("Save failed"));
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to fetch team settings");
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
});
});
it("should handle model fetch error gracefully", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
mockModelAvailableCall.mockRejectedValue(new Error("Model fetch failed"));
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
await userEvent.click(screen.getByRole("button", { name: /Save Changes/i }));
await waitFor(() => {
expect(screen.getByText("Default Team Settings")).toBeInTheDocument();
expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to update team settings");
});
});
it("should disable cancel button while saving", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
mockUpdateDefaultTeamSettings.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ settings: mockSettings.values }), 100)),
() => new Promise((resolve) => setTimeout(() => resolve({ settings: mockSettingsResponse.values }), 100)),
);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
});
const editButton = screen.getByRole("button", { name: "Edit Settings" });
await userEvent.click(editButton);
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
await userEvent.click(screen.getByRole("button", { name: /Save Changes/i }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Save Changes" })).toBeInTheDocument();
});
const saveButton = screen.getByRole("button", { name: "Save Changes" });
await userEvent.click(saveButton);
const cancelButton = screen.getByRole("button", { name: "Cancel" });
expect(cancelButton).toBeDisabled();
});
it("should display field descriptions", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Budget duration setting")).toBeInTheDocument();
expect(screen.getByText("Maximum budget amount")).toBeInTheDocument();
});
});
it("should format field names by replacing underscores and capitalizing", async () => {
const settingsWithUnderscores = {
...mockSettings,
field_schema: {
...mockSettings.field_schema,
properties: {
...mockSettings.field_schema.properties,
max_budget_per_user: {
type: "number",
description: "Max budget per user",
},
},
},
values: {
...mockSettings.values,
max_budget_per_user: 500,
},
};
mockGetDefaultTeamSettings.mockResolvedValue(settingsWithUnderscores);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Max Budget Per User")).toBeInTheDocument();
});
});
it("should display 'No schema information available' when schema is missing", async () => {
const settingsWithoutSchema = {
values: {},
field_schema: null,
};
mockGetDefaultTeamSettings.mockResolvedValue(settingsWithoutSchema);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("No schema information available")).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /Cancel/i })).toBeDisabled();
});
});
@@ -1,30 +1,96 @@
import React, { useState, useEffect } from "react";
import { Card, Title, Text, Divider, Button, TextInput } from "@tremor/react";
import { Typography, Spin, Switch, Select } from "antd";
import { getDefaultTeamSettings, updateDefaultTeamSettings, modelAvailableCall } from "./networking";
import { Card, Button, InputNumber, Typography, Spin, Select, Tag, Row, Col } from "antd";
import { EditOutlined, SaveOutlined } from "@ant-design/icons";
import { getDefaultTeamSettings, updateDefaultTeamSettings } from "./networking";
import BudgetDurationDropdown, { getBudgetDurationLabel } from "./common_components/budget_duration_dropdown";
import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key";
import NotificationsManager from "./molecules/notifications_manager";
import { ModelSelect } from "./ModelSelect/ModelSelect";
const { Title, Text } = Typography;
interface TeamSSOSettingsProps {
accessToken: string | null;
userID: string;
userRole: string;
}
const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken, userID, userRole }) => {
const PERMISSION_OPTIONS = [
"/key/generate",
"/key/update",
"/key/delete",
"/key/regenerate",
"/key/service-account/generate",
"/key/{key_id}/regenerate",
"/key/block",
"/key/unblock",
"/key/bulk_update",
"/key/{key_id}/reset_spend",
];
interface SettingRowProps {
label: string;
description: string;
isEditing: boolean;
viewContent: React.ReactNode;
editContent: React.ReactNode;
}
const SettingRow: React.FC<SettingRowProps> = ({ label, description, isEditing, viewContent, editContent }) => (
<Row className="py-5 border-b border-gray-100 last:border-0">
<Col span={8} className="pr-6">
<div className="text-sm font-semibold text-gray-900">{label}</div>
<div className="text-xs text-gray-500 mt-1 leading-relaxed">{description}</div>
</Col>
<Col span={16} className="flex items-center">
<div className="w-full">{isEditing ? editContent : viewContent}</div>
</Col>
</Row>
);
const NotSet = () => <Text className="text-gray-400 italic">Not set</Text>;
const renderTags = (values: string[], displayFn?: (v: string) => string) => {
if (!values || values.length === 0) return <NotSet />;
return (
<div className="flex flex-wrap gap-2">
{values.map((v) => (
<Tag key={v} color="blue">
{displayFn ? displayFn(v) : v}
</Tag>
))}
</div>
);
};
interface SettingsValues {
max_budget: number | null;
budget_duration: string | null;
tpm_limit: number | null;
rpm_limit: number | null;
models: string[];
team_member_permissions: string[];
}
const DEFAULT_VALUES: SettingsValues = {
max_budget: null,
budget_duration: null,
tpm_limit: null,
rpm_limit: null,
models: [],
team_member_permissions: [],
};
const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
const [loading, setLoading] = useState<boolean>(true);
const [settings, setSettings] = useState<any>(null);
const [values, setValues] = useState<SettingsValues>(DEFAULT_VALUES);
const [isEditing, setIsEditing] = useState<boolean>(false);
const [editedValues, setEditedValues] = useState<any>({});
const [editedValues, setEditedValues] = useState<SettingsValues>(DEFAULT_VALUES);
const [saving, setSaving] = useState<boolean>(false);
const [availableModels, setAvailableModels] = useState<string[]>([]);
const { Paragraph } = Typography;
const { Option } = Select;
const [fetchError, setFetchError] = useState<boolean>(false);
useEffect(() => {
const fetchTeamSSOSettings = async () => {
const fetchSettings = async () => {
if (!accessToken) {
setLoading(false);
return;
@@ -32,39 +98,30 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken, userID,
try {
const data = await getDefaultTeamSettings(accessToken);
setSettings(data);
setEditedValues(data.values || {});
// Fetch available models
if (accessToken) {
try {
const modelResponse = await modelAvailableCall(accessToken, userID, userRole);
if (modelResponse && modelResponse.data) {
const modelNames = modelResponse.data.map((model: { id: string }) => model.id);
setAvailableModels(modelNames);
}
} catch (error) {
console.error("Error fetching available models:", error);
}
}
const fetched = { ...DEFAULT_VALUES, ...(data.values || {}) };
setValues(fetched);
setEditedValues(fetched);
} catch (error) {
console.error("Error fetching team SSO settings:", error);
setFetchError(true);
NotificationsManager.fromBackend("Failed to fetch team settings");
} finally {
setLoading(false);
}
};
fetchTeamSSOSettings();
fetchSettings();
}, [accessToken]);
const handleSaveSettings = async () => {
const handleSave = async () => {
if (!accessToken) return;
setSaving(true);
try {
const updatedSettings = await updateDefaultTeamSettings(accessToken, editedValues);
setSettings({ ...settings, values: updatedSettings.settings });
const newValues = { ...DEFAULT_VALUES, ...(updatedSettings.settings || {}) };
setValues(newValues);
setEditedValues(newValues);
setIsEditing(false);
NotificationsManager.success("Default team settings updated successfully");
} catch (error) {
@@ -75,129 +132,13 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken, userID,
}
};
const handleTextInputChange = (key: string, value: any) => {
setEditedValues((prev: Record<string, any>) => ({
...prev,
[key]: value,
}));
const handleCancel = () => {
setIsEditing(false);
setEditedValues(values);
};
const renderEditableField = (key: string, property: any, value: any) => {
const type = property.type;
if (key === "budget_duration") {
return (
<BudgetDurationDropdown
value={editedValues[key] || null}
onChange={(value) => handleTextInputChange(key, value)}
className="mt-2"
/>
);
} else if (type === "boolean") {
return (
<div className="mt-2">
<Switch checked={!!editedValues[key]} onChange={(checked) => handleTextInputChange(key, checked)} />
</div>
);
} else if (type === "array" && property.items?.enum) {
return (
<Select
mode="multiple"
style={{ width: "100%" }}
value={editedValues[key] || []}
onChange={(value) => handleTextInputChange(key, value)}
className="mt-2"
>
{property.items.enum.map((option: string) => (
<Option key={option} value={option}>
{option}
</Option>
))}
</Select>
);
} else if (key === "models") {
return (
<ModelSelect
value={editedValues[key] || []}
onChange={(value) => handleTextInputChange(key, value)}
context="global"
style={{ width: "100%" }}
options={{
includeSpecialOptions: true,
}}
/>
);
} else if (type === "string" && property.enum) {
return (
<Select
style={{ width: "100%" }}
value={editedValues[key] || ""}
onChange={(value) => handleTextInputChange(key, value)}
className="mt-2"
>
{property.enum.map((option: string) => (
<Option key={option} value={option}>
{option}
</Option>
))}
</Select>
);
} else {
return (
<TextInput
value={editedValues[key] !== undefined ? String(editedValues[key]) : ""}
onChange={(e) => handleTextInputChange(key, e.target.value)}
placeholder={property.description || ""}
className="mt-2"
/>
);
}
};
const renderValue = (key: string, value: any): JSX.Element => {
if (value === null || value === undefined) return <span className="text-gray-400">Not set</span>;
if (key === "budget_duration") {
return <span>{getBudgetDurationLabel(value)}</span>;
}
if (typeof value === "boolean") {
return <span>{value ? "Enabled" : "Disabled"}</span>;
}
if (key === "models" && Array.isArray(value)) {
if (value.length === 0) return <span className="text-gray-400">None</span>;
return (
<div className="flex flex-wrap gap-2 mt-1">
{value.map((model, index) => (
<span key={index} className="px-2 py-1 bg-blue-100 rounded text-xs">
{getModelDisplayName(model)}
</span>
))}
</div>
);
}
if (typeof value === "object") {
if (Array.isArray(value)) {
if (value.length === 0) return <span className="text-gray-400">None</span>;
return (
<div className="flex flex-wrap gap-2 mt-1">
{value.map((item, index) => (
<span key={index} className="px-2 py-1 bg-blue-100 rounded text-xs">
{typeof item === "object" ? JSON.stringify(item) : String(item)}
</span>
))}
</div>
);
}
return <pre className="bg-gray-100 p-2 rounded text-xs overflow-auto mt-1">{JSON.stringify(value, null, 2)}</pre>;
}
return <span>{String(value)}</span>;
const update = <K extends keyof SettingsValues>(key: K, value: SettingsValues[K]) => {
setEditedValues((prev) => ({ ...prev, [key]: value }));
};
if (loading) {
@@ -208,7 +149,7 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken, userID,
);
}
if (!settings) {
if (fetchError) {
return (
<Card>
<Text>No team settings available or you do not have permission to view them.</Text>
@@ -216,70 +157,166 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken, userID,
);
}
// Dynamically render settings based on the schema
const renderSettings = () => {
const { values, field_schema } = settings;
if (!field_schema || !field_schema.properties) {
return <Text>No schema information available</Text>;
}
return Object.entries(field_schema.properties).map(([key, property]: [string, any]) => {
const value = values[key];
const displayName = key.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
return (
<div key={key} className="mb-6 pb-6 border-b border-gray-200 last:border-0">
<Text className="font-medium text-lg">{displayName}</Text>
<Paragraph className="text-sm text-gray-500 mt-1">
{property.description || "No description available"}
</Paragraph>
{isEditing ? (
<div className="mt-2">{renderEditableField(key, property, value)}</div>
) : (
<div className="mt-1 p-2 bg-gray-50 rounded">{renderValue(key, value)}</div>
)}
</div>
);
});
};
return (
<Card>
<div className="flex justify-between items-center mb-4">
<Title className="text-xl">Default Team Settings</Title>
{!loading &&
settings &&
(isEditing ? (
<div className="flex gap-2">
<Button
variant="secondary"
onClick={() => {
setIsEditing(false);
setEditedValues(settings.values || {});
}}
disabled={saving}
>
<Card styles={{ body: { padding: 32 } }}>
{/* Header */}
<div className="flex justify-between items-start mb-2">
<div>
<Title level={3} className="m-0 text-gray-900">
Default Team Settings
</Title>
<Text className="text-gray-500 mt-1 block">
These settings will be applied by default when creating new teams.
</Text>
</div>
<div>
{isEditing ? (
<div className="flex gap-3">
<Button onClick={handleCancel} disabled={saving}>
Cancel
</Button>
<Button onClick={handleSaveSettings} loading={saving}>
<Button type="primary" onClick={handleSave} loading={saving} icon={<SaveOutlined />}>
Save Changes
</Button>
</div>
) : (
<Button onClick={() => setIsEditing(true)}>Edit Settings</Button>
))}
<Button onClick={() => setIsEditing(true)} icon={<EditOutlined />}>
Edit Settings
</Button>
)}
</div>
</div>
<Text>These settings will be applied by default when creating new teams.</Text>
<div className="mt-8">
{/* Budget & Rate Limits */}
<div className="mb-8">
<div className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Budget & Rate Limits</div>
<div className="border-t border-gray-100">
<SettingRow
label="Max Budget"
description="Maximum budget (in USD) for new automatically created teams."
isEditing={isEditing}
viewContent={
values.max_budget != null ? <Text>${Number(values.max_budget).toLocaleString()}</Text> : <NotSet />
}
editContent={
<InputNumber
className="w-full"
style={{ maxWidth: 320 }}
value={editedValues.max_budget}
onChange={(v) => update("max_budget", v)}
placeholder="Not set"
prefix="$"
min={0}
/>
}
/>
{settings?.field_schema?.description && (
<Paragraph className="mb-4 mt-2">{settings.field_schema.description}</Paragraph>
)}
<Divider />
<SettingRow
label="Budget Duration"
description="How frequently the team's budget resets."
isEditing={isEditing}
viewContent={
values.budget_duration ? <Text>{getBudgetDurationLabel(values.budget_duration)}</Text> : <NotSet />
}
editContent={
<BudgetDurationDropdown
value={editedValues.budget_duration || null}
onChange={(v) => update("budget_duration", v)}
style={{ maxWidth: 320 }}
/>
}
/>
<div className="mt-4 space-y-4">{renderSettings()}</div>
<SettingRow
label="TPM Limit"
description="Maximum tokens per minute allowed across all models."
isEditing={isEditing}
viewContent={
values.tpm_limit != null ? <Text>{values.tpm_limit.toLocaleString()}</Text> : <NotSet />
}
editContent={
<InputNumber
className="w-full"
style={{ maxWidth: 320 }}
value={editedValues.tpm_limit}
onChange={(v) => update("tpm_limit", v)}
placeholder="Not set"
min={0}
/>
}
/>
<SettingRow
label="RPM Limit"
description="Maximum requests per minute allowed across all models."
isEditing={isEditing}
viewContent={
values.rpm_limit != null ? <Text>{values.rpm_limit.toLocaleString()}</Text> : <NotSet />
}
editContent={
<InputNumber
className="w-full"
style={{ maxWidth: 320 }}
value={editedValues.rpm_limit}
onChange={(v) => update("rpm_limit", v)}
placeholder="Not set"
min={0}
/>
}
/>
</div>
</div>
{/* Access & Permissions */}
<div className="mb-8">
<div className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Access & Permissions</div>
<div className="border-t border-gray-100">
<SettingRow
label="Models"
description="Default list of models that new teams can access."
isEditing={isEditing}
viewContent={renderTags(values.models, getModelDisplayName)}
editContent={
<ModelSelect
value={editedValues.models || []}
onChange={(v) => update("models", v)}
context="global"
style={{ width: "100%" }}
options={{ includeSpecialOptions: true }}
/>
}
/>
<SettingRow
label="Team Member Permissions"
description="Default permissions granted to members of newly created teams. /key/info and /key/health are always included."
isEditing={isEditing}
viewContent={renderTags(values.team_member_permissions)}
editContent={
<Select
mode="multiple"
style={{ width: "100%" }}
value={editedValues.team_member_permissions || []}
onChange={(v) => update("team_member_permissions", v)}
placeholder="Select permissions"
tagRender={({ label, closable, onClose }) => (
<Tag color="blue" closable={closable} onClose={onClose} className="mr-1 mt-1 mb-1">
{label}
</Tag>
)}
>
{PERMISSION_OPTIONS.map((option) => (
<Select.Option key={option} value={option}>
{option}
</Select.Option>
))}
</Select>
}
/>
</div>
</div>
</div>
</Card>
);
};
@@ -7206,7 +7206,6 @@ export const updateDefaultTeamSettings = async (accessToken: string, settings: R
const data = await response.json();
console.log("Updated default team settings:", data);
NotificationsManager.success("Default team settings updated successfully");
return data;
} catch (error) {
console.error("Failed to update default team settings:", error);
+1 -1
View File
@@ -14,7 +14,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{