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);
+ });
+});