diff --git a/litellm/constants.py b/litellm/constants.py index dbc79b69a6..0cf9700cdd 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -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", diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 9c8e6f7282..d83ceb1b09 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -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 diff --git a/litellm/proxy/management_helpers/team_member_permission_checks.py b/litellm/proxy/management_helpers/team_member_permission_checks.py index 4642028b77..7dd99d4ff1 100644 --- a/litellm/proxy/management_helpers/team_member_permission_checks.py +++ b/litellm/proxy/management_helpers/team_member_permission_checks.py @@ -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 diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 8df215d998..0fa27905ba 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -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. 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: diff --git a/litellm/types/proxy/management_endpoints/ui_sso.py b/litellm/types/proxy/management_endpoints/ui_sso.py index 6743c4a5b9..7d8ff0f65c 100644 --- a/litellm/types/proxy/management_endpoints/ui_sso.py +++ b/litellm/types/proxy/management_endpoints/ui_sso.py @@ -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): diff --git a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py index 1355ca0abb..3c4444a5ef 100644 --- a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py @@ -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", ) diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_default_params.py b/tests/test_litellm/proxy/management_endpoints/test_team_default_params.py new file mode 100644 index 0000000000..7fc7cb8aae --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_team_default_params.py @@ -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 diff --git a/tests/test_litellm/proxy/management_helpers/test_team_member_permission_checks.py b/tests/test_litellm/proxy/management_helpers/test_team_member_permission_checks.py new file mode 100644 index 0000000000..6aa08dddd0 --- /dev/null +++ b/tests/test_litellm/proxy/management_helpers/test_team_member_permission_checks.py @@ -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 diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index f955a6134b..7378b14cdf 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -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 diff --git a/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx b/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx index ae93b11879..5006afb61e 100644 --- a/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx +++ b/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx @@ -18,14 +18,18 @@ vi.mock("./common_components/budget_duration_dropdown", () => { aria-label="Budget duration" > - - + + + ); BudgetDurationDropdown.displayName = "BudgetDurationDropdown"; return { default: BudgetDurationDropdown, - getBudgetDurationLabel: vi.fn((value: string) => `Budget: ${value}`), + getBudgetDurationLabel: vi.fn((value: string) => { + const map: Record = { "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(); 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) => { + 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(); - - 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(); 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(); @@ -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(); + + 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(); + + 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(); 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(); + + 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(); + + await waitFor(() => { + expect(screen.getByText("gpt-4")).toBeInTheDocument(); + }); + }); + + it("should display permissions as tags in view mode", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); + + renderWithProviders(); + + 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(); 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(); 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(); 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(); 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(); - - 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(); 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); 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(); 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(); 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(); 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(); + 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(); 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(); - - 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(); - - 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(); - - await waitFor(() => { - expect(screen.getByText("No schema information available")).toBeInTheDocument(); - }); + expect(screen.getByRole("button", { name: /Cancel/i })).toBeDisabled(); }); }); diff --git a/ui/litellm-dashboard/src/components/TeamSSOSettings.tsx b/ui/litellm-dashboard/src/components/TeamSSOSettings.tsx index 33bfc783af..a9c07cdbcf 100644 --- a/ui/litellm-dashboard/src/components/TeamSSOSettings.tsx +++ b/ui/litellm-dashboard/src/components/TeamSSOSettings.tsx @@ -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 = ({ 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 = ({ label, description, isEditing, viewContent, editContent }) => ( + + +
{label}
+
{description}
+ + +
{isEditing ? editContent : viewContent}
+ +
+); + +const NotSet = () => Not set; + +const renderTags = (values: string[], displayFn?: (v: string) => string) => { + if (!values || values.length === 0) return ; + return ( +
+ {values.map((v) => ( + + {displayFn ? displayFn(v) : v} + + ))} +
+ ); +}; + +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 = ({ accessToken }) => { const [loading, setLoading] = useState(true); - const [settings, setSettings] = useState(null); + const [values, setValues] = useState(DEFAULT_VALUES); const [isEditing, setIsEditing] = useState(false); - const [editedValues, setEditedValues] = useState({}); + const [editedValues, setEditedValues] = useState(DEFAULT_VALUES); const [saving, setSaving] = useState(false); - const [availableModels, setAvailableModels] = useState([]); - const { Paragraph } = Typography; - const { Option } = Select; + const [fetchError, setFetchError] = useState(false); useEffect(() => { - const fetchTeamSSOSettings = async () => { + const fetchSettings = async () => { if (!accessToken) { setLoading(false); return; @@ -32,39 +98,30 @@ const TeamSSOSettings: React.FC = ({ 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 = ({ accessToken, userID, } }; - const handleTextInputChange = (key: string, value: any) => { - setEditedValues((prev: Record) => ({ - ...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 ( - handleTextInputChange(key, value)} - className="mt-2" - /> - ); - } else if (type === "boolean") { - return ( -
- handleTextInputChange(key, checked)} /> -
- ); - } else if (type === "array" && property.items?.enum) { - return ( - - ); - } else if (key === "models") { - return ( - handleTextInputChange(key, value)} - context="global" - style={{ width: "100%" }} - options={{ - includeSpecialOptions: true, - }} - /> - ); - } else if (type === "string" && property.enum) { - return ( - - ); - } else { - return ( - 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 Not set; - - if (key === "budget_duration") { - return {getBudgetDurationLabel(value)}; - } - - if (typeof value === "boolean") { - return {value ? "Enabled" : "Disabled"}; - } - - if (key === "models" && Array.isArray(value)) { - if (value.length === 0) return None; - - return ( -
- {value.map((model, index) => ( - - {getModelDisplayName(model)} - - ))} -
- ); - } - - if (typeof value === "object") { - if (Array.isArray(value)) { - if (value.length === 0) return None; - - return ( -
- {value.map((item, index) => ( - - {typeof item === "object" ? JSON.stringify(item) : String(item)} - - ))} -
- ); - } - - return
{JSON.stringify(value, null, 2)}
; - } - - return {String(value)}; + const update = (key: K, value: SettingsValues[K]) => { + setEditedValues((prev) => ({ ...prev, [key]: value })); }; if (loading) { @@ -208,7 +149,7 @@ const TeamSSOSettings: React.FC = ({ accessToken, userID, ); } - if (!settings) { + if (fetchError) { return ( No team settings available or you do not have permission to view them. @@ -216,70 +157,166 @@ const TeamSSOSettings: React.FC = ({ accessToken, userID, ); } - // Dynamically render settings based on the schema - const renderSettings = () => { - const { values, field_schema } = settings; - - if (!field_schema || !field_schema.properties) { - return No schema information available; - } - - 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 ( -
- {displayName} - - {property.description || "No description available"} - - - {isEditing ? ( -
{renderEditableField(key, property, value)}
- ) : ( -
{renderValue(key, value)}
- )} -
- ); - }); - }; - return ( - -
- Default Team Settings - {!loading && - settings && - (isEditing ? ( -
- -
) : ( - - ))} + + )} +
- These settings will be applied by default when creating new teams. +
+ {/* Budget & Rate Limits */} +
+
Budget & Rate Limits
+
+ ${Number(values.max_budget).toLocaleString()} : + } + editContent={ + update("max_budget", v)} + placeholder="Not set" + prefix="$" + min={0} + /> + } + /> - {settings?.field_schema?.description && ( - {settings.field_schema.description} - )} - + {getBudgetDurationLabel(values.budget_duration)} : + } + editContent={ + update("budget_duration", v)} + style={{ maxWidth: 320 }} + /> + } + /> -
{renderSettings()}
+ {values.tpm_limit.toLocaleString()} : + } + editContent={ + update("tpm_limit", v)} + placeholder="Not set" + min={0} + /> + } + /> + + {values.rpm_limit.toLocaleString()} : + } + editContent={ + update("rpm_limit", v)} + placeholder="Not set" + min={0} + /> + } + /> +
+
+ + {/* Access & Permissions */} +
+
Access & Permissions
+
+ update("models", v)} + context="global" + style={{ width: "100%" }} + options={{ includeSpecialOptions: true }} + /> + } + /> + + update("team_member_permissions", v)} + placeholder="Select permissions" + tagRender={({ label, closable, onClose }) => ( + + {label} + + )} + > + {PERMISSION_OPTIONS.map((option) => ( + + {option} + + ))} + + } + /> +
+
+
); }; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 11a8a6f1fe..bb9a0c1e01 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -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); diff --git a/ui/litellm-dashboard/tsconfig.json b/ui/litellm-dashboard/tsconfig.json index 5b0352feb9..d24bdd340f 100644 --- a/ui/litellm-dashboard/tsconfig.json +++ b/ui/litellm-dashboard/tsconfig.json @@ -14,7 +14,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ {