diff --git a/dbsetup.js b/dbsetup.js new file mode 100755 index 00000000..deb6abef --- /dev/null +++ b/dbsetup.js @@ -0,0 +1,23 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process' + +const env = { ...process.env } + +await exec('npx next build --experimental-build-mode generate') + +// launch application +await exec(process.argv.slice(2).join(' ')) + +function exec(command) { + const child = spawn(command, { shell: true, stdio: 'inherit', env }) + return new Promise((resolve, reject) => { + child.on('exit', code => { + if (code === 0) { + resolve() + } else { + reject(new Error(`${command} failed rc=${code}`)) + } + }) + }) +} diff --git a/docs/FLY_IO_DEPLOYMENT_GUIDE.md b/docs/FLY_IO_DEPLOYMENT_GUIDE.md new file mode 100644 index 00000000..5d48c420 --- /dev/null +++ b/docs/FLY_IO_DEPLOYMENT_GUIDE.md @@ -0,0 +1,451 @@ +# OmniRoute Fly.io 部署指南 + +本文档记录 OmniRoute 在 Fly.io 上的实际部署方法,适用于两类场景: + +- 首次把当前项目部署到 Fly.io +- 后续代码更新后继续发布 +- 新项目参考同样流程部署 + +本文基于当前项目已经验证通过的配置整理,应用名为 `omniroute`。 + +--- + +## 1. 部署目标 + +- 平台:Fly.io +- 部署方式:本地 `flyctl` 直接发布 +- 运行方式:使用仓库内现有 `Dockerfile` 和 `fly.toml` +- 数据持久化:Fly Volume 挂载到 `/data` +- 访问地址:`https://omniroute.fly.dev/` + +--- + +## 2. 当前项目关键配置 + +当前仓库中的 `fly.toml` 已确认包含以下关键项: + +```toml +app = 'omniroute' +primary_region = 'sin' + +[[mounts]] + source = 'data' + destination = '/data' + +[processes] + app = 'node run-standalone.mjs' + +[http_service] + internal_port = 20128 + +[env] + TZ = "Asia/Shanghai" + HOST = "0.0.0.0" + HOSTNAME = "0.0.0.0" + BIND = "0.0.0.0" +``` + +说明: + +- `app = 'omniroute'` 决定实际部署到哪个 Fly 应用 +- `destination = '/data'` 决定持久卷挂载目录 +- 本项目必须让 `DATA_DIR=/data`,否则数据库和密钥会写到容器临时目录 + +--- + +## 3. 必备工具 + +### 3.1 安装 Fly CLI + +Windows PowerShell: + +```powershell +pwsh -Command "iwr https://fly.io/install.ps1 -useb | iex" +``` + +如果安装脚本在当前环境失败,也可以手动下载 `flyctl` 二进制并放到 `PATH` 中。 + +### 3.2 登录 Fly 账号 + +```powershell +flyctl auth login +``` + +### 3.3 检查登录状态 + +```powershell +flyctl auth whoami +flyctl version +``` + +--- + +## 4. 首次部署当前项目 + +### 4.1 获取代码并进入目录 + +```powershell +git clone https://github.com/xiaoge1688/OmniRoute.git +cd OmniRoute +``` + +### 4.2 确认应用名 + +打开 `fly.toml`,重点看这一行: + +```toml +app = 'omniroute' +``` + +如果你准备部署到自己的新应用,可改成全局唯一名称,例如: + +```toml +app = 'omniroute-yourname' +``` + +注意: + +- 控制台里要看的是与 `fly.toml` 里 `app` 一致的应用 +- 以前如果用过别的名字,例如 `oroute`,不要和 `omniroute` 混淆 + +### 4.3 创建应用 + +如果该应用尚不存在: + +```powershell +flyctl apps create omniroute +``` + +如果你已经改成别的应用名,把 `omniroute` 替换成你的名字。 + +### 4.4 首次部署 + +```powershell +flyctl deploy +``` + +--- + +## 5. 必配参数 + +本项目在 Fly.io 上建议至少配置以下参数。 + +### 5.1 已验证使用的参数 + +这些参数已经在当前 `omniroute` 应用上实际部署: + +- `API_KEY_SECRET` +- `DATA_DIR` +- `JWT_SECRET` +- `MACHINE_ID_SALT` +- `NEXT_PUBLIC_BASE_URL` +- `STORAGE_ENCRYPTION_KEY` + +### 5.2 关于 `INITIAL_PASSWORD` + +当前项目没有设置 `INITIAL_PASSWORD`,因为本次部署按需求不使用它。 + +如果不设置: + +- 启动日志会提示默认密码是 `CHANGEME` +- 部署后应尽快在系统设置中修改登录密码 + +如果你希望无人值守初始化后台密码,也可以后续补: + +- `INITIAL_PASSWORD` + +--- + +## 6. 推荐参数说明 + +### 6.1 Secrets 中设置 + +建议放入 Fly Secrets: + +| 变量名 | 是否推荐 | 说明 | +| --- | --- | --- | +| `API_KEY_SECRET` | 必需 | API Key 生成与校验使用 | +| `JWT_SECRET` | 必需 | 登录态和 JWT 签名使用 | +| `STORAGE_ENCRYPTION_KEY` | 强烈推荐 | 加密存储敏感连接信息 | +| `MACHINE_ID_SALT` | 推荐 | 生成稳定机器标识 | +| `INITIAL_PASSWORD` | 可选 | 首次部署时直接指定后台初始密码 | +| OAuth/API 私密凭证 | 按需 | 各类外部平台鉴权配置 | + +### 6.2 当前项目推荐值 + +| 变量名 | 推荐值 | +| --- | --- | +| `DATA_DIR` | `/data` | +| `NEXT_PUBLIC_BASE_URL` | `https://omniroute.fly.dev` | + +说明: + +- `DATA_DIR=/data` 非常关键,必须与 Fly Volume 挂载点一致 +- `NEXT_PUBLIC_BASE_URL` 用于调度器和前端回调等场景 + +--- + +## 7. 一键设置参数 + +下面命令会生成安全随机值,并把当前项目需要的参数一次性写入 Fly Secrets。 + +说明: + +- 不包含 `INITIAL_PASSWORD` +- 适用于当前项目 `omniroute` + +```powershell +$apiKeySecret = [Convert]::ToHexString((1..32 | ForEach-Object { Get-Random -Minimum 0 -Maximum 256 })).ToLower() +$jwtSecret = [Convert]::ToHexString((1..64 | ForEach-Object { Get-Random -Minimum 0 -Maximum 256 })).ToLower() +$machineIdSalt = [Convert]::ToHexString((1..32 | ForEach-Object { Get-Random -Minimum 0 -Maximum 256 })).ToLower() +$storageKey = [Convert]::ToHexString((1..32 | ForEach-Object { Get-Random -Minimum 0 -Maximum 256 })).ToLower() + +flyctl secrets set ` + API_KEY_SECRET=$apiKeySecret ` + JWT_SECRET=$jwtSecret ` + MACHINE_ID_SALT=$machineIdSalt ` + STORAGE_ENCRYPTION_KEY=$storageKey ` + DATA_DIR=/data ` + NEXT_PUBLIC_BASE_URL=https://omniroute.fly.dev ` + -a omniroute +``` + +如果你还要加初始密码: + +```powershell +flyctl secrets set INITIAL_PASSWORD=你的强密码 -a omniroute +``` + +--- + +## 8. 查看当前参数 + +```powershell +flyctl secrets list -a omniroute +``` + +如果控制台 `Secrets` 页面没有显示你期待的变量,先检查: + +- 看的应用是不是 `omniroute` +- `fly.toml` 的 `app` 是否和控制台应用一致 + +--- + +## 9. 后续更新发布 + +代码有更新后,发布步骤很简单: + +```powershell +git pull +flyctl deploy +``` + +如果只更新参数,不改代码: + +```powershell +flyctl secrets set KEY=value -a omniroute +``` + +Fly 会自动滚动更新机器。 + +### 9.1 跟踪原仓库更新并保留 fork 的 `fly.toml` + +如果当前仓库是 fork,并且你要同步上游 `https://github.com/diegosouzapw/OmniRoute` 的更新,推荐按下面流程执行。 + +先确认远程: + +```powershell +git remote -v +``` + +应至少包含: + +- `origin` 指向你自己的 fork +- `upstream` 指向原仓库 + +如果没有 `upstream`,先添加: + +```powershell +git remote add upstream https://github.com/diegosouzapw/OmniRoute.git +``` + +同步上游前,先抓取最新提交和标签: + +```powershell +git fetch upstream --tags +``` + +查看当前版本和上游标签: + +```powershell +git describe --tags --always +git show --no-patch --oneline v3.4.7 +``` + +如果你想合并上游最新 `main`,并强制保留 fork 当前的 `fly.toml`,可按下面流程执行: + +```powershell +git merge upstream/main +git checkout HEAD~1 -- fly.toml +git add -- fly.toml +git commit -m "chore(deploy): keep fork fly.toml" +git push origin main +``` + +说明: + +- `git merge upstream/main` 用于同步原仓库最新代码 +- `git checkout HEAD~1 -- fly.toml` 用于恢复合并前你 fork 自己的 `fly.toml` +- 如果上游没有改 `fly.toml`,这一步不会带来额外差异 +- 如果上游改了 `fly.toml`,这一步能确保 Fly 应用名、挂载卷、区域等 fork 自定义部署配置不被覆盖 + +如果你明确只想对齐某个发布标签,例如 `v3.4.7`,也可以先确认标签是否已经包含在 `upstream/main`: + +```powershell +git merge-base --is-ancestor v3.4.7 upstream/main +``` + +返回成功表示 `upstream/main` 已经包含该版本,直接合并 `upstream/main` 即可。 + +### 9.2 同步上游后的标准发布顺序 + +同步原仓库完成后,推荐按下面顺序发布: + +1. `git fetch upstream --tags` +2. `git merge upstream/main` +3. 恢复 fork 的 `fly.toml` +4. `git push origin main` +5. `flyctl deploy` +6. `flyctl status -a omniroute` +7. `flyctl logs --no-tail -a omniroute` + +这就是当前项目升级到 `v3.4.7` 时使用的实际流程。 + +--- + +## 10. 发布后检查 + +### 10.1 查看应用状态 + +```powershell +flyctl status -a omniroute +``` + +### 10.2 查看启动日志 + +```powershell +flyctl logs --no-tail -a omniroute +``` + +### 10.3 检查网站可访问 + +```powershell +try { + (Invoke-WebRequest -Uri "https://omniroute.fly.dev" -MaximumRedirection 5 -UseBasicParsing).StatusCode +} catch { + if ($_.Exception.Response) { + $_.Exception.Response.StatusCode.value__ + } else { + throw + } +} +``` + +返回 `200` 说明站点已正常响应。 + +--- + +## 11. 成功标志 + +部署成功后,日志里应看到类似内容: + +```text +[bootstrap] Secrets persisted to: /data/server.env +[DB] SQLite database ready: /data/storage.sqlite +``` + +这两个点很关键: + +- `/data/server.env` 说明运行时密钥落到了持久卷 +- `/data/storage.sqlite` 说明数据库写入持久卷 + +如果你看到的是 `/app/data/...`,说明 `DATA_DIR` 没配对,需要立即修正。 + +--- + +## 12. 常见问题 + +### 12.1 `Secrets` 页面是空的 + +通常有两种原因: + +- 你还没执行 `flyctl secrets set` +- 你打开的是另一个应用,例如 `oroute`,不是 `omniroute` + +### 12.2 `flyctl deploy` 报 `app not found` + +先创建应用: + +```powershell +flyctl apps create omniroute +``` + +### 12.3 `fly.toml` 解析失败 + +重点检查: + +- 注释里是否有乱码字符 +- TOML 引号和缩进是否正确 + +### 12.4 数据没有持久化 + +检查以下两点: + +- `fly.toml` 中是否存在 `destination = '/data'` +- `DATA_DIR` 是否设置为 `/data` + +### 12.5 不设置 `INITIAL_PASSWORD` 是否能跑 + +可以运行,但会回退到默认 `CHANGEME`。生产环境建议尽快修改后台密码。 + +--- + +## 13. 新项目复用建议 + +如果以后是新项目照着这份文档部署,最少改这几项: + +1. 修改 `fly.toml` 里的 `app` +2. 修改 `NEXT_PUBLIC_BASE_URL` +3. 保持 `DATA_DIR=/data` +4. 重新生成 `API_KEY_SECRET`、`JWT_SECRET`、`MACHINE_ID_SALT`、`STORAGE_ENCRYPTION_KEY` +5. 首次部署后检查日志是否写入 `/data` + +不要直接复用旧项目的密钥。 + +--- + +## 14. 当前项目的最小发布清单 + +当前项目后续最常用的命令如下: + +```powershell +flyctl auth whoami +flyctl status -a omniroute +flyctl secrets list -a omniroute +flyctl deploy +flyctl logs --no-tail -a omniroute +``` + +如果只是正常发版,核心就是: + +```powershell +flyctl deploy +``` + +如果是新环境首次部署,核心就是: + +1. `flyctl auth login` +2. `flyctl apps create omniroute` +3. `flyctl secrets set ... -a omniroute` +4. `flyctl deploy` +5. `flyctl logs --no-tail -a omniroute` diff --git a/fly.toml b/fly.toml new file mode 100644 index 00000000..e1dea968 --- /dev/null +++ b/fly.toml @@ -0,0 +1,44 @@ +# fly.toml app configuration file generated for oroute on 2026-03-20T09:12:34Z +# +# See https://fly.io/docs/reference/configuration/ for information about how to use this file. +# + +app = 'omniroute' +primary_region = 'sin' + +[build] + +[processes] + app = 'node run-standalone.mjs' + +[deploy] +# release_command = "node ./dbsetup.js" + +[[mounts]] + source = 'data' + destination = '/data' + auto_extend_size_threshold = 80 + auto_extend_size_increment = '1GB' + auto_extend_size_limit = '10GB' + +[http_service] + internal_port = 20128 + force_https = true + auto_stop_machines = 'stop' + auto_start_machines = true + min_machines_running = 1 + processes = ['app'] + +[[vm]] + memory = '1gb' + cpu_kind = 'shared' + cpus = 1 + memory_mb = 1024 + +[env] + TZ = "Asia/Shanghai" + + # Bind to all interfaces for Fly runtime networking. + HOST = "0.0.0.0" + HOSTNAME = "0.0.0.0" + BIND = "0.0.0.0" diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index 91ad9495..ec0f40d3 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -105,12 +105,13 @@ export function parseModel(modelStr) { extendedContext = true; cleanStr = cleanStr.slice(0, -4); } + cleanStr = cleanStr.trim(); // Check if standard format: provider/model or alias/model if (cleanStr.includes("/")) { const firstSlash = cleanStr.indexOf("/"); - const providerOrAlias = cleanStr.slice(0, firstSlash); - const model = cleanStr.slice(firstSlash + 1); + const providerOrAlias = cleanStr.slice(0, firstSlash).trim(); + const model = cleanStr.slice(firstSlash + 1).trim(); const provider = resolveProviderAlias(providerOrAlias); return { provider, model, isAlias: false, providerAlias: providerOrAlias, extendedContext }; } diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 8f280732..06d15f29 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -23,6 +23,7 @@ import { } from "@omniroute/open-sse/config/providerRegistry.ts"; import { COOLDOWN_MS } from "@omniroute/open-sse/config/constants.ts"; import { getCodexModelScope } from "@omniroute/open-sse/executors/codex.ts"; +import { getProviderAlias, resolveProviderId } from "@/shared/constants/providers"; import * as log from "../utils/logger"; import { fisherYatesShuffle, getNextFromDeckSync } from "@/shared/utils/shuffleDeck"; @@ -316,13 +317,17 @@ export { fisherYatesShuffle, getNextFromDeckSync as getNextFromDeck }; * Resolve provider aliases (e.g., nvidia -> nvidia_nim) for DB lookup */ function getProviderSearchPool(provider: string): string[] { + const canonicalProvider = resolveProviderId(provider); + const canonicalAlias = getProviderAlias(canonicalProvider); + if (provider === "nvidia") { return ["nvidia", "nvidia_nim"]; } if (provider === "nvidia_nim") { return ["nvidia_nim", "nvidia"]; } - return [provider]; + + return Array.from(new Set([provider, canonicalProvider, canonicalAlias].filter(Boolean))); } /** diff --git a/tests/unit/auth-clear-account-error.test.mjs b/tests/unit/auth-clear-account-error.test.mjs index 7f957a1c..4a9b45a6 100644 --- a/tests/unit/auth-clear-account-error.test.mjs +++ b/tests/unit/auth-clear-account-error.test.mjs @@ -56,3 +56,19 @@ test("clearAccountError clears stale provider error metadata after recovery", as assert.equal(updated.rateLimitedUntil, undefined); assert.equal(updated.backoffLevel, 0); }); + +test("getProviderCredentials resolves provider aliases to canonical DB records", async () => { + await resetStorage(); + + const created = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "alias@example.com", + accessToken: "access", + refreshToken: "refresh", + testStatus: "active", + }); + + const credentials = await auth.getProviderCredentials("cx"); + assert.equal(credentials.connectionId, created.id); +}); diff --git a/tests/unit/model-parse.test.mjs b/tests/unit/model-parse.test.mjs index 39d539bf..e85e91f5 100644 --- a/tests/unit/model-parse.test.mjs +++ b/tests/unit/model-parse.test.mjs @@ -20,3 +20,10 @@ test("[1m] suffix: works with provider prefix", () => { assert.strictEqual(result.model, "claude-sonnet-4-6"); assert.strictEqual(result.extendedContext, true); }); + +test("parseModel trims provider prefix and model id", () => { + const result = parseModel(" cx / gpt-5.4 "); + assert.strictEqual(result.providerAlias, "cx"); + assert.strictEqual(result.provider, "codex"); + assert.strictEqual(result.model, "gpt-5.4"); +});