430 lines
13 KiB
PowerShell
430 lines
13 KiB
PowerShell
# AI Stack Windows service installer
|
|
# Run in elevated PowerShell:
|
|
# powershell -NoProfile -ExecutionPolicy Bypass -File .\install.ps1
|
|
|
|
param(
|
|
[switch]$Uninstall,
|
|
[switch]$Start,
|
|
[switch]$Stop,
|
|
[switch]$Status,
|
|
[string]$VenvDir,
|
|
[switch]$SkipVenvBootstrap
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
$BASE_DIR = $PSScriptRoot
|
|
$WINSW_EXE = Join-Path $BASE_DIR "WinSW.exe"
|
|
$LITELLM_XML = Join-Path $BASE_DIR "litellm\litellm-service.xml"
|
|
$WEBUI_XML = Join-Path $BASE_DIR "open_webui\openwebui-service.xml"
|
|
$ENV_FILE = Join-Path $BASE_DIR ".env"
|
|
|
|
$LOG_DIR = Join-Path $BASE_DIR "logs\install"
|
|
$INSTALL_LOG = Join-Path $LOG_DIR ("install-{0}.log" -f (Get-Date -Format "yyyyMMdd-HHmmss"))
|
|
|
|
# Venv paths for each service (separate venv for independence)
|
|
$LITELLM_VENV = Join-Path $BASE_DIR "litellm\venv"
|
|
$WEBUI_VENV = Join-Path $BASE_DIR "open_webui\venv"
|
|
|
|
function Fail {
|
|
param([string]$Message)
|
|
Write-Host "[ERROR] $Message" -ForegroundColor Red
|
|
throw $Message
|
|
}
|
|
|
|
function Step {
|
|
param([string]$Message)
|
|
Write-Host "[STEP] $Message" -ForegroundColor Cyan
|
|
}
|
|
|
|
function Diag {
|
|
param([string]$Message)
|
|
Write-Host "[DIAG] $Message" -ForegroundColor DarkGray
|
|
}
|
|
|
|
function Ensure-InstallLogDir {
|
|
if (-not (Test-Path $LOG_DIR)) {
|
|
New-Item -ItemType Directory -Path $LOG_DIR -Force | Out-Null
|
|
}
|
|
}
|
|
|
|
function Get-DotEnvValue {
|
|
param([string]$Name)
|
|
|
|
if (-not (Test-Path $ENV_FILE)) {
|
|
return $null
|
|
}
|
|
|
|
foreach ($line in Get-Content $ENV_FILE) {
|
|
$trimmed = $line.Trim()
|
|
if ([string]::IsNullOrWhiteSpace($trimmed)) { continue }
|
|
if ($trimmed.StartsWith("#")) { continue }
|
|
if ($trimmed -like "$Name=*") {
|
|
return $trimmed.Substring($Name.Length + 1).Trim()
|
|
}
|
|
}
|
|
|
|
return $null
|
|
}
|
|
|
|
function Ensure-GeminiApiKey {
|
|
$apiKey = $env:GEMINI_API_KEY
|
|
if ([string]::IsNullOrWhiteSpace($apiKey)) {
|
|
$apiKey = Get-DotEnvValue -Name "GEMINI_API_KEY"
|
|
}
|
|
|
|
if ([string]::IsNullOrWhiteSpace($apiKey)) {
|
|
Fail "GEMINI_API_KEY not found. Set a system environment variable or add GEMINI_API_KEY=... to .env"
|
|
}
|
|
|
|
[Environment]::SetEnvironmentVariable("GEMINI_API_KEY", $apiKey, "Machine")
|
|
$env:GEMINI_API_KEY = $apiKey
|
|
Diag "GEMINI_API_KEY synced to machine environment for Windows services"
|
|
}
|
|
|
|
function Check-Admin {
|
|
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
|
|
$principal = New-Object System.Security.Principal.WindowsPrincipal($identity)
|
|
if (-not $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
|
Fail "Please run PowerShell as Administrator."
|
|
}
|
|
}
|
|
|
|
function Test-PEExecutable {
|
|
param(
|
|
[string]$Path,
|
|
[string]$Name
|
|
)
|
|
|
|
if (-not (Test-Path $Path)) {
|
|
Fail "$Name not found: $Path"
|
|
}
|
|
|
|
$file = Get-Item $Path
|
|
$bytes = [System.IO.File]::ReadAllBytes($Path)
|
|
$header = if ($bytes.Length -ge 2) { "{0:X2} {1:X2}" -f $bytes[0], $bytes[1] } else { "N/A" }
|
|
|
|
Diag "$Name path: $Path"
|
|
Diag "$Name size: $($file.Length) bytes"
|
|
Diag "$Name header: $header"
|
|
|
|
if ($bytes.Length -lt 2 -or $bytes[0] -ne 0x4D -or $bytes[1] -ne 0x5A) {
|
|
Fail "$Name is not a valid Windows executable (missing MZ header)."
|
|
}
|
|
}
|
|
|
|
function Invoke-External {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$File,
|
|
[string[]]$Arguments = @(),
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$ActionLabel
|
|
)
|
|
|
|
$display = if ($Arguments.Count -gt 0) { " " + ($Arguments -join " ") } else { "" }
|
|
Step "$ActionLabel -> $File$display"
|
|
|
|
try {
|
|
& $File @Arguments
|
|
if ($LASTEXITCODE -ne $null -and $LASTEXITCODE -ne 0) {
|
|
Fail "$ActionLabel failed, exit code: $LASTEXITCODE"
|
|
}
|
|
} catch {
|
|
Write-Host "[ERROR] file: $File" -ForegroundColor Red
|
|
Write-Host "[ERROR] args: $($Arguments -join ' ')" -ForegroundColor Red
|
|
Write-Host "[ERROR] detail: $($_.Exception.Message)" -ForegroundColor Red
|
|
throw
|
|
}
|
|
}
|
|
|
|
function Check-WinSW {
|
|
if (-not (Test-Path $WINSW_EXE)) {
|
|
Fail "WinSW.exe not found in project root. Download WinSW-x64.exe and rename to WinSW.exe."
|
|
}
|
|
|
|
$file = Get-Item $WINSW_EXE
|
|
if ($file.Length -lt 100KB) {
|
|
Fail "WinSW.exe file size looks invalid ($($file.Length) bytes)."
|
|
}
|
|
|
|
Test-PEExecutable -Path $WINSW_EXE -Name "WinSW.exe"
|
|
|
|
Step "Validate WinSW executable"
|
|
Invoke-External -File $WINSW_EXE -Arguments @("--version") -ActionLabel "Check WinSW version"
|
|
}
|
|
|
|
function Resolve-VenvDir {
|
|
param([string]$VenvPath)
|
|
$pythonExe = Join-Path $VenvPath "Scripts\python.exe"
|
|
return (Test-Path $pythonExe)
|
|
}
|
|
|
|
function New-VenvIfNeeded {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$VenvPath,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$ServiceName
|
|
)
|
|
|
|
$pythonExe = Join-Path $VenvPath "Scripts\python.exe"
|
|
if (Test-Path $pythonExe) {
|
|
return $VenvPath
|
|
}
|
|
|
|
if ($SkipVenvBootstrap) {
|
|
Fail "No virtual environment found at $VenvPath and -SkipVenvBootstrap is set."
|
|
}
|
|
|
|
Step "Create virtual environment for $ServiceName : $VenvPath"
|
|
|
|
$created = $false
|
|
|
|
try {
|
|
& py -3.11 -m venv $VenvPath
|
|
if ((Test-Path $pythonExe) -and $LASTEXITCODE -eq 0) { $created = $true }
|
|
} catch {}
|
|
|
|
if (-not $created) {
|
|
try {
|
|
& py -m venv $VenvPath
|
|
if ((Test-Path $pythonExe) -and $LASTEXITCODE -eq 0) { $created = $true }
|
|
} catch {}
|
|
}
|
|
|
|
if (-not $created) {
|
|
try {
|
|
& python -m venv $VenvPath
|
|
if ((Test-Path $pythonExe) -and $LASTEXITCODE -eq 0) { $created = $true }
|
|
} catch {}
|
|
}
|
|
|
|
if (-not $created) {
|
|
Fail "Failed to create virtual environment at $VenvPath. Ensure Python 3.11 is installed."
|
|
}
|
|
|
|
Write-Host "[OK] virtual environment created: $VenvPath" -ForegroundColor Green
|
|
return $VenvPath
|
|
}
|
|
|
|
function Install-LiteLLMDependencies {
|
|
param([string]$VenvPath)
|
|
|
|
$venvPython = Join-Path $VenvPath "Scripts\python.exe"
|
|
$litellmExe = Join-Path $VenvPath "Scripts\litellm.exe"
|
|
|
|
if (Test-Path $litellmExe) {
|
|
Diag "LiteLLM already installed in $VenvPath"
|
|
return
|
|
}
|
|
|
|
if ($SkipVenvBootstrap) {
|
|
Fail "Missing litellm in venv and -SkipVenvBootstrap is set."
|
|
}
|
|
|
|
Step "Install LiteLLM dependencies"
|
|
Invoke-External -File $venvPython -Arguments @("-m", "pip", "install", "--upgrade", "pip", "setuptools", "wheel") -ActionLabel "Upgrade pip toolchain (LiteLLM)"
|
|
|
|
# Install litellm[proxy] with email-validator for pydantic
|
|
Invoke-External -File $venvPython -Arguments @("-m", "pip", "install", "email-validator") -ActionLabel "Install email-validator"
|
|
Invoke-External -File $venvPython -Arguments @("-m", "pip", "install", "litellm[proxy]") -ActionLabel "Install litellm[proxy]"
|
|
|
|
if (-not (Test-Path $litellmExe)) {
|
|
Fail "LiteLLM installed but litellm.exe not found at $litellmExe"
|
|
}
|
|
}
|
|
|
|
function Install-OpenWebUIDependencies {
|
|
param([string]$VenvPath)
|
|
|
|
$venvPython = Join-Path $VenvPath "Scripts\python.exe"
|
|
$webuiExe = Join-Path $VenvPath "Scripts\open-webui.exe"
|
|
|
|
if (Test-Path $webuiExe) {
|
|
Diag "Open WebUI already installed in $VenvPath"
|
|
return
|
|
}
|
|
|
|
if ($SkipVenvBootstrap) {
|
|
Fail "Missing open-webui in venv and -SkipVenvBootstrap is set."
|
|
}
|
|
|
|
Step "Install Open WebUI dependencies"
|
|
Invoke-External -File $venvPython -Arguments @("-m", "pip", "install", "--upgrade", "pip", "setuptools", "wheel") -ActionLabel "Upgrade pip toolchain (Open WebUI)"
|
|
Invoke-External -File $venvPython -Arguments @("-m", "pip", "install", "open-webui") -ActionLabel "Install open-webui"
|
|
|
|
if (-not (Test-Path $webuiExe)) {
|
|
Fail "Open WebUI installed but open-webui.exe not found at $webuiExe"
|
|
}
|
|
}
|
|
|
|
function Check-Venvs {
|
|
Step "Check/create LiteLLM venv"
|
|
$script:LiteLLMVenv = New-VenvIfNeeded -VenvPath $LITELLM_VENV -ServiceName "LiteLLM"
|
|
Install-LiteLLMDependencies -VenvPath $script:LiteLLMVenv
|
|
|
|
Step "Check/create Open WebUI venv"
|
|
$script:WebUIVenv = New-VenvIfNeeded -VenvPath $WEBUI_VENV -ServiceName "Open WebUI"
|
|
Install-OpenWebUIDependencies -VenvPath $script:WebUIVenv
|
|
|
|
$litellmExe = Join-Path $script:LiteLLMVenv "Scripts\litellm.exe"
|
|
$webuiExe = Join-Path $script:WebUIVenv "Scripts\open-webui.exe"
|
|
|
|
if (-not (Test-Path $litellmExe)) { Fail "litellm.exe not found: $litellmExe" }
|
|
if (-not (Test-Path $webuiExe)) { Fail "open-webui.exe not found: $webuiExe" }
|
|
|
|
Diag "LiteLLM venv: $script:LiteLLMVenv"
|
|
Diag "Open WebUI venv: $script:WebUIVenv"
|
|
}
|
|
|
|
function Ensure-DataDirs {
|
|
$dirs = @(
|
|
(Join-Path $BASE_DIR "logs\litellm"),
|
|
(Join-Path $BASE_DIR "logs\openwebui"),
|
|
(Join-Path $BASE_DIR ".data\open-webui")
|
|
)
|
|
|
|
foreach ($d in $dirs) {
|
|
if (-not (Test-Path $d)) {
|
|
New-Item -ItemType Directory -Path $d -Force | Out-Null
|
|
Diag "Created directory: $d"
|
|
}
|
|
}
|
|
}
|
|
|
|
function Install-Services {
|
|
Write-Host ""
|
|
Write-Host "=== Install AI Stack Windows Services ===" -ForegroundColor Cyan
|
|
Write-Host ""
|
|
Write-Host "This install preserves existing config and data directories." -ForegroundColor Yellow
|
|
Write-Host "It only creates missing venvs, installs packages, and registers services." -ForegroundColor Yellow
|
|
Write-Host ""
|
|
|
|
Check-Admin
|
|
Check-WinSW
|
|
Ensure-GeminiApiKey
|
|
Check-Venvs
|
|
Ensure-DataDirs
|
|
|
|
$litellmWinSW = Join-Path $BASE_DIR "litellm\WinSW.exe"
|
|
$webuiWinSW = Join-Path $BASE_DIR "open_webui\WinSW.exe"
|
|
|
|
Step "Copy WinSW.exe into service folders"
|
|
Copy-Item $WINSW_EXE $litellmWinSW -Force
|
|
Copy-Item $WINSW_EXE $webuiWinSW -Force
|
|
|
|
Test-PEExecutable -Path $litellmWinSW -Name "litellm\\WinSW.exe"
|
|
Test-PEExecutable -Path $webuiWinSW -Name "open_webui\\WinSW.exe"
|
|
|
|
Invoke-External -File $litellmWinSW -Arguments @("--version") -ActionLabel "Validate litellm WinSW"
|
|
Invoke-External -File $webuiWinSW -Arguments @("--version") -ActionLabel "Validate open_webui WinSW"
|
|
|
|
Invoke-External -File $litellmWinSW -Arguments @("install", $LITELLM_XML) -ActionLabel "Install LiteLLM service"
|
|
Invoke-External -File $webuiWinSW -Arguments @("install", $WEBUI_XML) -ActionLabel "Install Open WebUI service"
|
|
|
|
Step "Start services"
|
|
Start-Service ai-stack-litellm
|
|
Start-Sleep -Seconds 3
|
|
Start-Service ai-stack-openwebui
|
|
|
|
Write-Host ""
|
|
Write-Host "[OK] Installation completed" -ForegroundColor Green
|
|
Write-Host "Open WebUI: http://127.0.0.1:3000"
|
|
Write-Host "LiteLLM API: http://127.0.0.1:4000"
|
|
Write-Host "Configuration and persistent data were preserved." -ForegroundColor Green
|
|
Write-Host ""
|
|
}
|
|
|
|
function Uninstall-Services {
|
|
Write-Host ""
|
|
Write-Host "=== Uninstall AI Stack Windows Services ===" -ForegroundColor Yellow
|
|
Write-Host ""
|
|
|
|
Check-Admin
|
|
|
|
$litellmWinSW = Join-Path $BASE_DIR "litellm\WinSW.exe"
|
|
$webuiWinSW = Join-Path $BASE_DIR "open_webui\WinSW.exe"
|
|
|
|
foreach ($svc in @("ai-stack-openwebui", "ai-stack-litellm")) {
|
|
$s = Get-Service -Name $svc -ErrorAction SilentlyContinue
|
|
if ($s -and $s.Status -eq "Running") {
|
|
Step "Stop service $svc"
|
|
Stop-Service $svc -Force
|
|
}
|
|
}
|
|
|
|
if (Test-Path $webuiWinSW) {
|
|
Invoke-External -File $webuiWinSW -Arguments @("uninstall", $WEBUI_XML) -ActionLabel "Uninstall Open WebUI service"
|
|
}
|
|
|
|
if (Test-Path $litellmWinSW) {
|
|
Invoke-External -File $litellmWinSW -Arguments @("uninstall", $LITELLM_XML) -ActionLabel "Uninstall LiteLLM service"
|
|
}
|
|
|
|
Write-Host ""
|
|
Write-Host "[OK] Uninstall completed" -ForegroundColor Green
|
|
Write-Host ""
|
|
}
|
|
|
|
function Start-Services {
|
|
Check-Admin
|
|
Step "Start LiteLLM"
|
|
Start-Service ai-stack-litellm
|
|
Start-Sleep -Seconds 3
|
|
Step "Start Open WebUI"
|
|
Start-Service ai-stack-openwebui
|
|
}
|
|
|
|
function Stop-Services {
|
|
Check-Admin
|
|
Step "Stop Open WebUI"
|
|
Stop-Service ai-stack-openwebui -Force -ErrorAction SilentlyContinue
|
|
Step "Stop LiteLLM"
|
|
Stop-Service ai-stack-litellm -Force -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
function Show-Status {
|
|
Write-Host ""
|
|
Write-Host "=== AI Stack Service Status ===" -ForegroundColor Cyan
|
|
Write-Host ""
|
|
|
|
foreach ($svc in @("ai-stack-litellm", "ai-stack-openwebui")) {
|
|
$s = Get-Service -Name $svc -ErrorAction SilentlyContinue
|
|
if ($s) {
|
|
$color = if ($s.Status -eq "Running") { "Green" } else { "Red" }
|
|
Write-Host " $($s.DisplayName): $($s.Status)" -ForegroundColor $color
|
|
} else {
|
|
Write-Host " ${svc}: not installed" -ForegroundColor DarkGray
|
|
}
|
|
}
|
|
|
|
Write-Host ""
|
|
}
|
|
|
|
Ensure-InstallLogDir
|
|
Write-Host "Install log file: $INSTALL_LOG" -ForegroundColor DarkGray
|
|
Start-Transcript -Path $INSTALL_LOG -Append | Out-Null
|
|
|
|
try {
|
|
if ($Uninstall) {
|
|
Uninstall-Services
|
|
} elseif ($Start) {
|
|
Start-Services
|
|
} elseif ($Stop) {
|
|
Stop-Services
|
|
} elseif ($Status) {
|
|
Show-Status
|
|
} else {
|
|
Install-Services
|
|
}
|
|
} catch {
|
|
Write-Host ""
|
|
Write-Host "Install failed. Send this log file:" -ForegroundColor Red
|
|
Write-Host " $INSTALL_LOG" -ForegroundColor Red
|
|
Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red
|
|
throw
|
|
} finally {
|
|
Stop-Transcript | Out-Null
|
|
}
|