diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index d797d9c7e0a..1d9f7fdfb4d 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2203,6 +2203,10 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): None, description="If True, forwards client headers (e.g. Authorization) to the LLM API. Required for Claude Code with Max subscription.", ) + allow_custom_api_keys: Optional[bool] = Field( + None, + description="If False, users cannot specify a custom API key value when creating keys. Keys will always be randomly generated.", + ) class ConfigYAML(LiteLLMPydanticObjectBase): diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 9fb725db02c..38852c306d4 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -650,6 +650,18 @@ async def _common_key_generation_helper( # noqa: PLR0915 prisma_client=prisma_client, ) + # Block custom API keys when disabled in settings + if data.key is not None: + from litellm.proxy.proxy_server import general_settings + + if not general_settings.get("allow_custom_api_keys", True): + raise HTTPException( + status_code=403, + detail={ + "error": "Custom API keys are disabled. Keys must be randomly generated." + }, + ) + # Validate user-provided key format if data.key is not None and not data.key.startswith("sk-"): _masked = ( @@ -1168,10 +1180,9 @@ async def generate_key_fn( # Auto-populate user_id from authenticated user when not provided (non-admins only) if data.user_id is None and user_api_key_dict.user_id is not None: - if user_api_key_dict.user_role not in [ - LitellmUserRoles.PROXY_ADMIN.value, - LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, - ]: + if ( + user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value + ): data.user_id = user_api_key_dict.user_id # Validate budget values are not negative @@ -1894,10 +1905,7 @@ async def update_key_fn( and _update_fields["user_id"] is None and existing_key_row.user_id is not None and user_api_key_dict.user_role - not in [ - LitellmUserRoles.PROXY_ADMIN.value, - LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, - ] + != LitellmUserRoles.PROXY_ADMIN.value ): raise HTTPException( status_code=403, @@ -3333,6 +3341,15 @@ async def _rotate_master_key( # noqa: PLR0915 def get_new_token(data: Optional[RegenerateKeyRequest]) -> str: if data and data.new_key is not None: + from litellm.proxy.proxy_server import general_settings + + if not general_settings.get("allow_custom_api_keys", True): + raise HTTPException( + status_code=403, + detail={ + "error": "Custom API keys are disabled. Keys must be randomly generated." + }, + ) new_token = data.new_key if not data.new_key.startswith("sk-"): raise HTTPException( diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 076a2c3bffd..596e9d3f692 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.", ) + allow_custom_api_keys: bool = Field( + default=True, + description="If false, users cannot specify a custom API key value when creating keys. Keys will always be randomly generated.", + ) + class UISettingsResponse(SettingsResponse): """Response model for UI settings""" @@ -149,6 +154,7 @@ ALLOWED_UI_SETTINGS_FIELDS = { "disable_vector_stores_for_internal_users", "allow_vector_stores_for_team_admins", "scope_user_search_to_org", + "allow_custom_api_keys", } # Flags that must be synced from the persisted UISettings into @@ -159,6 +165,7 @@ _RUNTIME_GENERAL_SETTINGS_FLAGS = [ "allow_agents_for_team_admins", "disable_vector_stores_for_internal_users", "allow_vector_stores_for_team_admins", + "allow_custom_api_keys", ] 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 918913fa0ca..ba3b7e0de4d 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 @@ -986,6 +986,25 @@ def test_get_new_token_with_invalid_key(): assert "New key must start with 'sk-'" in str(exc_info.value.detail) +def test_get_new_token_rejected_when_custom_keys_disabled(): + """Test get_new_token rejects custom keys when allow_custom_api_keys is False""" + from litellm.proxy._types import RegenerateKeyRequest + from litellm.proxy.management_endpoints.key_management_endpoints import ( + get_new_token, + ) + + data = RegenerateKeyRequest(new_key="sk-custom-regen-key") + + with patch( + "litellm.proxy.proxy_server.general_settings", + {"allow_custom_api_keys": False}, + ): + with pytest.raises(HTTPException) as exc_info: + get_new_token(data) + assert exc_info.value.status_code == 403 + assert "Custom API keys are disabled" in exc_info.value.detail["error"] + + @pytest.mark.asyncio async def test_generate_service_account_requires_team_id(): with pytest.raises(HTTPException): @@ -6865,3 +6884,72 @@ async def test_generate_key_validation_user_id_and_team_id(monkeypatch): ), ) assert exc.value.code == "403" + + +@pytest.mark.asyncio +async def test_custom_api_key_rejected_when_disabled(): + """ + When general_settings['allow_custom_api_keys'] is False, + _common_key_generation_helper should reject requests that specify a custom key. + """ + with patch( + "litellm.proxy.proxy_server.general_settings", + {"allow_custom_api_keys": False}, + ), patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( + "litellm.proxy.proxy_server.llm_router" + ), patch( + "litellm.proxy.proxy_server.premium_user", False + ): + mock_prisma.return_value = AsyncMock() + + with pytest.raises(HTTPException) as exc: + await _common_key_generation_helper( + data=GenerateKeyRequest(key="sk-custom-key-123"), + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-1234" + ), + litellm_changed_by=None, + team_table=None, + ) + assert exc.value.status_code == 403 + assert "Custom API keys are disabled" in exc.value.detail["error"] + + +@pytest.mark.asyncio +async def test_custom_api_key_allowed_when_enabled(): + """ + When general_settings['allow_custom_api_keys'] is True (default), + _common_key_generation_helper should allow custom keys. + """ + with patch( + "litellm.proxy.proxy_server.general_settings", + {"allow_custom_api_keys": True}, + ), patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( + "litellm.proxy.proxy_server.llm_router" + ), patch( + "litellm.proxy.proxy_server.premium_user", False + ), patch( + "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" + ), patch( + "litellm.proxy.management_endpoints.key_management_endpoints.generate_key_helper_fn" + ) as mock_generate_key: + mock_prisma.return_value = AsyncMock() + mock_generate_key.return_value = { + "key": "sk-custom-key-123", + "expires": None, + "user_id": "test-user", + "team_id": None, + } + + # Should NOT raise + await _common_key_generation_helper( + data=GenerateKeyRequest(key="sk-custom-key-123"), + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, + api_key="sk-1234", + user_id="test-user", + ), + litellm_changed_by=None, + team_table=None, + ) + mock_generate_key.assert_awaited_once() diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index 78ff01d6419..77b4c640eec 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -163,6 +163,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp const { data: projects, isLoading: isProjectsLoading } = useProjects(); const { data: uiSettingsData } = useUISettings(); const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui); + const allowCustomApiKeys = uiSettingsData?.values?.allow_custom_api_keys !== false; const queryClient = useQueryClient(); const [form] = Form.useForm(); const [isModalVisible, setIsModalVisible] = useState(false); @@ -1537,6 +1538,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp "budget_duration", "tpm_limit", "rpm_limit", + ...(!allowCustomApiKeys ? ["key"] : []), ]} />