From 8e4424b82a3a791df87d571b06bd003387b4a9d6 Mon Sep 17 00:00:00 2001 From: alook Date: Tue, 18 Aug 2026 10:03:35 +0800 Subject: [PATCH] Initial commit --- .env.example | 15 ++ .gitignore | 6 + README.md | 389 ++++++++++++++++++++++++++++ cliproxyapi/config.example.yaml | 33 +++ docker-compose.yml | 118 +++++++++ install.ps1 | 429 +++++++++++++++++++++++++++++++ litellm/config.yaml | 56 ++++ litellm/litellm-service.xml | 45 ++++ open_webui/openwebui-service.xml | 56 ++++ uninstall.ps1 | 153 +++++++++++ update.ps1 | 224 ++++++++++++++++ 11 files changed, 1524 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cliproxyapi/config.example.yaml create mode 100644 docker-compose.yml create mode 100644 install.ps1 create mode 100644 litellm/config.yaml create mode 100644 litellm/litellm-service.xml create mode 100644 open_webui/openwebui-service.xml create mode 100644 uninstall.ps1 create mode 100644 update.ps1 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..71d00ed --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +CLIPROXY_API_KEY=replace-with-the-key-from-cliproxyapi-config +GEMINI_API_KEY=replace-with-your-gemini-api-key +JUPYTER_TOKEN=replace-with-a-strong-random-token + +WEBUI_AUTH=true +ENABLE_LOGIN_FORM=true +ENABLE_SIGNUP=false +ENABLE_OAUTH_SIGNUP=true +DEFAULT_USER_ROLE=user + +GOOGLE_CLIENT_ID=replace-with-your-google-client-id +GOOGLE_CLIENT_SECRET=replace-with-your-google-client-secret +GOOGLE_REDIRECT_URI=http://localhost:3000/oauth/google/callback +GOOGLE_OAUTH_SCOPE=openid email profile +OAUTH_ALLOWED_DOMAINS=example.com \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c39fade --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +.env +cliproxyapi/config.yaml +cliproxyapi/auths/ +cliproxyapi/logs/ +litellm/gen-lang-client-*.json \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..aea3a7a --- /dev/null +++ b/README.md @@ -0,0 +1,389 @@ +# AI Stack + +这个项目用 Docker Compose 组合了三个服务: + +- LiteLLM:作为统一的模型代理层,对外提供 OpenAI 兼容接口 +- Open WebUI:作为 Web 端聊天界面,直接连接 LiteLLM +- Jupyter:作为 Open WebUI Code Interpreter 的 Python 执行环境,用于生成 Excel 等文件 + +当前仓库的核心目标,是基于 Gemini 系列模型快速搭一套可本地运行的 AI 网关与聊天界面。 + +## 目录结构 + +- docker-compose.yml:服务编排文件 +- litellm/config.yaml:LiteLLM 模型映射与回退策略配置 +- .env:本地环境变量文件,至少需要配置 Gemini API Key +- app/:预留目录,当前未使用 +- open_webui/:Open WebUI 的 Windows 服务配置目录 +- install.ps1:Windows 服务安装/卸载/启停脚本(WinSW) +- litellm/litellm-service.xml:LiteLLM 的 WinSW 服务配置 +- open_webui/openwebui-service.xml:Open WebUI 的 WinSW 服务配置 + +## 服务说明 + +### 1. LiteLLM + +LiteLLM 容器暴露 4000 端口,对外提供 OpenAI 兼容接口。 + +当前配置特点: + +- 通过环境变量 GEMINI_API_KEY 访问 Gemini 模型 +- 读取 litellm/config.yaml 中定义的模型别名 +- 为部分高阶模型配置了 fallback,当主模型不可用时自动降级到可用模型 +- 容器内默认带有 HTTP_PROXY、HTTPS_PROXY、ALL_PROXY 配置,适合需要代理访问外网模型接口的环境 + +### 2. Open WebUI + +Open WebUI 容器映射到本机 3000 端口。 + +当前配置特点: + +- 通过 OPENAI_API_BASE_URL 指向 LiteLLM 的 OpenAI 兼容接口 +- OPENAI_API_KEY 使用占位值 dummy,仅用于满足 Open WebUI 接口配置要求 +- WEBUI_AUTH=false,默认关闭登录认证,适合内网或本地开发环境 +- 使用 Docker Volume 持久化 WebUI 数据 +- 已连接 Jupyter Code Interpreter,可让模型运行 Python 生成 .xlsx 等文件 + +### 3. Jupyter + +Jupyter 只在 Docker 内网中提供给 Open WebUI 使用,不暴露到宿主机端口。 + +当前用途: + +- 为 Open WebUI 的 Code Interpreter 提供 Python 执行环境 +- 支持通过 pandas/openpyxl 等库生成 Excel 文件 +- 使用 Docker Volume 持久化工作目录 + +在 Open WebUI 中导出 Excel 时,可以这样提问: + +```text +请使用 Code Interpreter 生成一个 Excel 文件,包含以下字段:姓名、部门、金额,并提供下载链接。 +``` + +## 已配置模型 + +LiteLLM 当前已配置以下 Gemini 模型别名: + +- gemini-3-pro-preview +- gemini-3-flash-preview +- gemini-3.1-pro-preview +- gemini-3.1-flash-lite-preview +- gemini-2.5-pro +- gemini-2.5-flash +- gemini-2.5-flash-lite +- gemini-2.0-flash +- gemini-2.0-flash-001 +- gemini-2.0-flash-lite +- gemini-flash-latest +- gemini-flash-lite-latest +- gemini-pro-latest + +其中部分模型配置了自动回退策略: + +- gemini-pro-latest 回退到 gemini-2.5-flash、gemini-flash-latest +- gemini-3.1-pro-preview 回退到 gemini-2.5-flash、gemini-flash-latest +- gemini-3-pro-preview 回退到 gemini-2.5-flash、gemini-flash-latest + +## 运行前准备 + +需要具备以下环境: + +- Docker +- Docker Compose +- 可用的 Gemini API Key + +建议在 .env 中配置环境变量: + +```env +GEMINI_API_KEY=你的实际密钥 +``` + +如果当前网络环境访问 Gemini 需要代理,可以保留 docker-compose.yml 中的代理配置;如果不需要,应按实际环境修改或删除对应的代理地址。 + +## 启动方式 + +在项目根目录执行: + +```bash +docker compose up -d +``` + +查看运行状态: + +```bash +docker compose ps +``` + +查看日志: + +```bash +docker compose logs -f +``` + +停止服务: + +```bash +docker compose down +``` + +## Windows 下使用 Python 直接运行 + +如果不想使用 Docker,也可以在 Windows 下通过 Python 分别启动 LiteLLM 和 Open WebUI。 + +建议环境: + +- Windows 10 或 11 +- Python 3.11 +- PowerShell + +### 1. 创建虚拟环境并安装依赖 + +在项目根目录打开 PowerShell,执行: + +```powershell +py -3.11 -m venv .venv +.\.venv\Scripts\Activate.ps1 +python -m pip install --upgrade pip +pip install "litellm[proxy]" open-webui +``` + +如果 PowerShell 默认禁止脚本执行,可以先在当前会话临时放开: + +```powershell +Set-ExecutionPolicy -Scope Process Bypass +``` + +### 2. 启动 LiteLLM + +在第一个 PowerShell 窗口中执行: + +```powershell +.\.venv\Scripts\Activate.ps1 +$env:GEMINI_API_KEY="你的实际密钥" + +# 如果访问 Gemini 需要代理,可以按实际环境设置 +$env:HTTP_PROXY="http://127.0.0.1:7890" +$env:HTTPS_PROXY="http://127.0.0.1:7890" +$env:ALL_PROXY="http://127.0.0.1:7890" + +litellm --config .\litellm\config.yaml --port 4000 +``` + +说明: + +- litellm/config.yaml 可以直接复用当前仓库里的模型配置 +- 如果本机不需要代理,可以删除上面的代理环境变量 +- 启动成功后,LiteLLM 默认监听 + +### 3. 启动 Open WebUI + +在第二个 PowerShell 窗口中执行: + +```powershell +.\.venv\Scripts\Activate.ps1 +$env:OPENAI_API_BASE_URL="http://127.0.0.1:4000/v1" +$env:OPENAI_API_KEY="dummy" +$env:WEBUI_AUTH="false" +$env:DATA_DIR="$PWD\.data\open-webui" +$env:PORT="3000" + +open-webui serve +``` + +说明: + +- OPENAI_API_BASE_URL 指向本地启动的 LiteLLM +- OPENAI_API_KEY 保持 dummy 即可,主要用于兼容 OpenAI 风格配置 +- DATA_DIR 用于持久化 Open WebUI 本地数据 +- PORT 设置为 3000,这样和 Docker 方式下的访问地址保持一致 + +### 4. 访问服务 + +启动完成后可通过以下地址访问: + +- Open WebUI: +- LiteLLM: +- LiteLLM OpenAI 兼容接口: + +### 5. 停止服务 + +分别在两个 PowerShell 窗口中按 Ctrl+C 即可停止。 + +### 6. 常见问题 + +- 如果 open-webui 命令不存在,先确认虚拟环境已激活,并重新执行依赖安装 +- 如果 LiteLLM 启动失败,优先检查 GEMINI_API_KEY 和 config.yaml 路径是否正确 +- 如果 Open WebUI 页面打开但没有模型,优先检查 LiteLLM 是否已在 4000 端口正常启动 + +## Windows 下使用 WinSW 注册为系统服务 + +适用于需要长期稳定运行的场景,解决命令窗口关掉就停止、异常不自动恢复的问题。 + +WinSW 把 LiteLLM 和 Open WebUI 注册为 Windows Service,具备以下能力: + +- 开机自动启动 +- 进程异常退出后自动重启 +- 无需保持终端窗口 +- 日志自动滚动,不占满磁盘 +- 可以在服务管理器(services.msc)里统一查看和管理 + +### 1. 准备工作 + +确认以下条件已满足: + +- Python 虚拟环境已经创建,litellm 和 open-webui 已安装 +- Python 虚拟环境目录建议为项目根目录下的 `venv`(可按需调整) +- 用管理员权限打开 PowerShell + +### 2. 下载 WinSW + +从官方 GitHub 下载最新版: + + + +下载 `WinSW-x64.exe`,重命名为 `WinSW.exe`,放到项目根目录(与 `install.ps1` 同级)。 + +### 3. 修改配置文件 + +按实际情况修改这两个文件: + +- `litellm/litellm-service.xml`:修改 GEMINI_API_KEY +- `open_webui/openwebui-service.xml`:按需调整端口和数据目录 + +关键字段说明: + +- ``:默认使用 `%BASE%` 相对路径(例如 `%BASE%\\..\\venv\\Scripts\\litellm.exe`),复制到任意目录仍可运行 +- ``:填写实际密钥 +- ``:日志输出目录,目录不存在时安装脚本会自动创建 + +说明:`%BASE%` 是 WinSW 内置变量,表示当前 WinSW.exe 所在目录。 + +### 4. 安装服务 + +在管理员 PowerShell 中,切换到项目根目录执行: + +```powershell +cd 你的项目目录 +Set-ExecutionPolicy -Scope Process Bypass +.\install.ps1 +``` + +安装成功后会自动启动两个服务,并输出访问地址。 + +### 5. 日常管理 + +```powershell +# 查看服务状态 +.\install.ps1 -Status + +# 停止服务 +.\install.ps1 -Stop + +# 启动服务 +.\install.ps1 -Start + +# 卸载服务 +.\install.ps1 -Uninstall +``` + +也可以通过 Windows 服务管理器(`Win+R` 输入 `services.msc`)可视化管理, +服务名称分别为 `AI Stack - LiteLLM` 和 `AI Stack - Open WebUI`。 + +### 6. 日志位置 + +- LiteLLM 日志:`项目根目录\\logs\\litellm\\` +- Open WebUI 日志:`项目根目录\\logs\\openwebui\\` + +日志文件为滚动模式,单文件超过 10MB 自动轮转,最多保留 5 个历史文件。 + +### 7. 修改配置后更新服务 + +修改 XML 文件后无需卸载重装,执行 refresh 即可: + +```powershell +.\litellm\WinSW.exe refresh .\litellm\litellm-service.xml +.\open_webui\WinSW.exe refresh .\open_webui\openwebui-service.xml +``` + +## 访问入口 + +- Open WebUI: +- LiteLLM API: +- LiteLLM OpenAI 兼容接口前缀: + +## 使用方式 + +### 在 Open WebUI 中使用 + +启动后直接访问 Open WebUI 页面即可,通过 Web 界面选择 LiteLLM 暴露出来的模型进行对话。 + +### 通过 OpenAI 兼容接口调用 + +可以把 LiteLLM 当作 OpenAI 兼容网关使用,只需要把 Base URL 指向本机的 /v1 接口。 + +示例: + +```bash +curl http://localhost:4000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer dummy" \ + -d '{ + "model": "gemini-2.5-flash", + "messages": [ + {"role": "user", "content": "你好"} + ] + }' +``` + +说明:LiteLLM 实际调用 Gemini 时使用的是服务端环境变量中的 GEMINI_API_KEY;示例中的 Bearer dummy 只是为了满足 OpenAI 风格客户端的请求格式。 + +## 配置说明 + +### 模型配置 + +LiteLLM 的模型配置位于 litellm/config.yaml,可以按以下方式扩展: + +- 新增或删除模型别名 +- 调整别名对应的真实模型 +- 配置 fallback 策略 + +### 服务配置 + +服务端口与环境变量位于 docker-compose.yml,可以根据部署环境调整: + +- 端口映射 +- 代理配置 +- WebUI 认证开关 +- 挂载目录和数据卷 + +## 注意事项 + +- 当前 WEBUI_AUTH=false,默认无登录认证,不建议直接暴露到公网 +- .env 中包含真实密钥时,不应提交到公开仓库 +- 如果代理地址不可用,LiteLLM 可能无法正常访问 Gemini 接口 +- app/ 和 open_webui/ 目录当前未承载实际代码,主要运行逻辑集中在 docker-compose.yml 与 litellm/config.yaml + +## 故障排查 + +### Open WebUI 无法加载模型 + +优先检查: + +- LiteLLM 容器是否正常启动 +- OPENAI_API_BASE_URL 是否仍然指向 +- GEMINI_API_KEY 是否配置正确 + +### 模型调用失败 + +优先检查: + +- 当前 API Key 是否有效 +- 代理是否可用 +- LiteLLM 日志中是否有上游模型接口错误 + +可使用以下命令查看日志: + +```bash +docker compose logs -f litellm +docker compose logs -f open-webui +``` diff --git a/cliproxyapi/config.example.yaml b/cliproxyapi/config.example.yaml new file mode 100644 index 0000000..d1fa388 --- /dev/null +++ b/cliproxyapi/config.example.yaml @@ -0,0 +1,33 @@ +host: '' +port: 8317 + +remote-management: + allow-remote: false + secret-key: 'replace-with-a-strong-secret' + disable-control-panel: false + +auth-dir: '/root/.cli-proxy-api' + +api-keys: + - 'replace-with-an-api-key' + +debug: false +commercial-mode: false +logging-to-file: false +usage-statistics-enabled: false +proxy-url: 'http://host.docker.internal:6152' +request-retry: 3 +ws-auth: false +disable-image-generation: 'chat' + +routing: + strategy: 'round-robin' + session-affinity: true + session-affinity-ttl: '1h' + +oauth-model-alias: + codex: + - name: 'gpt-5.5' + alias: 'GPT 5.5' + fork: true + force-mapping: true \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8777bcb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,118 @@ +name: ai_chat + +services: + cli-proxy-api: + image: eceasy/cli-proxy-api:latest + container_name: cli-proxy-api + pull_policy: always + ports: + - '127.0.0.1:8317:8317' + - '127.0.0.1:8085:8085' + - '127.0.0.1:1455:1455' + - '127.0.0.1:54545:54545' + - '127.0.0.1:51121:51121' + - '127.0.0.1:11451:11451' + environment: + - HTTP_PROXY=http://192.168.1.142:6152 + - HTTPS_PROXY=http://192.168.1.142:6152 + - ALL_PROXY=http://192.168.1.142:6152 + - NO_PROXY=localhost,127.0.0.1,litellm,open-webui,cli-proxy-api + - no_proxy=localhost,127.0.0.1,litellm,open-webui,cli-proxy-api + volumes: + - ./cliproxyapi/config.yaml:/CLIProxyAPI/config.yaml:ro + - ./cliproxyapi/auths:/root/.cli-proxy-api + - ./cliproxyapi/logs:/CLIProxyAPI/logs + restart: unless-stopped + + litellm: + image: docker.litellm.ai/berriai/litellm:main-stable + container_name: litellm + ports: + - '4000:4000' + environment: + - GEMINI_API_KEY=${GEMINI_API_KEY} + - HTTP_PROXY=http://192.168.1.142:6152 + - HTTPS_PROXY=http://192.168.1.142:6152 + - ALL_PROXY=http://192.168.1.142:6152 + - NO_PROXY=localhost,127.0.0.1,litellm,open-webui,cli-proxy-api + - no_proxy=localhost,127.0.0.1,litellm,open-webui,cli-proxy-api + - GOOGLE_APPLICATION_CREDENTIALS=/app/vertex_key.json + - CLIPROXY_API_KEY=${CLIPROXY_API_KEY} + volumes: + - ./litellm:/app/litellm:ro + - ./litellm/gen-lang-client-0934968879-03bf3a88d4dd.json:/app/vertex_key.json + env_file: + - .env + depends_on: + - cli-proxy-api + command: > + --config /app/litellm/config.yaml + --port 4000 + restart: unless-stopped + + jupyter: + image: quay.io/jupyter/datascience-notebook:latest + container_name: ai-chat-jupyter + environment: + - JUPYTER_TOKEN=${JUPYTER_TOKEN} + command: > + start-notebook.py + --ServerApp.token=${JUPYTER_TOKEN} + --ServerApp.password= + --ServerApp.allow_origin=* + volumes: + - jupyter-work:/home/jovyan/work + restart: unless-stopped + + open-webui: + image: ghcr.io/open-webui/open-webui:main + container_name: open-webui + pull_policy: always + ports: + - '3000:8080' + env_file: + - .env + environment: + - OPENAI_API_BASE_URLS=http://litellm:4000/v1;http://cli-proxy-api:8317/v1 + - OPENAI_API_KEYS=dummy;${CLIPROXY_API_KEY} + - WEBUI_NAME=AI Chat + - WEBUI_AUTH=true + - ENABLE_SIGNUP=true + - ENABLE_LOGIN_FORM=true + - ENABLE_OAUTH_SIGNUP=true + - DEFAULT_USER_ROLE=pending + - OAUTH_ALLOWED_DOMAINS=${OAUTH_ALLOWED_DOMAINS} + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} + - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} + - GOOGLE_REDIRECT_URI=${GOOGLE_REDIRECT_URI} + - GOOGLE_OAUTH_SCOPE=${GOOGLE_OAUTH_SCOPE} + - ENABLE_IMAGE_GENERATION=true + - ENABLE_IMAGE_PROMPT_GENERATION=true + - IMAGE_GENERATION_ENGINE=openai + - IMAGE_GENERATION_MODEL=imagen-4.0-generate-001-vertex + - IMAGE_SIZE=1024x1024 + - IMAGES_OPENAI_API_BASE_URL=http://litellm:4000/v1 + - IMAGES_OPENAI_API_KEY=dummy + - ENABLE_CODE_INTERPRETER=true + - CODE_INTERPRETER_ENGINE=jupyter + - CODE_INTERPRETER_JUPYTER_URL=http://jupyter:8888 + - CODE_INTERPRETER_JUPYTER_AUTH=token + - CODE_INTERPRETER_JUPYTER_AUTH_TOKEN=${JUPYTER_TOKEN} + - CODE_INTERPRETER_JUPYTER_TIMEOUT=120 + - ENABLE_CODE_EXECUTION=true + - CODE_EXECUTION_ENGINE=jupyter + - CODE_EXECUTION_JUPYTER_URL=http://jupyter:8888 + - CODE_EXECUTION_JUPYTER_AUTH=token + - CODE_EXECUTION_JUPYTER_AUTH_TOKEN=${JUPYTER_TOKEN} + - CODE_EXECUTION_JUPYTER_TIMEOUT=120 + volumes: + - open-webui:/app/backend/data + depends_on: + - litellm + - cli-proxy-api + - jupyter + restart: unless-stopped + +volumes: + open-webui: + jupyter-work: diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..427569e --- /dev/null +++ b/install.ps1 @@ -0,0 +1,429 @@ +# 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 +} diff --git a/litellm/config.yaml b/litellm/config.yaml new file mode 100644 index 0000000..6a07a07 --- /dev/null +++ b/litellm/config.yaml @@ -0,0 +1,56 @@ +model_list: + - model_name: gemini-flash-latest + litellm_params: + model: vertex_ai/gemini-flash-latest + api_key: os.environ/GEMINI_API_KEY + + - model_name: gemini-flash-lite-latest + litellm_params: + model: vertex_ai/gemini-flash-lite-latest + api_key: os.environ/GEMINI_API_KEY + + - model_name: gemini-pro-latest + litellm_params: + model: vertex_ai/gemini-pro-latest + api_key: os.environ/GEMINI_API_KEY + + - model_name: chatgpt-pro + litellm_params: + model: openai/chatgpt-pro + api_base: http://cli-proxy-api:8317/v1 + api_key: os.environ/CLIPROXY_API_KEY + + # # 供 Open WebUI 的图片生成功能调用。不要把这些模型当普通聊天模型直接在对话里选择, + # # 否则走 /chat/completions 流式返回大块 base64 时,前端可能报 Chunk too big。 + # - model_name: gemini-3-pro-image-preview + # litellm_params: + # model: gemini/gemini-3-pro-image-preview + # api_key: os.environ/GEMINI_API_KEY + +litellm_settings: + drop_params: true + +router_settings: + num_retries: 3 + timeout: 60 + fallbacks: + - { + 'gemini-flash-lite-latest': + ['gemini-flash-latest', 'gemini-pro-latest'], + } + - { 'gemini-flash-latest': ['gemini-pro-latest'] } + +# +# - model_name: gemini-flash-enterprise +# litellm_params: +# model: vertex_ai/gemini-2.5-flash +# vertex_project: 'gen-lang-client-0934968879' +# vertex_location: 'asia-southeast1' +# # vertex_credentials: '/app/litellm/gen-lang-client-0934968879-03bf3a88d4dd.json' # 容器内路径 + +# - model_name: gemini-pro-enterprise +# litellm_params: +# model: vertex_ai/gemini-2.5-pro +# vertex_project: 'gen-lang-client-0934968879' +# vertex_location: 'asia-southeast1' +# vertex_credentials: '/app/litellm/gen-lang-client-0934968879-03bf3a88d4dd.json' # 容器内路径 diff --git a/litellm/litellm-service.xml b/litellm/litellm-service.xml new file mode 100644 index 0000000..bdde412 --- /dev/null +++ b/litellm/litellm-service.xml @@ -0,0 +1,45 @@ + + ai-stack-litellm + AI Stack - LiteLLM + LiteLLM proxy for Gemini models, provides OpenAI-compatible API on port 4000 + + + %BASE%\venv\Scripts\litellm.exe + --config %BASE%\config.yaml --port 4000 + %BASE%\.. + + + + + + + + + + + %BASE%\..\logs\litellm + + 10240 + 5 + + + + Automatic + true + + + + 1 hour + + + 15 sec + + + true + diff --git a/open_webui/openwebui-service.xml b/open_webui/openwebui-service.xml new file mode 100644 index 0000000..35e6dd1 --- /dev/null +++ b/open_webui/openwebui-service.xml @@ -0,0 +1,56 @@ + + ai-stack-openwebui + AI Stack - Open WebUI + Open WebUI chat interface connected to LiteLLM, runs on port 3000 + + + %BASE%\venv\Scripts\open-webui.exe + serve --port 3000 + %BASE%\.. + + + + + + + + + + + + + + + + + + + + + %BASE%\..\logs\openwebui + + 10240 + 5 + + + + Automatic + true + + + + 1 hour + + + 15 sec + + + true + + + ai-stack-litellm + diff --git a/uninstall.ps1 b/uninstall.ps1 new file mode 100644 index 0000000..2b24809 --- /dev/null +++ b/uninstall.ps1 @@ -0,0 +1,153 @@ +# AI Stack Windows service uninstaller +# Run in elevated PowerShell: +# powershell -NoProfile -ExecutionPolicy Bypass -File .\uninstall.ps1 + +param( + [switch]$RemoveVenv, + [switch]$RemoveData +) + +$ErrorActionPreference = "Stop" + +$BASE_DIR = $PSScriptRoot +$LITELLM_XML = Join-Path $BASE_DIR "litellm\litellm-service.xml" +$WEBUI_XML = Join-Path $BASE_DIR "open_webui\openwebui-service.xml" +$LITELLM_VENV = Join-Path $BASE_DIR "litellm\venv" +$WEBUI_VENV = Join-Path $BASE_DIR "open_webui\venv" +$DATA_DIR = Join-Path $BASE_DIR ".data\open-webui" + +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 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 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 Uninstall-All { + 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" + + # Stop services in reverse order (Open WebUI depends on LiteLLM) + Step "Stop Open WebUI service" + $webuiSvc = Get-Service -Name "ai-stack-openwebui" -ErrorAction SilentlyContinue + if ($webuiSvc -and $webuiSvc.Status -eq "Running") { + Stop-Service ai-stack-openwebui -Force + Start-Sleep -Seconds 2 + } + + Step "Stop LiteLLM service" + $litellmSvc = Get-Service -Name "ai-stack-litellm" -ErrorAction SilentlyContinue + if ($litellmSvc -and $litellmSvc.Status -eq "Running") { + Stop-Service ai-stack-litellm -Force + Start-Sleep -Seconds 2 + } + + # Uninstall services + if (Test-Path $webuiWinSW) { + Invoke-External -File $webuiWinSW -Arguments @("uninstall", $WEBUI_XML) -ActionLabel "Uninstall Open WebUI service" + Start-Sleep -Seconds 1 + } + + if (Test-Path $litellmWinSW) { + Invoke-External -File $litellmWinSW -Arguments @("uninstall", $LITELLM_XML) -ActionLabel "Uninstall LiteLLM service" + Start-Sleep -Seconds 1 + } + + # Optional: remove venv + if ($RemoveVenv) { + Step "Remove virtual environments" + if (Test-Path $LITELLM_VENV) { + Remove-Item $LITELLM_VENV -Recurse -Force + Diag "Removed: $LITELLM_VENV" + } + if (Test-Path $WEBUI_VENV) { + Remove-Item $WEBUI_VENV -Recurse -Force + Diag "Removed: $WEBUI_VENV" + } + } else { + Diag "Keeping virtual environments by default (use -RemoveVenv to delete them)" + } + + # Optional: remove data + if ($RemoveData) { + Step "Remove Open WebUI data" + if (Test-Path $DATA_DIR) { + Remove-Item $DATA_DIR -Recurse -Force + Diag "Removed: $DATA_DIR" + } + } else { + Diag "Keeping Open WebUI data by default (use -RemoveData to delete it)" + } + + # Remove WinSW copies from service folders + Step "Clean up WinSW from service folders" + Remove-Item $litellmWinSW -Force -ErrorAction SilentlyContinue + Remove-Item $webuiWinSW -Force -ErrorAction SilentlyContinue + + Write-Host "" + Write-Host "[OK] Uninstall completed" -ForegroundColor Green + Write-Host "" + Write-Host "Usage for next install:" -ForegroundColor Cyan + Write-Host " powershell -NoProfile -ExecutionPolicy Bypass -File .\install.ps1" + Write-Host "" + Write-Host "To remove venv/data too:" -ForegroundColor Cyan + Write-Host " powershell -NoProfile -ExecutionPolicy Bypass -File .\uninstall.ps1 -RemoveVenv -RemoveData" + Write-Host "" +} + +try { + Uninstall-All +} catch { + Write-Host "" + Write-Host "Uninstall failed" -ForegroundColor Red + Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red + throw +} diff --git a/update.ps1 b/update.ps1 new file mode 100644 index 0000000..08c4564 --- /dev/null +++ b/update.ps1 @@ -0,0 +1,224 @@ +# AI Stack Windows service updater +# Run in elevated PowerShell: +# powershell -NoProfile -ExecutionPolicy Bypass -File .\update.ps1 + +param( + [switch]$SkipRestart +) + +$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" +$LITELLM_VENV = Join-Path $BASE_DIR "litellm\venv" +$WEBUI_VENV = Join-Path $BASE_DIR "open_webui\venv" + +$LOG_DIR = Join-Path $BASE_DIR "logs\install" +$UPDATE_LOG = Join-Path $LOG_DIR ("update-{0}.log" -f (Get-Date -Format "yyyyMMdd-HHmmss")) + +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-LogDir { + 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 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-Prerequisites { + if (-not (Test-Path $WINSW_EXE)) { + Fail "WinSW.exe not found in project root. Download WinSW-x64.exe and rename to WinSW.exe." + } + + foreach ($path in @($LITELLM_XML, $WEBUI_XML)) { + if (-not (Test-Path $path)) { + Fail "Required file not found: $path" + } + } + + foreach ($venvPath in @($LITELLM_VENV, $WEBUI_VENV)) { + $pythonExe = Join-Path $venvPath "Scripts\python.exe" + if (-not (Test-Path $pythonExe)) { + Fail "Virtual environment not found: $venvPath. Run install.ps1 first." + } + } +} + +function Stop-ServicesIfPresent { + foreach ($svc in @("ai-stack-openwebui", "ai-stack-litellm")) { + $service = Get-Service -Name $svc -ErrorAction SilentlyContinue + if ($service -and $service.Status -eq "Running") { + Step "Stop service $svc" + Stop-Service $svc -Force + Start-Sleep -Seconds 2 + } + } +} + +function Upgrade-VenvPackages { + param( + [string]$PythonExe, + [string[]]$Packages, + [string]$Name + ) + + Invoke-External -File $PythonExe -Arguments @("-m", "pip", "install", "--upgrade", "pip", "setuptools", "wheel") -ActionLabel "Upgrade pip toolchain ($Name)" + $upgradeArgs = @("-m", "pip", "install", "--upgrade") + $Packages + Invoke-External -File $PythonExe -Arguments $upgradeArgs -ActionLabel "Upgrade packages ($Name)" +} + +function Refresh-ServiceBinaries { + $litellmWinSW = Join-Path $BASE_DIR "litellm\WinSW.exe" + $webuiWinSW = Join-Path $BASE_DIR "open_webui\WinSW.exe" + + Step "Sync WinSW.exe into service folders" + Copy-Item $WINSW_EXE $litellmWinSW -Force + Copy-Item $WINSW_EXE $webuiWinSW -Force +} + +function Start-ServicesIfNeeded { + if ($SkipRestart) { + Diag "SkipRestart is set, services remain stopped after package upgrade." + return + } + + Step "Start LiteLLM service" + Start-Service ai-stack-litellm + Start-Sleep -Seconds 3 + + Step "Start Open WebUI service" + Start-Service ai-stack-openwebui +} + +function Update-All { + Write-Host "" + Write-Host "=== Update AI Stack Windows Services ===" -ForegroundColor Cyan + Write-Host "" + Write-Host "This update preserves config files, Open WebUI data, and existing venv directories." -ForegroundColor Yellow + Write-Host "It only upgrades packages and refreshes service runtime files." -ForegroundColor Yellow + Write-Host "" + + Check-Admin + Check-Prerequisites + Ensure-GeminiApiKey + Stop-ServicesIfPresent + Refresh-ServiceBinaries + + $litellmPython = Join-Path $LITELLM_VENV "Scripts\python.exe" + $webuiPython = Join-Path $WEBUI_VENV "Scripts\python.exe" + + Upgrade-VenvPackages -PythonExe $litellmPython -Packages @("email-validator", "litellm[proxy]") -Name "LiteLLM" + Upgrade-VenvPackages -PythonExe $webuiPython -Packages @("open-webui") -Name "Open WebUI" + + Start-ServicesIfNeeded + + Write-Host "" + Write-Host "[OK] Update completed" -ForegroundColor Green + Write-Host "Configuration and data were preserved." -ForegroundColor Green + if ($SkipRestart) { + Write-Host "Services were not restarted because -SkipRestart was specified." -ForegroundColor Yellow + } else { + Write-Host "Open WebUI: http://127.0.0.1:3000" + Write-Host "LiteLLM API: http://127.0.0.1:4000" + } + Write-Host "" +} + +Ensure-LogDir +Write-Host "Update log file: $UPDATE_LOG" -ForegroundColor DarkGray +Start-Transcript -Path $UPDATE_LOG -Append | Out-Null + +try { + Update-All +} catch { + Write-Host "" + Write-Host "Update failed. Send this log file:" -ForegroundColor Red + Write-Host " $UPDATE_LOG" -ForegroundColor Red + Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red + throw +} finally { + Stop-Transcript | Out-Null +} \ No newline at end of file