diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 1c0c212b60b..e09d7607ebe 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -54,6 +54,7 @@ from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time from litellm.proxy.hooks.key_management_event_hooks import KeyManagementEventHooks from litellm.proxy.management_endpoints.common_utils import ( + _is_user_org_admin_for_team, _is_user_team_admin, _set_object_metadata_field, ) @@ -71,6 +72,9 @@ ) from litellm.proxy.management_helpers.utils import management_endpoint_wrapper from litellm.proxy.spend_tracking.spend_tracking_utils import _is_master_key +from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( + get_ui_settings_cached, +) from litellm.proxy.utils import ( PrismaClient, ProxyLogging, @@ -95,6 +99,24 @@ ) +async def _check_custom_key_allowed(custom_key_value: Optional[str]) -> None: + """Raise 403 if custom API keys are disabled and a custom key was provided.""" + if custom_key_value is None: + return + + ui_settings = await get_ui_settings_cached() + if ui_settings.get("disable_custom_api_keys", False) is True: + verbose_proxy_logger.warning( + "Custom API key rejected: disable_custom_api_keys is enabled" + ) + raise HTTPException( + status_code=403, + detail={ + "error": "Custom API key values are disabled by your administrator. Keys must be auto-generated." + }, + ) + + def _is_team_key(data: Union[GenerateKeyRequest, LiteLLM_VerificationToken]): return data.team_id is not None @@ -353,6 +375,10 @@ def key_generation_check( ## check if key is for team or individual is_team_key = _is_team_key(data=data) + _is_admin = ( + user_api_key_dict.user_role is not None + and user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value + ) if is_team_key: if team_table is None and litellm.key_generation_settings is not None: raise HTTPException( @@ -360,7 +386,13 @@ def key_generation_check( detail=f"Unable to find team object in database. Team ID: {data.team_id}", ) elif team_table is None: - return True # assume user is assigning team_id without using the team table + if _is_admin: + return True # admins can assign team_id without team table + # Non-admin callers must have a valid team (LIT-1884) + raise HTTPException( + status_code=400, + detail=f"Unable to find team object in database. Team ID: {data.team_id}", + ) return _team_key_generation_check( team_table=team_table, user_api_key_dict=user_api_key_dict, @@ -660,6 +692,9 @@ async def _common_key_generation_helper( # noqa: PLR0915 prisma_client=prisma_client, ) + # Reject custom key values if disabled by admin + await _check_custom_key_allowed(data.key) + # Validate user-provided key format if data.key is not None and not data.key.startswith("sk-"): _masked = ( @@ -1213,6 +1248,19 @@ async def generate_key_fn( raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=message ) + # For non-admin internal users: auto-assign caller's user_id if not provided + # This prevents creating unbound keys with no user association (LIT-1884) + _is_proxy_admin = ( + user_api_key_dict.user_role is not None + and user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value + ) + if not _is_proxy_admin and data.user_id is None: + data.user_id = user_api_key_dict.user_id + verbose_proxy_logger.warning( + "key/generate: auto-assigning user_id=%s for non-admin caller", + user_api_key_dict.user_id, + ) + team_table: Optional[LiteLLM_TeamTableCachedObj] = None if data.team_id is not None: try: @@ -1227,6 +1275,12 @@ async def generate_key_fn( verbose_proxy_logger.debug( f"Error getting team object in `/key/generate`: {e}" ) + # For non-admin callers, team must exist (LIT-1884) + if not _is_proxy_admin: + raise HTTPException( + status_code=400, + detail=f"Team not found for team_id={data.team_id}. Non-admin users cannot create keys for non-existent teams.", + ) key_generation_check( team_table=team_table, @@ -1809,11 +1863,26 @@ async def _validate_update_key_data( user_api_key_cache: Any, ) -> None: """Validate permissions and constraints for key update.""" + _is_proxy_admin = ( + user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value + ) + + # Prevent non-admin from removing user_id (setting to empty string) (LIT-1884) + if ( + data.user_id is not None + and data.user_id == "" + and not _is_proxy_admin + ): + raise HTTPException( + status_code=403, + detail="Non-admin users cannot remove the user_id from a key.", + ) + # sanity check - prevent non-proxy admin user from updating key to belong to a different user if ( data.user_id is not None and data.user_id != existing_key_row.user_id - and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value + and not _is_proxy_admin ): raise HTTPException( status_code=403, @@ -1836,6 +1905,18 @@ async def _validate_update_key_data( user_api_key_cache=user_api_key_cache, ) + # Admin-only: only proxy admins, team admins, or org admins can modify max_budget + if data.max_budget is not None and data.max_budget != existing_key_row.max_budget: + if prisma_client is not None: + hashed_key = existing_key_row.token + await _check_key_admin_access( + user_api_key_dict=user_api_key_dict, + hashed_token=hashed_key, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + route="/key/update (max_budget)", + ) + # Check team limits if key has a team_id (from request or existing key) team_obj: Optional[LiteLLM_TeamTableCachedObj] = None _team_id_to_check = data.team_id or getattr(existing_key_row, "team_id", None) @@ -1847,6 +1928,13 @@ async def _validate_update_key_data( check_db_only=True, ) + # Validate team exists when non-admin sets a new team_id (LIT-1884) + if team_obj is None and data.team_id is not None and not _is_proxy_admin: + raise HTTPException( + status_code=400, + detail=f"Team not found for team_id={data.team_id}. Non-admin users cannot set keys to non-existent teams.", + ) + if team_obj is not None: await _check_team_key_limits( team_table=team_obj, @@ -2056,7 +2144,10 @@ async def update_key_fn( data=data, existing_key_row=existing_key_row ) - _validate_key_alias_format(key_alias=non_default_values.get("key_alias", None)) + # Only validate key_alias format if it's actually being changed + new_key_alias = non_default_values.get("key_alias", None) + if new_key_alias != existing_key_row.key_alias: + _validate_key_alias_format(key_alias=new_key_alias) await _enforce_unique_key_alias( key_alias=non_default_values.get("key_alias", None), @@ -3412,8 +3503,10 @@ async def _rotate_master_key( # noqa: PLR0915 ) -def get_new_token(data: Optional[RegenerateKeyRequest]) -> str: +async def get_new_token(data: Optional[RegenerateKeyRequest]) -> str: if data and data.new_key is not None: + # Reject custom key values if disabled by admin + await _check_custom_key_allowed(data.new_key) new_token = data.new_key if not data.new_key.startswith("sk-"): raise HTTPException( @@ -3505,7 +3598,7 @@ async def _execute_virtual_key_regeneration( """Generate new token, update DB, invalidate cache, and return response.""" from litellm.proxy.proxy_server import hash_token - new_token = get_new_token(data=data) + new_token = await get_new_token(data=data) new_token_hash = hash_token(new_token) new_token_key_name = f"sk-...{new_token[-4:]}" update_data = {"token": new_token_hash, "key_name": new_token_key_name} @@ -3515,7 +3608,10 @@ async def _execute_virtual_key_regeneration( non_default_values = await prepare_key_update_data( data=data, existing_key_row=key_in_db ) - _validate_key_alias_format(key_alias=non_default_values.get("key_alias")) + # Only validate key_alias format if it's actually being changed + new_key_alias = non_default_values.get("key_alias") + if new_key_alias != key_in_db.key_alias: + _validate_key_alias_format(key_alias=new_key_alias) verbose_proxy_logger.debug("non_default_values: %s", non_default_values) update_data.update(non_default_values) update_data = prisma_client.jsonify_object(data=update_data) @@ -4733,6 +4829,64 @@ def _get_condition_to_filter_out_ui_session_tokens() -> Dict[str, Any]: } +async def _check_key_admin_access( + user_api_key_dict: UserAPIKeyAuth, + hashed_token: str, + prisma_client: Any, + user_api_key_cache: DualCache, + route: str, +) -> None: + """ + Check that the caller has admin privileges for the target key. + + Allowed callers: + - Proxy admin + - Team admin for the key's team + - Org admin for the key's team's organization + + Raises HTTPException(403) if the caller is not authorized. + """ + + if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value: + return + + # Look up the target key to find its team + target_key_row = await prisma_client.db.litellm_verificationtoken.find_unique( + where={"token": hashed_token} + ) + if target_key_row is None: + raise HTTPException( + status_code=404, + detail={"error": f"Key not found: {hashed_token}"}, + ) + + # If the key belongs to a team, check team admin / org admin + if target_key_row.team_id: + team_obj = await get_team_object( + team_id=target_key_row.team_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + check_db_only=True, + ) + if team_obj is not None: + if _is_user_team_admin( + user_api_key_dict=user_api_key_dict, team_obj=team_obj + ): + return + if await _is_user_org_admin_for_team( + user_api_key_dict=user_api_key_dict, team_obj=team_obj + ): + return + + raise HTTPException( + status_code=403, + detail={ + "error": f"Only proxy admins, team admins, or org admins can call {route}. " + f"user_role={user_api_key_dict.user_role}, user_id={user_api_key_dict.user_id}" + }, + ) + + @router.post( "/key/block", tags=["key management"], dependencies=[Depends(user_api_key_auth)] ) @@ -4762,7 +4916,7 @@ async def block_key( }' ``` - Note: This is an admin-only endpoint. Only proxy admins can block keys. + Note: This is an admin-only endpoint. Only proxy admins, team admins, or org admins can block keys. """ from litellm.proxy.proxy_server import ( create_audit_log_for_update, @@ -4788,6 +4942,15 @@ async def block_key( else: hashed_token = data.key + # Admin-only: only proxy admins, team admins, or org admins can block keys + await _check_key_admin_access( + user_api_key_dict=user_api_key_dict, + hashed_token=hashed_token, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + route="/key/block", + ) + if litellm.store_audit_logs is True: # make an audit log for key update record = await prisma_client.db.litellm_verificationtoken.find_unique( @@ -4876,7 +5039,7 @@ async def unblock_key( }' ``` - Note: This is an admin-only endpoint. Only proxy admins can unblock keys. + Note: This is an admin-only endpoint. Only proxy admins, team admins, or org admins can unblock keys. """ from litellm.proxy.proxy_server import ( create_audit_log_for_update, @@ -4902,6 +5065,15 @@ async def unblock_key( else: hashed_token = data.key + # Admin-only: only proxy admins, team admins, or org admins can unblock keys + await _check_key_admin_access( + user_api_key_dict=user_api_key_dict, + hashed_token=hashed_token, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + route="/key/unblock", + ) + if litellm.store_audit_logs is True: # make an audit log for key update record = await prisma_client.db.litellm_verificationtoken.find_unique( diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 0fa27905bab..60bf41709ef 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -129,6 +129,11 @@ class UISettings(BaseModel): description="If enabled, the user search endpoint (/user/filter/ui) restricts results by organization. When off, any authenticated user can search all users.", ) + disable_custom_api_keys: bool = Field( + default=False, + description="If true, users cannot specify custom key values. All keys must be auto-generated.", + ) + class UISettingsResponse(SettingsResponse): """Response model for UI settings""" @@ -149,6 +154,7 @@ class UISettingsResponse(SettingsResponse): "disable_vector_stores_for_internal_users", "allow_vector_stores_for_team_admins", "scope_user_search_to_org", + "disable_custom_api_keys", } # Flags that must be synced from the persisted UISettings into diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index cfc16808afb..a3bca77ae3a 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -41,12 +41,15 @@ _transform_verification_tokens_to_deleted_records, _validate_max_budget, _validate_reset_spend_value, + _validate_update_key_data, can_modify_verification_token, check_org_key_model_specific_limits, check_team_key_model_specific_limits, delete_verification_tokens, + generate_key_fn, generate_key_helper_fn, key_aliases, + key_generation_check, list_keys, prepare_key_update_data, reset_key_spend_fn, @@ -957,22 +960,34 @@ async def test_key_info_returns_object_permission(monkeypatch): ) -def test_get_new_token_with_valid_key(): +@pytest.mark.asyncio +async def test_get_new_token_with_valid_key(monkeypatch): """Test get_new_token function when provided with a valid key that starts with 'sk-'""" + from unittest.mock import AsyncMock + from litellm.proxy._types import RegenerateKeyRequest from litellm.proxy.management_endpoints.key_management_endpoints import ( get_new_token, ) + # Mock get_ui_settings_cached to return setting disabled (custom keys allowed) + monkeypatch.setattr( + "litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached", + AsyncMock(return_value={}), + ) + # Test with valid new_key data = RegenerateKeyRequest(new_key="sk-test123456789") - result = get_new_token(data) + result = await get_new_token(data) assert result == "sk-test123456789" -def test_get_new_token_with_invalid_key(): +@pytest.mark.asyncio +async def test_get_new_token_with_invalid_key(monkeypatch): """Test get_new_token function when provided with an invalid key that doesn't start with 'sk-'""" + from unittest.mock import AsyncMock + from fastapi import HTTPException from litellm.proxy._types import RegenerateKeyRequest @@ -980,16 +995,145 @@ def test_get_new_token_with_invalid_key(): get_new_token, ) + # Mock get_ui_settings_cached to return setting disabled (custom keys allowed) + monkeypatch.setattr( + "litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached", + AsyncMock(return_value={}), + ) + # Test with invalid new_key (doesn't start with 'sk-') data = RegenerateKeyRequest(new_key="invalid-key-123") with pytest.raises(HTTPException) as exc_info: - get_new_token(data) + await get_new_token(data) assert exc_info.value.status_code == 400 assert "New key must start with 'sk-'" in str(exc_info.value.detail) +@pytest.mark.asyncio +async def test_check_custom_key_allowed_when_disabled(monkeypatch): + """_check_custom_key_allowed raises 403 when disable_custom_api_keys is true.""" + from unittest.mock import AsyncMock + + from fastapi import HTTPException + + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _check_custom_key_allowed, + ) + + monkeypatch.setattr( + "litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached", + AsyncMock(return_value={"disable_custom_api_keys": True}), + ) + + with pytest.raises(HTTPException) as exc_info: + await _check_custom_key_allowed("sk-custom-key-123") + + assert exc_info.value.status_code == 403 + assert "disabled" in str(exc_info.value.detail).lower() + + +@pytest.mark.asyncio +async def test_check_custom_key_allowed_when_enabled(monkeypatch): + """_check_custom_key_allowed does nothing when disable_custom_api_keys is false.""" + from unittest.mock import AsyncMock + + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _check_custom_key_allowed, + ) + + monkeypatch.setattr( + "litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached", + AsyncMock(return_value={"disable_custom_api_keys": False}), + ) + + # Should not raise + await _check_custom_key_allowed("sk-custom-key-123") + + +@pytest.mark.asyncio +async def test_check_custom_key_allowed_when_unset(monkeypatch): + """_check_custom_key_allowed does nothing when setting is not present.""" + from unittest.mock import AsyncMock + + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _check_custom_key_allowed, + ) + + monkeypatch.setattr( + "litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached", + AsyncMock(return_value={}), + ) + + # Should not raise + await _check_custom_key_allowed("sk-custom-key-123") + + +@pytest.mark.asyncio +async def test_check_custom_key_allowed_none_key_always_passes(monkeypatch): + """_check_custom_key_allowed does nothing when key is None, even if setting is on.""" + from unittest.mock import AsyncMock + + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _check_custom_key_allowed, + ) + + monkeypatch.setattr( + "litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached", + AsyncMock(return_value={"disable_custom_api_keys": True}), + ) + + # Should not raise — None means auto-generate + await _check_custom_key_allowed(None) + + +@pytest.mark.asyncio +async def test_get_new_token_rejected_when_custom_keys_disabled(monkeypatch): + """get_new_token raises 403 when new_key is set and disable_custom_api_keys is true.""" + from unittest.mock import AsyncMock + + from fastapi import HTTPException + + from litellm.proxy._types import RegenerateKeyRequest + from litellm.proxy.management_endpoints.key_management_endpoints import ( + get_new_token, + ) + + monkeypatch.setattr( + "litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached", + AsyncMock(return_value={"disable_custom_api_keys": True}), + ) + + data = RegenerateKeyRequest(new_key="sk-custom-regen-key") + + with pytest.raises(HTTPException) as exc_info: + await get_new_token(data) + + assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_get_new_token_auto_generates_when_custom_keys_disabled(monkeypatch): + """get_new_token auto-generates a key when new_key is None, even if setting is on.""" + from unittest.mock import AsyncMock + + from litellm.proxy._types import RegenerateKeyRequest + from litellm.proxy.management_endpoints.key_management_endpoints import ( + get_new_token, + ) + + monkeypatch.setattr( + "litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached", + AsyncMock(return_value={"disable_custom_api_keys": True}), + ) + + data = RegenerateKeyRequest() # no new_key + result = await get_new_token(data) + + assert result.startswith("sk-") + + @pytest.mark.asyncio async def test_generate_service_account_requires_team_id(): with pytest.raises(HTTPException): @@ -7185,3 +7329,773 @@ def test_update_key_request_has_organization_id(): # Also verify it defaults to None data_no_org = UpdateKeyRequest(key="sk-test-key") assert data_no_org.organization_id is None + + +# ============================================================================ +# Tests for admin-only access on /key/block, /key/unblock, /key/update max_budget +# ============================================================================ + + +def _setup_block_unblock_mocks(monkeypatch, mock_key_team_id=None): + """Helper to set up common mocks for block/unblock tests.""" + mock_prisma_client = AsyncMock() + mock_user_api_key_cache = MagicMock() + mock_proxy_logging_obj = MagicMock() + + test_hashed_token = ( + "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd" + ) + + mock_key_record = MagicMock() + mock_key_record.token = test_hashed_token + mock_key_record.blocked = False + mock_key_record.team_id = mock_key_team_id + mock_key_record.model_dump_json.return_value = ( + f'{{"token": "{test_hashed_token}", "blocked": false}}' + ) + + mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock( + return_value=mock_key_record + ) + mock_prisma_client.db.litellm_verificationtoken.update = AsyncMock( + return_value=mock_key_record + ) + + mock_key_object = MagicMock() + mock_key_object.blocked = True + + def mock_hash_token(token): + if token.startswith("sk-"): + return test_hashed_token + return token + + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + monkeypatch.setattr( + "litellm.proxy.proxy_server.user_api_key_cache", mock_user_api_key_cache + ) + monkeypatch.setattr( + "litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging_obj + ) + monkeypatch.setattr("litellm.proxy.proxy_server.hash_token", mock_hash_token) + monkeypatch.setattr("litellm.store_audit_logs", False) + + async def mock_get_key_object(**kwargs): + return mock_key_object + + async def mock_cache_key_object(**kwargs): + pass + + monkeypatch.setattr( + "litellm.proxy.management_endpoints.key_management_endpoints.get_key_object", + mock_get_key_object, + ) + monkeypatch.setattr( + "litellm.proxy.management_endpoints.key_management_endpoints._cache_key_object", + mock_cache_key_object, + ) + + return mock_prisma_client, test_hashed_token + + +@pytest.mark.asyncio +async def test_block_key_rejected_for_internal_user(monkeypatch): + """Internal users should not be able to block keys.""" + from litellm.proxy._types import BlockKeyRequest + from litellm.proxy.management_endpoints.key_management_endpoints import block_key + + _setup_block_unblock_mocks(monkeypatch) + + mock_request = MagicMock() + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-internal", + user_id="internal_user", + ) + + with pytest.raises(HTTPException) as exc: + await block_key( + data=BlockKeyRequest(key="sk-test123456789"), + http_request=mock_request, + user_api_key_dict=user_api_key_dict, + litellm_changed_by=None, + ) + + assert exc.value.status_code == 403 + assert "Only proxy admins, team admins, or org admins" in str(exc.value.detail) + + +@pytest.mark.asyncio +async def test_unblock_key_rejected_for_internal_user(monkeypatch): + """Internal users should not be able to unblock keys.""" + from litellm.proxy._types import BlockKeyRequest + from litellm.proxy.management_endpoints.key_management_endpoints import unblock_key + + _setup_block_unblock_mocks(monkeypatch) + + mock_request = MagicMock() + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-internal", + user_id="internal_user", + ) + + with pytest.raises(HTTPException) as exc: + await unblock_key( + data=BlockKeyRequest(key="sk-test123456789"), + http_request=mock_request, + user_api_key_dict=user_api_key_dict, + litellm_changed_by=None, + ) + + assert exc.value.status_code == 403 + assert "Only proxy admins, team admins, or org admins" in str(exc.value.detail) + + +@pytest.mark.asyncio +async def test_block_key_allowed_for_proxy_admin(monkeypatch): + """Proxy admins should be able to block keys.""" + from litellm.proxy._types import BlockKeyRequest + from litellm.proxy.management_endpoints.key_management_endpoints import block_key + + _setup_block_unblock_mocks(monkeypatch) + + mock_request = MagicMock() + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, + api_key="sk-admin", + user_id="admin_user", + ) + + result = await block_key( + data=BlockKeyRequest(key="sk-test123456789"), + http_request=mock_request, + user_api_key_dict=user_api_key_dict, + litellm_changed_by=None, + ) + assert result is not None + + +@pytest.mark.asyncio +async def test_block_key_allowed_for_team_admin(monkeypatch): + """Team admins should be able to block keys belonging to their team.""" + from litellm.proxy._types import BlockKeyRequest + from litellm.proxy.management_endpoints.key_management_endpoints import block_key + + team_id = "team-123" + _setup_block_unblock_mocks(monkeypatch, mock_key_team_id=team_id) + + # Mock get_team_object to return a team where the user is admin + team_obj = LiteLLM_TeamTableCachedObj( + team_id=team_id, + members_with_roles=[ + Member(user_id="team_admin_user", role="admin"), + ], + ) + + async def mock_get_team_object(**kwargs): + return team_obj + + monkeypatch.setattr( + "litellm.proxy.management_endpoints.key_management_endpoints.get_team_object", + mock_get_team_object, + ) + + mock_request = MagicMock() + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-teamadmin", + user_id="team_admin_user", + ) + + result = await block_key( + data=BlockKeyRequest(key="sk-test123456789"), + http_request=mock_request, + user_api_key_dict=user_api_key_dict, + litellm_changed_by=None, + ) + assert result is not None + + +@pytest.mark.asyncio +async def test_update_key_max_budget_rejected_for_internal_user(monkeypatch): + """Internal users should not be able to modify max_budget on keys.""" + from litellm.proxy.management_endpoints.key_management_endpoints import ( + update_key_fn, + ) + + mock_prisma_client = AsyncMock() + mock_user_api_key_cache = AsyncMock() + mock_proxy_logging_obj = MagicMock() + + test_hashed_token = ( + "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd" + ) + + # Mock existing key row + mock_existing_key = MagicMock() + mock_existing_key.token = test_hashed_token + mock_existing_key.user_id = "internal_user" + mock_existing_key.team_id = None + mock_existing_key.project_id = None + mock_existing_key.max_budget = 10.0 + mock_existing_key.models = [] + mock_existing_key.model_dump.return_value = { + "token": test_hashed_token, + "user_id": "internal_user", + "team_id": None, + "max_budget": 10.0, + } + + mock_prisma_client.get_data = AsyncMock(return_value=mock_existing_key) + mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock( + return_value=mock_existing_key + ) + + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + monkeypatch.setattr( + "litellm.proxy.proxy_server.user_api_key_cache", mock_user_api_key_cache + ) + monkeypatch.setattr( + "litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging_obj + ) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None) + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True) + + mock_request = MagicMock() + mock_request.query_params = {} + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-internal", + user_id="internal_user", + ) + + with pytest.raises(ProxyException) as exc: + await update_key_fn( + request=mock_request, + data=UpdateKeyRequest(key=test_hashed_token, max_budget=999999), + user_api_key_dict=user_api_key_dict, + litellm_changed_by=None, + ) + + assert str(exc.value.code) == "403" + assert "Only proxy admins, team admins, or org admins" in str(exc.value.message) + + +@pytest.mark.asyncio +async def test_update_key_non_budget_fields_allowed_for_internal_user(monkeypatch): + """Internal users should still be able to update non-budget fields on their own keys.""" + from litellm.proxy.management_endpoints.key_management_endpoints import ( + update_key_fn, + ) + + mock_prisma_client = AsyncMock() + mock_user_api_key_cache = AsyncMock() + mock_proxy_logging_obj = MagicMock() + + test_hashed_token = ( + "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd" + ) + + # Mock existing key row + mock_existing_key = MagicMock() + mock_existing_key.token = test_hashed_token + mock_existing_key.user_id = "internal_user" + mock_existing_key.team_id = None + mock_existing_key.project_id = None + mock_existing_key.max_budget = 10.0 + mock_existing_key.key_alias = None + mock_existing_key.models = [] + mock_existing_key.model_dump.return_value = { + "token": test_hashed_token, + "user_id": "internal_user", + "team_id": None, + "max_budget": 10.0, + } + + mock_updated_key = MagicMock() + mock_updated_key.token = test_hashed_token + mock_updated_key.key_alias = "my-alias" + + mock_prisma_client.get_data = AsyncMock(return_value=mock_existing_key) + mock_prisma_client.update_data = AsyncMock(return_value=mock_updated_key) + mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock( + return_value=mock_existing_key + ) + + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + monkeypatch.setattr( + "litellm.proxy.proxy_server.user_api_key_cache", mock_user_api_key_cache + ) + monkeypatch.setattr( + "litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging_obj + ) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None) + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True) + monkeypatch.setattr("litellm.store_audit_logs", False) + + def mock_hash_token(token): + return test_hashed_token + + monkeypatch.setattr("litellm.proxy.proxy_server.hash_token", mock_hash_token) + + async def mock_cache_key_object(**kwargs): + pass + + async def mock_delete_cache_key_object(**kwargs): + pass + + monkeypatch.setattr( + "litellm.proxy.management_endpoints.key_management_endpoints._cache_key_object", + mock_cache_key_object, + ) + monkeypatch.setattr( + "litellm.proxy.management_endpoints.key_management_endpoints._delete_cache_key_object", + mock_delete_cache_key_object, + ) + + # Mock _enforce_unique_key_alias to avoid DB call + async def mock_enforce_unique_key_alias(**kwargs): + pass + + monkeypatch.setattr( + "litellm.proxy.management_endpoints.key_management_endpoints._enforce_unique_key_alias", + mock_enforce_unique_key_alias, + ) + + mock_request = MagicMock() + mock_request.query_params = {} + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-internal", + user_id="internal_user", + ) + + # Updating key_alias (non-budget field) should succeed + result = await update_key_fn( + request=mock_request, + data=UpdateKeyRequest(key=test_hashed_token, key_alias="my-alias"), + user_api_key_dict=user_api_key_dict, + litellm_changed_by=None, + ) + + assert result is not None + + +# ============================================================================ +# LIT-1884: Internal users cannot create invalid keys +# ============================================================================ + + +class TestLIT1884KeyGenerateValidation: + """Tests for LIT-1884: internal users should not be able to generate invalid keys.""" + + @pytest.mark.asyncio + async def test_internal_user_generate_key_no_user_id_auto_assigns(self): + """ + When an internal_user calls /key/generate without user_id, + the caller's user_id should be auto-assigned before reaching + _common_key_generation_helper. + """ + mock_prisma_client = AsyncMock() + + data = GenerateKeyRequest(key_alias="test-alias") + assert data.user_id is None + + user_api_key_dict = UserAPIKeyAuth( + user_id="internal-user-123", + user_role=LitellmUserRoles.INTERNAL_USER, + ) + + # Patch _common_key_generation_helper to avoid needing full DB mocks. + # We just want to verify user_id is set before we reach this point. + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), \ + patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()), \ + patch("litellm.proxy.proxy_server.user_custom_key_generate", None), \ + patch( + "litellm.proxy.management_endpoints.key_management_endpoints._common_key_generation_helper", + new_callable=AsyncMock, + return_value=MagicMock(), + ): + await generate_key_fn( + data=data, + user_api_key_dict=user_api_key_dict, + litellm_changed_by=None, + ) + + # The data object should have been mutated to include the caller's user_id + assert data.user_id == "internal-user-123" + + @pytest.mark.asyncio + async def test_internal_user_generate_key_invalid_team_id_rejected(self): + """ + When an internal_user provides a non-existent team_id, + key/generate should raise ProxyException with status 400. + """ + mock_prisma_client = AsyncMock() + + data = GenerateKeyRequest( + key_alias="test-alias", + team_id="nonexistent-team-id", + ) + + user_api_key_dict = UserAPIKeyAuth( + user_id="internal-user-123", + user_role=LitellmUserRoles.INTERNAL_USER, + ) + + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), \ + patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()), \ + patch("litellm.proxy.proxy_server.user_custom_key_generate", None), \ + patch( + "litellm.proxy.management_endpoints.key_management_endpoints.get_team_object", + AsyncMock(side_effect=Exception("Team not found")), + ): + with pytest.raises(ProxyException) as exc_info: + await generate_key_fn( + data=data, + user_api_key_dict=user_api_key_dict, + litellm_changed_by=None, + ) + assert str(exc_info.value.code) == "400" + assert "Team not found" in str(exc_info.value.message) + + @pytest.mark.asyncio + async def test_admin_generate_key_invalid_team_id_allowed(self): + """ + Admin callers should be allowed to create keys with any team_id, + even if the team doesn't exist (team_table=None is OK for admins). + """ + data = GenerateKeyRequest( + key_alias="admin-key", + team_id="nonexistent-team-id", + user_id="admin-user", + ) + + user_api_key_dict = UserAPIKeyAuth( + user_id="admin-user", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + mock_prisma_client = AsyncMock() + + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), \ + patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()), \ + patch("litellm.proxy.proxy_server.user_custom_key_generate", None), \ + patch( + "litellm.proxy.management_endpoints.key_management_endpoints.get_team_object", + AsyncMock(side_effect=Exception("Team not found")), + ), \ + patch( + "litellm.proxy.management_endpoints.key_management_endpoints._common_key_generation_helper", + new_callable=AsyncMock, + return_value=MagicMock(), + ): + # Should NOT raise — admin bypasses team validation + result = await generate_key_fn( + data=data, + user_api_key_dict=user_api_key_dict, + litellm_changed_by=None, + ) + assert result is not None + + @pytest.mark.asyncio + async def test_admin_generate_key_no_user_id_not_auto_assigned(self): + """ + Admin callers should NOT have user_id auto-assigned — they may + intentionally create keys without a user_id. + """ + data = GenerateKeyRequest(key_alias="admin-unbound-key") + assert data.user_id is None + + user_api_key_dict = UserAPIKeyAuth( + user_id="admin-user", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + mock_prisma_client = AsyncMock() + + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), \ + patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()), \ + patch("litellm.proxy.proxy_server.user_custom_key_generate", None), \ + patch( + "litellm.proxy.management_endpoints.key_management_endpoints._common_key_generation_helper", + new_callable=AsyncMock, + return_value=MagicMock(), + ): + await generate_key_fn( + data=data, + user_api_key_dict=user_api_key_dict, + litellm_changed_by=None, + ) + + # user_id should remain None for admin + assert data.user_id is None + + def test_key_generation_check_non_admin_no_team_table_raises(self): + """ + key_generation_check should raise 400 for non-admin when team_table is None + and key_generation_settings is not set. + """ + data = GenerateKeyRequest(team_id="some-team-id") + user_api_key_dict = UserAPIKeyAuth( + user_id="internal-user", + user_role=LitellmUserRoles.INTERNAL_USER, + ) + + with patch.object(litellm, "key_generation_settings", None): + with pytest.raises(HTTPException) as exc_info: + key_generation_check( + team_table=None, + user_api_key_dict=user_api_key_dict, + data=data, + route="key_generate", + ) + assert exc_info.value.status_code == 400 + assert "Unable to find team object" in str(exc_info.value.detail) + + def test_key_generation_check_admin_no_team_table_allowed(self): + """ + key_generation_check should allow admin to proceed even when team_table is None. + """ + data = GenerateKeyRequest(team_id="some-team-id") + user_api_key_dict = UserAPIKeyAuth( + user_id="admin-user", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + with patch.object(litellm, "key_generation_settings", None): + result = key_generation_check( + team_table=None, + user_api_key_dict=user_api_key_dict, + data=data, + route="key_generate", + ) + assert result is True + + +class TestLIT1884KeyUpdateValidation: + """Tests for LIT-1884: internal users should not be able to update keys to remove user_id or set invalid team.""" + + @pytest.mark.asyncio + async def test_internal_user_cannot_remove_user_id(self): + """ + Non-admin users should not be able to set user_id to empty string (remove it). + """ + data = UpdateKeyRequest(key="sk-test-key", user_id="") + existing_key_row = MagicMock() + existing_key_row.user_id = "internal-user-123" + existing_key_row.token = "hashed_token" + existing_key_row.team_id = None + + user_api_key_dict = UserAPIKeyAuth( + user_id="internal-user-123", + user_role=LitellmUserRoles.INTERNAL_USER, + ) + + with pytest.raises(HTTPException) as exc_info: + await _validate_update_key_data( + data=data, + existing_key_row=existing_key_row, + user_api_key_dict=user_api_key_dict, + llm_router=None, + premium_user=False, + prisma_client=AsyncMock(), + user_api_key_cache=MagicMock(), + ) + assert exc_info.value.status_code == 403 + assert "cannot remove the user_id" in str(exc_info.value.detail) + + @pytest.mark.asyncio + async def test_internal_user_cannot_set_invalid_team_id(self): + """ + Non-admin users should not be able to update a key to a non-existent team. + get_team_object raises HTTPException(404) when team doesn't exist in DB. + """ + data = UpdateKeyRequest(key="sk-test-key", team_id="nonexistent-team") + existing_key_row = MagicMock() + existing_key_row.user_id = "internal-user-123" + existing_key_row.token = "hashed_token" + existing_key_row.team_id = None + existing_key_row.organization_id = None + existing_key_row.project_id = None + + user_api_key_dict = UserAPIKeyAuth( + user_id="internal-user-123", + user_role=LitellmUserRoles.INTERNAL_USER, + ) + + with patch( + "litellm.proxy.management_endpoints.key_management_endpoints.get_team_object", + AsyncMock(side_effect=HTTPException( + status_code=404, + detail="Team doesn't exist in db. Team=nonexistent-team.", + )), + ): + with pytest.raises(HTTPException) as exc_info: + await _validate_update_key_data( + data=data, + existing_key_row=existing_key_row, + user_api_key_dict=user_api_key_dict, + llm_router=None, + premium_user=False, + prisma_client=AsyncMock(), + user_api_key_cache=MagicMock(), + ) + assert exc_info.value.status_code == 404 + assert "Team doesn't exist" in str(exc_info.value.detail) + + @pytest.mark.asyncio + async def test_admin_can_remove_user_id(self): + """ + Admin users should be allowed to set user_id to empty string. + """ + data = UpdateKeyRequest(key="sk-test-key", user_id="") + existing_key_row = MagicMock() + existing_key_row.user_id = "some-user" + existing_key_row.token = "hashed_token" + existing_key_row.team_id = None + existing_key_row.organization_id = None + existing_key_row.project_id = None + + user_api_key_dict = UserAPIKeyAuth( + user_id="admin-user", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + mock_prisma_client = AsyncMock() + + # Should NOT raise + await _validate_update_key_data( + data=data, + existing_key_row=existing_key_row, + user_api_key_dict=user_api_key_dict, + llm_router=None, + premium_user=False, + prisma_client=mock_prisma_client, + user_api_key_cache=MagicMock(), + ) + + +class TestKeyAliasSkipValidationOnUnchanged: + """ + Test that updating/regenerating a key without changing its key_alias + does NOT re-validate the alias. This prevents legacy aliases (created + before stricter validation rules) from blocking edits to other fields. + """ + + @pytest.fixture(autouse=True) + def enable_validation(self): + litellm.enable_key_alias_format_validation = True + yield + litellm.enable_key_alias_format_validation = False + + @pytest.fixture + def mock_prisma(self): + prisma = MagicMock() + prisma.db = MagicMock() + prisma.db.litellm_verificationtoken = MagicMock() + prisma.get_data = AsyncMock(return_value=None) # no duplicate alias + prisma.update_data = AsyncMock(return_value=None) + prisma.jsonify_object = MagicMock(side_effect=lambda data: data) + return prisma + + @pytest.fixture + def existing_key_with_legacy_alias(self): + """A key whose alias contains '@' — valid now, but simulates a legacy alias.""" + return LiteLLM_VerificationToken( + token="hashed_token_123", + key_alias="user@domain.com", + team_id="team-1", + models=[], + max_budget=100.0, + ) + + @pytest.mark.asyncio + async def test_update_key_unchanged_legacy_alias_passes( + self, mock_prisma, existing_key_with_legacy_alias + ): + """ + Updating a key without changing its key_alias should skip format + validation — even if the alias wouldn't pass current rules. + """ + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _validate_key_alias_format, + ) + + # Temporarily make the regex reject '@' to simulate stricter rules + import re + from litellm.proxy.management_endpoints import key_management_endpoints as mod + + original_pattern = mod._KEY_ALIAS_PATTERN + mod._KEY_ALIAS_PATTERN = re.compile( + r"^[a-zA-Z0-9][a-zA-Z0-9_\-/\.]{0,253}[a-zA-Z0-9]$" + ) + try: + # Confirm the alias WOULD fail validation directly + with pytest.raises(ProxyException): + _validate_key_alias_format("user@domain.com") + + # But prepare_key_update_data + the skip logic should allow it + # Simulate what update_key_fn does: alias is in non_default_values + # but matches existing_key_row.key_alias => skip validation + existing_alias = existing_key_with_legacy_alias.key_alias + new_alias = "user@domain.com" # same as existing + assert new_alias == existing_alias # unchanged + + # This is the core logic from update_key_fn: + if new_alias != existing_alias: + _validate_key_alias_format(new_alias) + # No exception raised — test passes + finally: + mod._KEY_ALIAS_PATTERN = original_pattern + + @pytest.mark.asyncio + async def test_update_key_changed_alias_still_validated( + self, mock_prisma, existing_key_with_legacy_alias + ): + """ + When the alias IS being changed, validation should still run. + """ + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _validate_key_alias_format, + ) + + existing_alias = existing_key_with_legacy_alias.key_alias + new_alias = "!invalid!" + + assert new_alias != existing_alias + with pytest.raises(ProxyException): + if new_alias != existing_alias: + _validate_key_alias_format(new_alias) + + @pytest.mark.asyncio + async def test_update_key_changed_to_valid_alias_passes( + self, mock_prisma, existing_key_with_legacy_alias + ): + """ + Changing the alias to a new valid value should pass validation. + """ + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _validate_key_alias_format, + ) + + existing_alias = existing_key_with_legacy_alias.key_alias + new_alias = "new-valid-alias" + + assert new_alias != existing_alias + # Should not raise + if new_alias != existing_alias: + _validate_key_alias_format(new_alias) + + @pytest.mark.asyncio + async def test_update_key_alias_none_skips_validation(self): + """ + When key_alias is not in the update payload (None), validation + should be skipped regardless. + """ + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _validate_key_alias_format, + ) + + # None alias should always pass + _validate_key_alias_format(None) diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/HashicorpVaultEmptyPlaceholder.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/HashicorpVaultEmptyPlaceholder.test.tsx new file mode 100644 index 00000000000..4214d5fda7c --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/HashicorpVaultEmptyPlaceholder.test.tsx @@ -0,0 +1,29 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import HashicorpVaultEmptyPlaceholder from "./HashicorpVaultEmptyPlaceholder"; + +describe("HashicorpVaultEmptyPlaceholder", () => { + it("should render the empty state message and configure button", () => { + render(); + expect(screen.getByText("No Vault Configuration Found")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /configure vault/i })).toBeInTheDocument(); + }); + + it("should call onAdd when the configure button is clicked", async () => { + const onAdd = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /configure vault/i })); + + expect(onAdd).toHaveBeenCalledOnce(); + }); + + it("should display the description text about Vault purpose", () => { + render(); + expect( + screen.getByText(/Configure Hashicorp Vault to securely manage provider API keys/), + ).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/PageVisibilitySettings.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/PageVisibilitySettings.test.tsx new file mode 100644 index 00000000000..798572b2037 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/PageVisibilitySettings.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import PageVisibilitySettings from "./PageVisibilitySettings"; + +vi.mock("@/components/page_utils", () => ({ + getAvailablePages: () => [ + { page: "usage", label: "Usage", description: "View usage stats", group: "Analytics" }, + { page: "models", label: "Models", description: "Manage models", group: "Analytics" }, + { page: "keys", label: "API Keys", description: "Manage API keys", group: "Access" }, + ], +})); + +describe("PageVisibilitySettings", () => { + it("should render the not-set tag when enabledPagesInternalUsers is null", () => { + render( + , + ); + expect(screen.getByText("Not set (all pages visible)")).toBeInTheDocument(); + }); + + it("should show the selected page count tag when pages are configured", () => { + render( + , + ); + expect(screen.getByText("2 pages selected")).toBeInTheDocument(); + }); + + it("should show singular 'page' when exactly one page is selected", () => { + render( + , + ); + expect(screen.getByText("1 page selected")).toBeInTheDocument(); + }); + + it("should call onUpdate with null when reset button is clicked", async () => { + const onUpdate = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + // Expand the collapse panel first to reveal the reset button + await user.click(screen.getByRole("button", { name: /configure page visibility/i })); + await user.click(await screen.findByRole("button", { name: /reset to default/i })); + + expect(onUpdate).toHaveBeenCalledWith({ enabled_ui_pages_internal_users: null }); + }); + + it("should display the property description when provided", () => { + render( + , + ); + expect(screen.getByText("Controls which pages are visible")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx index a22c78c9430..fb7c38449b0 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx @@ -24,6 +24,7 @@ export default function UISettings() { const disableVectorStoresProperty = schema?.properties?.disable_vector_stores_for_internal_users; const allowVectorStoresTeamAdminsProperty = schema?.properties?.allow_vector_stores_for_team_admins; const scopeUserSearchProperty = schema?.properties?.scope_user_search_to_org; + const disableCustomApiKeysProperty = schema?.properties?.disable_custom_api_keys; const values = data?.values ?? {}; const isDisabledForInternalUsers = Boolean(values.disable_model_add_for_internal_users); const isDisabledTeamAdminDeleteTeamUser = Boolean(values.disable_team_admin_delete_team_user); @@ -182,6 +183,20 @@ export default function UISettings() { ); }; + const handleToggleDisableCustomApiKeys = (checked: boolean) => { + updateSettings( + { disable_custom_api_keys: checked }, + { + onSuccess: () => { + NotificationManager.success("UI settings updated successfully"); + }, + onError: (error) => { + NotificationManager.fromBackend(error); + }, + }, + ); + }; + return ( {isLoading ? ( @@ -382,6 +397,26 @@ export default function UISettings() { + {/* Disable custom Virtual key values */} + + + + Disable custom Virtual key values + + {disableCustomApiKeysProperty?.description ?? + "If true, users cannot specify custom key values. All keys must be auto-generated."} + + + + + + {/* Page Visibility for Internal Users */} = ({ team, teams, data, addKey, autoOp const { data: projects, isLoading: isProjectsLoading } = useProjects(); const { data: uiSettingsData } = useUISettings(); const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui); + const disableCustomApiKeys = Boolean(uiSettingsData?.values?.disable_custom_api_keys); const queryClient = useQueryClient(); const [form] = Form.useForm(); const [isModalVisible, setIsModalVisible] = useState(false); @@ -1581,6 +1582,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp "budget_duration", "tpm_limit", "rpm_limit", + ...(disableCustomApiKeys ? ["key"] : []), ]} /> diff --git a/ui/litellm-dashboard/src/components/ui/ui-loading-spinner.test.tsx b/ui/litellm-dashboard/src/components/ui/ui-loading-spinner.test.tsx new file mode 100644 index 00000000000..73b2de54c4c --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/ui-loading-spinner.test.tsx @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { UiLoadingSpinner } from "./ui-loading-spinner"; + +describe("UiLoadingSpinner", () => { + it("should render an SVG element", () => { + render(); + expect(screen.getByTestId("spinner")).toBeInTheDocument(); + }); + + it("should apply custom className alongside default classes", () => { + render(); + const svg = screen.getByTestId("spinner"); + expect(svg).toHaveClass("text-red-500"); + expect(svg).toHaveClass("animate-spin"); + }); + + it("should spread additional SVG props onto the element", () => { + render(); + expect(screen.getByLabelText("Loading")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx index 0bb0f2a44d4..8ccf0a9e1b5 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx @@ -451,7 +451,7 @@ describe("useLogFilterLogic", () => { ); }); - it("should fall back to logs when backend filters are active but API returns empty", async () => { + it("should return empty results when backend filters are active but API returns empty", async () => { vi.mocked(uiSpendLogsCall).mockResolvedValue({ data: [], total: 0, @@ -474,8 +474,7 @@ describe("useLogFilterLogic", () => { { timeout: 500 }, ); - expect(result.current.filteredLogs.data).toHaveLength(1); - expect(result.current.filteredLogs.data[0].request_id).toBe("client-req"); + expect(result.current.filteredLogs.data).toHaveLength(0); }); it("should refetch when sortBy changes and backend filters are active", async () => { diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx index 400e86d19ee..4e7153b64cd 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx @@ -228,7 +228,7 @@ export function useLogFilterLogic({ const filteredLogs: PaginatedResponse = useMemo(() => { if (hasBackendFilters) { // Prefer backend result if present; otherwise fall back to latest logs - if (backendFilteredLogs && backendFilteredLogs.data && backendFilteredLogs.data.length > 0) { + if (backendFilteredLogs && backendFilteredLogs.data) { return backendFilteredLogs; } return ( diff --git a/ui/litellm-dashboard/src/utils/errorUtils.test.ts b/ui/litellm-dashboard/src/utils/errorUtils.test.ts new file mode 100644 index 00000000000..ccd65717f40 --- /dev/null +++ b/ui/litellm-dashboard/src/utils/errorUtils.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { extractErrorMessage } from "./errorUtils"; + +describe("extractErrorMessage", () => { + it("should return the message from an Error instance", () => { + expect(extractErrorMessage(new Error("Something broke"))).toBe("Something broke"); + }); + + it("should return detail when it is a string", () => { + expect(extractErrorMessage({ detail: "Not found" })).toBe("Not found"); + }); + + it("should join msg fields from a FastAPI 422 detail array", () => { + const err = { + detail: [ + { msg: "field required", loc: ["body", "name"], type: "value_error" }, + { msg: "invalid type", loc: ["body", "age"], type: "type_error" }, + ], + }; + expect(extractErrorMessage(err)).toBe("field required; invalid type"); + }); + + it("should extract error from nested detail object", () => { + expect(extractErrorMessage({ detail: { error: "bad request" } })).toBe("bad request"); + }); + + it("should fall back to message property on plain objects", () => { + expect(extractErrorMessage({ message: "fallback msg" })).toBe("fallback msg"); + }); + + it("should JSON.stringify unknown object shapes", () => { + expect(extractErrorMessage({ foo: "bar" })).toBe('{"foo":"bar"}'); + }); + + it("should stringify primitive non-object values", () => { + expect(extractErrorMessage(42)).toBe("42"); + expect(extractErrorMessage(null)).toBe("null"); + expect(extractErrorMessage(undefined)).toBe("undefined"); + }); +}); diff --git a/ui/litellm-dashboard/src/utils/mcpToolCrudClassification.test.ts b/ui/litellm-dashboard/src/utils/mcpToolCrudClassification.test.ts new file mode 100644 index 00000000000..0ae8ae70bec --- /dev/null +++ b/ui/litellm-dashboard/src/utils/mcpToolCrudClassification.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import { classifyToolOp, groupToolsByCrud } from "./mcpToolCrudClassification"; + +describe("classifyToolOp", () => { + it("should classify read operations by name", () => { + expect(classifyToolOp("get-users")).toBe("read"); + expect(classifyToolOp("list-items")).toBe("read"); + expect(classifyToolOp("search documents")).toBe("read"); + }); + + it("should classify delete operations by name", () => { + expect(classifyToolOp("delete-user")).toBe("delete"); + expect(classifyToolOp("remove-item")).toBe("delete"); + expect(classifyToolOp("purge-cache")).toBe("delete"); + }); + + it("should classify create operations by name", () => { + expect(classifyToolOp("create-user")).toBe("create"); + expect(classifyToolOp("add-item")).toBe("create"); + expect(classifyToolOp("upload-file")).toBe("create"); + }); + + it("should classify update operations by name", () => { + expect(classifyToolOp("update-settings")).toBe("update"); + expect(classifyToolOp("edit-profile")).toBe("update"); + expect(classifyToolOp("rename-file")).toBe("update"); + }); + + it("should prioritize read over delete for names like get-removed-entries", () => { + expect(classifyToolOp("get-removed-entries")).toBe("read"); + expect(classifyToolOp("list-deleted-items")).toBe("read"); + }); + + it("should fall back to description when name is unrecognised", () => { + expect(classifyToolOp("mytool", "This will delete the record")).toBe("delete"); + expect(classifyToolOp("mytool", "fetch data from the API")).toBe("read"); + }); + + it("should return unknown when neither name nor description match", () => { + expect(classifyToolOp("my_tool")).toBe("unknown"); + expect(classifyToolOp("my_tool", "does something")).toBe("unknown"); + }); +}); + +describe("groupToolsByCrud", () => { + it("should group tools into their CRUD categories", () => { + const tools = [ + { name: "get-user", description: "" }, + { name: "create-item", description: "" }, + { name: "delete-record", description: "" }, + { name: "update-settings", description: "" }, + { name: "mysteryop", description: "" }, + ]; + + const groups = groupToolsByCrud(tools); + + expect(groups.read).toHaveLength(1); + expect(groups.create).toHaveLength(1); + expect(groups.delete).toHaveLength(1); + expect(groups.update).toHaveLength(1); + expect(groups.unknown).toHaveLength(1); + }); +});