Merge pull request #497 from zhangqiang8vip/feat/zws-v5

fix(perf): resolve dev-mode HMR resource leaks, Edge warnings, and Windows test stability
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-03-20 16:13:27 -03:00
committed by GitHub
22 changed files with 1168 additions and 338 deletions

374
ZWS_README_V4.md Normal file
View File

@@ -0,0 +1,374 @@
# ZWS_README_V4 — 启动性能优化HMR 泄漏修复与 Turbopack 迁移
## 一、如何发现问题
### 现象
- `npm run dev` 后,首次打开浏览器白屏等待 **5-22 秒**不等。
- 运行一段时间后 Node 进程内存飙升至 **2.4 GB**,触发 Next.js 内存阈值保护强制重启。
- 重启后 `Ready in 82.6s`(正常冷启动仅 3.4s),之后每个页面首次编译需 **7-28 秒**
- 日志中大量重复输出,单次会话内:
- `[DB] SQLite database ready` 出现 **485 次**
- `[HealthCheck] Starting proactive token health-check` 出现 **586 次**
- `[CREDENTIALS] No external credentials file found` 出现 **432 次**
### 排查过程
1. **Terminal 日志分析**:统计关键日志出现次数,发现 DB 连接和 HealthCheck 定时器被反复创建。
2. **代码审计**:追踪到所有受影响模块使用 `let initialized = false` 作为单例守卫——这在 Next.js dev 模式的 Webpack HMR 下会被重置。
3. **对比**`apiBridgeServer.ts` 使用了 `globalThis.__omnirouteApiBridgeStarted`,在日志中无重复初始化,验证了 `globalThis` 方案的有效性。
4. **内存快照**:通过 `Get-Process node` 观察到两个 node 进程分别占用 1.7GB 和 1.0GB。
5. **编译时间分析**:日志中 `compile:` 字段显示 Webpack 编译每个路由需 2-26 秒,对比 Turbopack 应在 0.5-3 秒。
---
## 二、根因分析
### 根因 1P0模块级单例在 HMR 中丢失
Next.js dev 模式下Webpack HMR 会重新执行被修改(或依赖链变化)的模块。模块级 `let` 变量在每次重新执行时被重置为初始值。
```typescript
// 修复前 — 每次 HMR 重新执行时 _db 重置为 null
let _db: SqliteDatabase | null = null;
export function getDbInstance() {
if (_db) return _db; // HMR 后这里永远 false
// ... 重新打开一个新的 DB 连接(旧连接泄漏)
}
```
**受影响的模块与泄漏类型:**
| 模块 | 泄漏资源 | 累计次数 | 后果 |
| ----------------------- | ---------------------- | -------- | ----------------------- |
| `db/core.ts` | SQLite 连接 | 485 | 文件句柄泄漏 + 内存占用 |
| `tokenHealthCheck.ts` | `setInterval` 定时器 | 586 | CPU 空转 + DB 查询风暴 |
| `localHealthCheck.ts` | `setTimeout` 定时器链 | ~400 | 重复 HTTP 请求 + CPU |
| `consoleInterceptor.ts` | console 方法包装 | ~400 | 日志 double-write |
| `gracefulShutdown.ts` | SIGTERM/SIGINT handler | ~400 | 信号处理器堆叠 |
**级联效应**:泄漏的资源持续消耗内存和 CPU → 触发 Next.js 内存阈值保护 → 进程重启 → Webpack 从零重建模块图 → **Ready in 82.6s**
### 根因 2P0强制使用 Webpack 而非 Turbopack
`scripts/run-next.mjs` 中硬编码了 `--webpack` 标志:
```javascript
if (mode === "dev") {
args.splice(2, 0, "--webpack");
}
```
Next.js 16 默认使用 TurbopackRust 编写的增量打包器dev 编译速度是 Webpack 的 5-10 倍。强制回退到 Webpack 导致:
| 指标 | Webpack | Turbopack预期 |
| ----------------------- | ------- | ----------------- |
| 首页编译 | 3.7s | ~0.5s |
| Provider 详情页首次编译 | 22s | ~2-3s |
| API route 首次编译 | 2-7s | ~0.3-1s |
| 内存重启后 Ready | 82.6s | 不会触发 |
### 根因 3P1`node:crypto` 被拉入客户端 bundle
`src/lib/db/proxies.ts` 使用了 `import { randomUUID } from "node:crypto"`。通过 `localDb.ts` 的 re-export 链,这个 Node.js 原生模块被间接拉入客户端组件的 bundle导致 Webpack 报错:
```
UnhandledSchemeError: Reading from "node:crypto" is not handled by plugins
Import trace: node:crypto → ./src/lib/db/proxies.ts → ./src/lib/localDb.ts → page.tsx
```
Webpack 无法处理 `node:` URI scheme 前缀。`crypto`(不带 `node:` 前缀)已在 `next.config.mjs``serverExternalPackages` 中声明为服务端外部包。
### 根因 4P1Edge Runtime 编译警告刷屏
Next.js 16 会同时为 **Node.js****Edge** 两种运行时编译 `instrumentation.ts`。虽然 `register()` 函数内有 `process.env.NEXT_RUNTIME === "nodejs"` 的运行时守卫,但 Turbopack 在打包 Edge 版本时仍会**静态追踪**所有动态 `import()` 的依赖链:
```
instrumentation.ts
→ import("@/lib/db/secrets")
→ @/lib/db/core.ts → fs, path, better-sqlite3
→ @/lib/dataPaths.ts → path, os
→ @/lib/db/migrationRunner.ts → fs, path, url
```
对每个 Node.js 原生模块Turbopack 都输出一条 "not supported in Edge Runtime" 警告。每次有新请求触发热编译时,这组 **10+ 条警告重复刷一遍**,严重污染终端输出,干扰开发调试。
### 根因 5P2启动 import 完全串行
`instrumentation.ts` 中 9 个 `await import()` 完全串行执行,每个都可能触发 Webpack 编译其依赖树:
```typescript
await ensureSecrets(); // 串行 1
const { initConsoleInterceptor } = await import(...); // 串行 2
const { initGracefulShutdown } = await import(...); // 串行 3
const { initApiBridgeServer } = await import(...); // 串行 4
const { startBackgroundRefresh } = await import(...); // 串行 5
const { getSettings } = await import(...); // 串行 6
const { setCustomAliases } = await import(...); // 串行 7
const { setDefaultFastServiceTierEnabled } = await import(...); // 串行 8
const { initAuditLog, cleanupExpiredLogs } = await import(...); // 串行 9
```
其中 4-6 互不依赖7-8 互不依赖,完全可以并行。
---
## 三、修复方案
### 修复 1globalThis 单例守卫core.ts, tokenHealthCheck.ts, localHealthCheck.ts, consoleInterceptor.ts, gracefulShutdown.ts
**原理**`globalThis` 对象在 Node.js 进程生命周期内全局唯一,不受 Webpack 模块重新执行的影响。
```typescript
// 修复后 — globalThis 在 HMR 后依然保留
declare global {
var __omnirouteDb: import("better-sqlite3").Database | undefined;
}
function getDb() {
return globalThis.__omnirouteDb ?? null;
}
function setDb(db) {
/* ... */
}
export function getDbInstance() {
const existing = getDb();
if (existing) return existing; // HMR 后命中缓存
// ...
}
```
**每个模块的具体改动:**
| 模块 | globalThis key | 守卫内容 |
| ----------------------- | ----------------------------------- | ----------------------------------------------------------- |
| `db/core.ts` | `__omnirouteDb` | SQLite 连接实例 |
| `tokenHealthCheck.ts` | `__omnirouteTokenHC` | `{ initialized, interval }` |
| `localHealthCheck.ts` | `__omnirouteLocalHC` | `{ initialized, sweepTimer, healthCache, sweepInProgress }` |
| `consoleInterceptor.ts` | `__omnirouteConsoleInterceptorInit` | `boolean` |
| `gracefulShutdown.ts` | `__omnirouteShutdownInit` | `boolean` |
**优点**
- 零依赖,无需额外库。
-`apiBridgeServer.ts` 已有模式一致。
- 对生产环境零影响(非 HMR 场景下行为完全相同)。
**缺点/注意**
- `globalThis` 键名需全局唯一,使用 `__omniroute` 前缀避免冲突。
- 需要 `declare global` 类型声明以保持 TypeScript 类型安全。
- 生产构建中 `globalThis` 存储略冗余(但仅是一个对象引用,几乎零开销)。
### 修复 2支持通过环境变量切换 Turbopackrun-next.mjs
```javascript
// 修复后 — 默认仍用 webpack保持原有行为设置环境变量可启用 Turbopack
if (mode === "dev" && process.env.OMNIROUTE_USE_TURBOPACK !== "1") {
args.splice(2, 0, "--webpack");
}
```
**默认行为不变**dev 模式仍使用 Webpack与修复前完全一致。设置 `OMNIROUTE_USE_TURBOPACK=1` 可切换到 Turbopack 以获得更快的 dev 编译速度。
**优点**
- 零风险:不改变任何人的现有体验。
- 需要时设置 `OMNIROUTE_USE_TURBOPACK=1` 即可获得 5-10 倍编译加速。
- `next.config.mjs` 中已有 `turbopack.resolveAlias` 配置,说明项目已在准备 Turbopack 迁移。
**缺点/注意**
- Turbopack 对某些 Webpack 特定配置(如自定义 externals 函数)的支持方式不同,启用前需测试兼容性。
- 默认走 Webpack 意味着不主动启用 Turbopack 的用户无法享受编译加速。
### 修复 3`node:crypto` → `crypto`proxies.ts, errorResponse.ts
```typescript
// 修复前
import { randomUUID } from "node:crypto";
// 修复后
import { randomUUID } from "crypto";
```
**优点**
- `crypto`(无 `node:` 前缀)已在 `next.config.mjs``serverExternalPackages` 列表中Webpack/Turbopack 会正确将其标记为外部包。
- 消除 `UnhandledSchemeError` 构建失败。
- Node.js 中 `crypto``node:crypto` 解析到同一模块。
**缺点**
- 无。`crypto` 是 Node.js 内建模块,两种写法功能完全等价。
### 修复 4分离 Edge/Node.js Instrumentationinstrumentation.ts → instrumentation-node.ts
**问题**`instrumentation.ts` 中所有 Node.js 逻辑(`ensureSecrets`、DB 初始化、审计日志等)虽然只在 `NEXT_RUNTIME === "nodejs"` 时执行,但 Turbopack 编译 Edge 版本时仍静态追踪其 import 链,对每个 `fs`/`path`/`os`/`better-sqlite3` 等原生模块输出警告。
**方案**:将所有 Node.js 专属逻辑提取到 `src/instrumentation-node.ts`,主文件通过**计算的 import 路径**引入,阻止 Turbopack 静态解析:
```typescript
// src/instrumentation.ts — 精简后仅 ~20 行
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
// 拼接路径阻止 Turbopack 在 Edge 编译时静态解析模块依赖
const nodeMod = "./instrumentation-" + "node";
const { registerNodejs } = await import(nodeMod);
await registerNodejs();
}
}
```
```typescript
// src/instrumentation-node.ts — 包含全部 Node.js 启动逻辑
export async function registerNodejs(): Promise<void> {
await ensureSecrets();
// initConsoleInterceptor, initGracefulShutdown, initApiBridgeServer, ...
// (原 instrumentation.ts 的完整 Node.js 逻辑)
}
```
**关键技术**`"./instrumentation-" + "node"` 是运行时拼接的字符串Turbopack 无法在编译期确定其值,因此**不会追踪**该 import 的依赖树。Node.js 运行时则正常解析该路径并执行。
**优点**
- Edge 编译时完全跳过 Node.js 模块追踪,**10+ 条重复警告全部消除**。
- Node.js 运行时行为与修复前完全一致。
- 启动时间从 **13.9s → 1.25s**Turbopack 不再在 Edge 编译中处理 Node.js 模块图)。
**缺点/注意**
- 新增一个文件 `instrumentation-node.ts`,需同步维护。
- 计算 import 路径是有意为之的 bundler 逃逸技巧,需加注释说明原因防止后续重构时被"优化"回静态字符串。
### 修复 5并行化 instrumentation.ts 中的启动 import
```typescript
// 修复后 — 4 个独立模块并行导入
const [
{ initGracefulShutdown },
{ initApiBridgeServer },
{ startBackgroundRefresh },
{ getSettings },
] = await Promise.all([
import("@/lib/gracefulShutdown"),
import("@/lib/apiBridgeServer"),
import("@/domain/quotaCache"),
import("@/lib/db/settings"),
]);
// 2 个 open-sse 模块也并行导入
const [{ setCustomAliases }, { setDefaultFastServiceTierEnabled }] = await Promise.all([
import("@omniroute/open-sse/services/modelDeprecation.ts"),
import("@omniroute/open-sse/executors/codex.ts"),
]);
```
**优点**
- `consoleInterceptor` 仍保持第一个(必须在任何日志前初始化)。
- 后续 4 个无依赖模块并行加载,节省 3 次串行等待。
- open-sse 的 2 个模块也并行加载。
**缺点**
- 并行 import 的错误堆栈略复杂Promise.all 中某一个失败会 reject 整个组)。
- 这里的 compliance 模块仍保持独立 try/catch 串行,因为它有自己的错误处理逻辑。
---
## 四、预期效果
| 指标 | 修复前 | 修复后(预期) |
| ----------------------------- | ------------------------- | ------------------------ |
| DB 连接创建次数 | 485 次/会话 | 1 次 |
| HealthCheck 定时器 | 586 个泄漏 | 1 个 |
| 信号处理器注册 | ~400 次重复 | 1 次 |
| Console 拦截层数 | ~400 层嵌套 | 1 层 |
| 内存使用峰值 | 2.4 GB → OOM 重启 | 预期 < 500 MB |
| 冷启动 Ready | 3.4s | ~3s略快 |
| 内存重启 Ready | 82.6s | 不再触发内存重启 |
| Login 页首次编译 | 3.7s | ~0.5s (需启用 Turbopack) |
| Provider 详情页首次编译 | 22s | ~2-3s (需启用 Turbopack) |
| `node:crypto` 构建错误 | 反复出现 | 消除 |
| Edge Runtime 编译警告 | 每次热编译刷出 10+ 条 | **0 条** |
| instrumentation 启动耗时 | 13.9s(含 Edge 模块追踪) | **1.25s** |
| instrumentation import 并行度 | 9 次串行 import | 3 批并行 import |
---
## 五、涉及文件清单
| 区域 | 文件 | 改动类型 |
| ------------------- | ------------------------------- | ------------------------------------------------------------------ |
| DB 单例 | `src/lib/db/core.ts` | `let _db``globalThis.__omnirouteDb` |
| Token 健康检查 | `src/lib/tokenHealthCheck.ts` | `let initialized``globalThis.__omnirouteTokenHC` |
| 本地节点健康检查 | `src/lib/localHealthCheck.ts` | `let initialized``globalThis.__omnirouteLocalHC` |
| Console 拦截 | `src/lib/consoleInterceptor.ts` | `let initialized``globalThis.__omnirouteConsoleInterceptorInit` |
| 优雅关停 | `src/lib/gracefulShutdown.ts` | 新增 `globalThis.__omnirouteShutdownInit` 守卫 |
| Dev 启动脚本 | `scripts/run-next.mjs` | 新增 `OMNIROUTE_USE_TURBOPACK=1` 开关 |
| Proxy 注册表 | `src/lib/db/proxies.ts` | `node:crypto``crypto` |
| API 错误响应 | `src/lib/api/errorResponse.ts` | `node:crypto``crypto` |
| 启动钩子(主入口) | `src/instrumentation.ts` | 精简为 ~20 行,计算 import 路径阻止 Edge 追踪 |
| 启动钩子Node.js | `src/instrumentation-node.ts` | 新文件,承载全部 Node.js 启动逻辑 + `Promise.all` 并行 |
---
## 六、回退方案
- **启用 Turbopack**:设置 `OMNIROUTE_USE_TURBOPACK=1` 环境变量;不设置则默认使用 Webpack原有行为不变
- **globalThis 方案异常**:所有 globalThis key 都以 `__omniroute` 为前缀,可通过 `delete globalThis.__omnirouteDb` 等方式手动重置。
- **Edge 警告回退**:若 `instrumentation-node.ts` 拆分导致问题,可将其内容合并回 `instrumentation.ts`,恢复为直接 `import()` 调用(警告会重新出现但不影响功能)。
- **生产环境**:以上修复对生产构建无负面影响——生产环境不存在 HMRglobalThis 单例仅在首次调用时初始化一次。计算 import 路径在 `next build` 时由 Node.js 正常解析,不影响打包产物。
---
## 七、单元测试与备份恢复pre-commit 验证通过)
为保证提交前必须通过验证(不再使用 `--no-verify`),对以下失败用例与生产逻辑做了修复与加固。
### 问题与根因
| 失败项 | 根因 |
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| bootstrap-env 4 个用例 | Windows 上 DATA_DIR 解析用 `APPDATA`/`homedir()`,测试只设了 `HOME`,脚本读不到测试用的 `.env`。 |
| domain-persistence costRules 2 个用例 | `core` 在首次 import 时缓存 `DATA_DIR`;测试每测一个 tmpDir 并在 afterEach 删目录,导致后续 describe 使用的 DB 路径已被删,读写得到 0。 |
| fixes-p1 restoreDbBackup | 测试在 DB 仍打开时写 stale 侧文件;`restoreDbBackup` 内 pre-restore 备份未 await 就关库Windows 上句柄未及时释放unlink 报 EBUSY。 |
| fixes-p1 resetStorage 及后续用例 | 上一测留下 DB 打开,下一测 `resetStorage()` 删目录时文件仍被占用EBUSY。 |
### 修复 6bootstrap-env 测试tests/unit/bootstrap-env.test.mjs
在每个用例的 `withTempEnv` 回调开头增加 `process.env.DATA_DIR = dataDir`,使脚本在任意平台(含 Windows都使用测试临时目录而不是依赖 `HOME`/`APPDATA`
### 修复 7domain-persistence 测试tests/unit/domain-persistence.test.mjs
- **单例 tmpDir**:全文件共用一个 `fileTmpDir`,在模块加载时创建并设置 `process.env.DATA_DIR`,与 `core` 首次加载时缓存的路径一致。
- **每测清 DB 不清目录**`beforeEach``resetDbInstance()` 后删除 `storage.sqlite` 及其 `-wal`/`-shm`/`-journal`,保证每测干净 DB不在 afterEach 删目录,避免路径失效。
- **收尾**`after()` 中恢复 `DATA_DIR` 并删除 `fileTmpDir`
- **costRules 断言**:改为小容差精确校验(`assertAlmostEqual`),继续验证 `4.5` / `4.0` 这类业务关键值,避免把真实累计错误放过去。
### 修复 8fixes-p1 测试tests/unit/fixes-p1.test.mjs
- **restoreDbBackup 用例**:在写入 stale 侧文件前调用 `core.resetDbInstance()`,避免 DB 仍打开时写 `-wal`/`-shm` 触发 Windows 锁错误。
- **Windows 跳过**:该用例在 Windows 上仍使用 `test(..., { skip: isWindows })`。原因不是业务逻辑不支持 Windows而是 better-sqlite3 关闭后底层句柄释放存在时序抖动,这条真实 sidecar 集成测试容易退化成不稳定的文件锁测试Linux/macOS 上照常运行。
- **核心兜底测试**:新增平台无关的 `unlinkFileWithRetry` 单测,直接模拟 `EBUSY` / `EPERM` 后重试并最终成功,确保 Windows 相关的重试删除逻辑被稳定覆盖,而不是完全依赖 flaky 的真实文件锁时序。
- **resetStorage**:改为 async`rmSync(TEST_DATA_DIR)` 做最多 10 次、间隔 100ms 的 EBUSY/EPERM 重试,避免下一测因上一测句柄未释放而失败。
### 修复 9备份恢复逻辑src/lib/db/backup.ts
- **pre-restore 备份改为同步等待**:在 `restoreDbBackup` 内用内联逻辑做 pre-restore 备份并 `await` 完成,再调用 `resetDbInstance()`,避免异步 backup 未结束就关库导致后续 unlink 失败。
- **节流语义保持一致**pre-restore 备份成功后补回 `_lastBackupAt = Date.now()`,避免恢复后紧接着又触发一轮额外自动备份。
- **关库后短延迟**`resetDbInstance()``await new Promise(r => setTimeout(r, 500))`,再执行 unlink给 Windows 等平台释放句柄留时间。
- **unlink 重试**:将主库及 `-wal`/`-shm`/`-journal` 的删除提取为 `unlinkFileWithRetry`,统一做最多 10 次、间隔 100ms 的 EBUSY/EPERM 重试,提高恢复流程在锁释放较慢环境下的成功率,也便于单测直接覆盖重试逻辑。
### 涉及文件(本节)
| 区域 | 文件 | 改动类型 |
| -------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| 单元测试 | `tests/unit/bootstrap-env.test.mjs` | 各用例内设置 `process.env.DATA_DIR = dataDir` |
| 单元测试 | `tests/unit/domain-persistence.test.mjs` | 单例 tmpDir、beforeEach 清 DB 文件、after 删目录costRules 改为小容差精确断言 |
| 单元测试 | `tests/unit/fixes-p1.test.mjs` | restoreDbBackup 前 resetDbInstance、Windows skip 说明、resetStorage 重试、`unlinkFileWithRetry` 核心单测 |
| 备份恢复 | `src/lib/db/backup.ts` | pre-restore 内联并 await、恢复 `_lastBackupAt` 节流语义、关库后 500ms 延迟、抽取 `unlinkFileWithRetry` 重试删除 |

View File

@@ -80,9 +80,17 @@ function supportsSystemRole(provider: string, model: string): boolean {
* OpenAI Responses API sends `developer`; MiniMax and most OpenAI-compatible gateways
* only accept system/user/assistant/tool and return "role param error" otherwise.
*
* Logic:
* - When targetFormat !== "openai": always convert developer → system (Claude, Gemini, etc.).
* - When targetFormat === "openai": convert only when preserveDeveloperRole === false.
* This covers OpenAI-compatible providers (MiniMax, etc.) that use targetFormat "openai"
* but do not accept the developer role; the per-model preserveDeveloperRole flag is set
* via the dashboard "Compatibility" toggle ("Do not preserve developer role").
* - When targetFormat === "openai" && preserveDeveloperRole !== false: keep developer (e.g. official OpenAI).
*
* @param messages - Array of messages
* @param targetFormat - The target format (e.g., "openai", "claude", "gemini")
* @param preserveDeveloperRole - For targetFormat openai: undefined/true = keep developer (legacy default); false = map to system (MiniMax etc.)
* @param preserveDeveloperRole - For targetFormat openai: undefined/true = keep developer (legacy default); false = map to system (MiniMax and other OpenAI-compatible gateways that reject developer)
*/
export function normalizeDeveloperRole(
messages: NormalizedMessage[] | unknown,
@@ -170,8 +178,14 @@ export function normalizeSystemRole(
/**
* Full role normalization pipeline.
* Call this before sending the request to the provider.
* Applies developer→system (when needed) then system→user for providers/models that do not support system role.
*
* @param preserveDeveloperRole - See {@link normalizeDeveloperRole}
* @param messages - Array of messages to normalize (or non-array, returned as-is)
* @param provider - Provider id for capability lookup (e.g. system role support)
* @param model - Model id for capability lookup
* @param targetFormat - Target request format (e.g. "openai", "claude", "gemini"); see {@link normalizeDeveloperRole}
* @param preserveDeveloperRole - Optional; see {@link normalizeDeveloperRole}. When false, developer role is mapped to system.
* @returns Normalized messages array, or the original value if messages is not an array
*/
export function normalizeRoles(
messages: NormalizedMessage[] | unknown,

View File

@@ -96,7 +96,9 @@ export function translateRequest(
// Fix missing tool responses (insert empty tool_result if needed)
fixMissingToolResponses(result);
// Normalize roles: developer→system unless preserved, system→user for incompatible models
// Normalize roles: developer→system unless preserved, system→user for incompatible models.
// This handles (1) sourceFormat openai with messages containing developer → non-openai target
// or preserveDeveloperRole=false, and (2) all other paths where result.messages already exists.
if (result.messages && Array.isArray(result.messages)) {
result.messages = normalizeRoles(
result.messages,
@@ -151,8 +153,10 @@ export function translateRequest(
result = normalizeOpenAIResponsesRequest(result);
}
// After OPENAI_RESPONSES → OPENAI, messages are built from input; first normalizeRoles was a no-op.
// Run role pipeline again so developer→system respects preserveDeveloperRole (no hardcoding in translator).
// Second role normalization: only for OPENAI_RESPONSES. Here messages are built from input
// after the translation step, so the first normalizeRoles (above) did not see them. For
// sourceFormat openai with messages already on the body, the first block handles developer
// → system (non-openai target or preserveDeveloperRole=false); no second pass needed.
if (
sourceFormat === FORMATS.OPENAI_RESPONSES &&
result.messages &&

View File

@@ -16,7 +16,8 @@ const { dashboardPort } = runtimePorts;
const env = bootstrapEnv();
const args = ["./node_modules/next/dist/bin/next", mode, "--port", String(dashboardPort)];
if (mode === "dev") {
// Default: use webpack (stable). Set OMNIROUTE_USE_TURBOPACK=1 to use Turbopack (faster dev).
if (mode === "dev" && process.env.OMNIROUTE_USE_TURBOPACK !== "1") {
args.splice(2, 0, "--webpack");
}

View File

@@ -32,6 +32,193 @@ import {
import { getModelsByProviderId } from "@/shared/constants/models";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
interface ModelRowProps {
model: { id: string };
fullModel: string;
alias?: string;
copied?: string;
onCopy: (text: string, key: string) => void;
t: (key: string, values?: Record<string, unknown>) => string;
showDeveloperToggle?: boolean;
normalizeToolCallId?: boolean;
preserveDeveloperRole?: boolean;
onNormalizeChange?: (v: boolean) => void;
onPreserveChange?: (v: boolean) => void;
compatDisabled?: boolean;
}
interface PassthroughModelRowProps {
modelId: string;
fullModel: string;
copied?: string;
onCopy: (text: string, key: string) => void;
onDeleteAlias: () => void;
t: (key: string, values?: Record<string, unknown>) => string;
showDeveloperToggle?: boolean;
normalizeToolCallId?: boolean;
preserveDeveloperRole?: boolean;
onNormalizeChange?: (v: boolean) => void;
onPreserveChange?: (v: boolean) => void;
compatDisabled?: boolean;
}
interface PassthroughModelsSectionProps {
providerAlias: string;
modelAliases: Record<string, string>;
copied?: string;
onCopy: (text: string, key: string) => void;
onSetAlias: (modelId: string, alias: string) => Promise<void>;
onDeleteAlias: (alias: string) => void;
t: (key: string, values?: Record<string, unknown>) => string;
effectiveModelNormalize: (alias: string) => boolean;
effectiveModelPreserveDeveloper: (alias: string) => boolean;
saveModelCompatFlags: (
modelId: string,
flags: {
normalizeToolCallId?: boolean;
preserveDeveloperRole?: boolean;
preserveOpenAIDeveloperRole?: boolean;
}
) => Promise<void>;
compatSavingModelId?: string;
}
interface CustomModelsSectionProps {
providerId: string;
providerAlias: string;
copied?: string;
onCopy: (text: string, key: string) => void;
onModelsChanged?: () => void;
}
interface CompatibleModelsSectionProps {
providerStorageAlias: string;
providerDisplayAlias: string;
modelAliases: Record<string, string>;
copied?: string;
onCopy: (text: string, key: string) => void;
onSetAlias: (modelId: string, alias: string, providerStorageAlias?: string) => Promise<void>;
onDeleteAlias: (alias: string) => void;
connections: { id?: string; isActive?: boolean }[];
isAnthropic?: boolean;
onImportWithProgress: (
fetchModels: () => Promise<{ models: unknown[] }>,
processModel: (model: unknown) => Promise<boolean>
) => Promise<void>;
t: (key: string, values?: Record<string, unknown>) => string;
effectiveModelNormalize: (alias: string) => boolean;
effectiveModelPreserveDeveloper: (alias: string) => boolean;
saveModelCompatFlags: (
modelId: string,
flags: {
normalizeToolCallId?: boolean;
preserveDeveloperRole?: boolean;
preserveOpenAIDeveloperRole?: boolean;
}
) => Promise<void>;
compatSavingModelId?: string;
onModelsChanged?: () => void;
}
interface CooldownTimerProps {
until: string | number | Date;
}
interface ConnectionRowConnection {
id?: string;
name?: string;
email?: string;
displayName?: string;
rateLimitedUntil?: string;
rateLimitProtection?: boolean;
testStatus?: string;
isActive?: boolean;
priority?: number;
lastError?: string;
lastErrorType?: string;
lastErrorSource?: string;
errorCode?: string | number;
globalPriority?: number;
providerSpecificData?: Record<string, unknown>;
expiresAt?: string;
}
interface ConnectionRowProps {
connection: ConnectionRowConnection;
isOAuth: boolean;
isCodex?: boolean;
isFirst: boolean;
isLast: boolean;
onMoveUp: () => void;
onMoveDown: () => void;
onToggleActive: (isActive?: boolean) => void | Promise<void>;
onToggleRateLimit: (enabled?: boolean) => void;
onToggleCodex5h?: (enabled?: boolean) => void;
onToggleCodexWeekly?: (enabled?: boolean) => void;
onRetest: () => void;
isRetesting?: boolean;
onEdit: () => void;
onDelete: () => void;
onReauth?: () => void;
onProxy?: () => void;
hasProxy?: boolean;
proxySource?: string;
proxyHost?: string;
onRefreshToken?: () => void;
isRefreshing?: boolean;
}
interface AddApiKeyModalProps {
isOpen: boolean;
provider?: string;
providerName?: string;
isCompatible?: boolean;
isAnthropic?: boolean;
onSave: (data: {
name: string;
apiKey: string;
priority: number;
baseUrl?: string;
}) => Promise<void | unknown>;
onClose: () => void;
}
interface EditConnectionModalConnection {
id?: string;
name?: string;
email?: string;
priority?: number;
authType?: string;
provider?: string;
providerSpecificData?: Record<string, unknown>;
healthCheckInterval?: number;
}
interface EditConnectionModalProps {
isOpen: boolean;
connection: EditConnectionModalConnection | null;
onSave: (data: unknown) => Promise<void | unknown>;
onClose: () => void;
}
interface EditCompatibleNodeModalNode {
id?: string;
name?: string;
prefix?: string;
apiType?: string;
baseUrl?: string;
chatPath?: string;
modelsPath?: string;
}
interface EditCompatibleNodeModalProps {
isOpen: boolean;
node: EditCompatibleNodeModalNode | null;
onSave: (data: unknown) => Promise<void>;
onClose: () => void;
isAnthropic?: boolean;
}
function normalizeCodexLimitPolicy(policy: unknown): { use5h: boolean; useWeekly: boolean } {
const record =
policy && typeof policy === "object" && !Array.isArray(policy)
@@ -62,11 +249,17 @@ function ModelCompatPopover({
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
// Click-outside: check both trigger and panel so that if the panel is ever rendered
// in a portal (outside this subtree), clicks inside the panel still do not close it.
useEffect(() => {
if (!open) return;
const onDocClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
const target = e.target as Node;
const insideTrigger = ref.current?.contains(target);
const insidePanel = panelRef.current?.contains(target);
if (!insideTrigger && !insidePanel) setOpen(false);
};
document.addEventListener("mousedown", onDocClick);
return () => document.removeEventListener("mousedown", onDocClick);
@@ -85,7 +278,10 @@ function ModelCompatPopover({
{t("compatButtonLabel")}
</button>
{open && (
<div className="absolute left-0 top-full mt-1 z-50 min-w-[200px] p-3 rounded-lg border border-border bg-white dark:bg-zinc-900 shadow-xl ring-1 ring-black/5 dark:ring-white/10">
<div
ref={panelRef}
className="absolute left-0 top-full mt-1 z-50 min-w-[200px] p-3 rounded-lg border border-border bg-white dark:bg-zinc-900 shadow-xl ring-1 ring-black/5 dark:ring-white/10"
>
<p className="text-[10px] font-semibold uppercase tracking-wide text-text-muted mb-2">
{t("compatAdjustmentsTitle")}
</p>
@@ -99,14 +295,17 @@ function ModelCompatPopover({
disabled={disabled}
/>
{showDeveloperToggle && (
<Toggle
size="sm"
label={t("compatDoNotPreserveDeveloper")}
title={t("preserveDeveloperRoleLabel")}
checked={preserveDeveloperRole === false}
onChange={(checked) => onPreserveChange(!checked)}
disabled={disabled}
/>
<>
{/* Inversion: Toggle checked = "do not preserve" (UI). onPreserveChange(val) = value to store (true = preserve, false = do not preserve), so we pass !checked. */}
<Toggle
size="sm"
label={t("compatDoNotPreserveDeveloper")}
title={t("preserveDeveloperRoleLabel")}
checked={preserveDeveloperRole === false}
onChange={(checked) => onPreserveChange(!checked)}
disabled={disabled}
/>
</>
)}
</div>
</div>
@@ -850,36 +1049,7 @@ export default function ProviderDetailPage() {
) => {
setCompatSavingModelId(modelId);
try {
const c = modelMeta.customModels.find((m: { id?: string }) => m.id === modelId) as Record<
string,
unknown
> | null;
let body: Record<string, unknown>;
if (c) {
body = {
provider: providerId,
modelId,
modelName: (c.name as string) || modelId,
source: (c.source as string) || "manual",
apiFormat: (c.apiFormat as string) || "chat-completions",
supportedEndpoints:
Array.isArray(c.supportedEndpoints) && (c.supportedEndpoints as unknown[]).length
? c.supportedEndpoints
: ["chat"],
normalizeToolCallId:
patch.normalizeToolCallId !== undefined
? patch.normalizeToolCallId
: Boolean(c.normalizeToolCallId),
preserveOpenAIDeveloperRole:
patch.preserveOpenAIDeveloperRole !== undefined
? patch.preserveOpenAIDeveloperRole
: Object.prototype.hasOwnProperty.call(c, "preserveOpenAIDeveloperRole")
? Boolean(c.preserveOpenAIDeveloperRole)
: true,
};
} else {
body = { provider: providerId, modelId, ...patch };
}
const body: Record<string, unknown> = { provider: providerId, modelId, ...patch };
const res = await fetch("/api/provider-models", {
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -1648,7 +1818,7 @@ function ModelRow({
onNormalizeChange,
onPreserveChange,
compatDisabled,
}: any) {
}: ModelRowProps) {
return (
<div className="flex flex-col px-3 py-2 rounded-lg border border-border hover:bg-sidebar/50 min-w-[220px] max-w-md">
<div className="flex items-center gap-2 flex-wrap">
@@ -1710,7 +1880,7 @@ function PassthroughModelsSection({
effectiveModelPreserveDeveloper,
saveModelCompatFlags,
compatSavingModelId,
}) {
}: PassthroughModelsSectionProps) {
const [newModel, setNewModel] = useState("");
const [adding, setAdding] = useState(false);
@@ -1832,7 +2002,7 @@ function PassthroughModelRow({
onNormalizeChange,
onPreserveChange,
compatDisabled,
}: any) {
}: PassthroughModelRowProps) {
return (
<div className="flex flex-col gap-0 p-3 rounded-lg border border-border hover:bg-sidebar/50">
<div className="flex items-start gap-3">
@@ -1896,7 +2066,13 @@ PassthroughModelRow.propTypes = {
// ============ Custom Models Section (for ALL providers) ============
function CustomModelsSection({ providerId, providerAlias, copied, onCopy, onModelsChanged }) {
function CustomModelsSection({
providerId,
providerAlias,
copied,
onCopy,
onModelsChanged,
}: CustomModelsSectionProps) {
const t = useTranslations("providers");
const notify = useNotificationStore();
const [customModels, setCustomModels] = useState([]);
@@ -2332,7 +2508,7 @@ function CompatibleModelsSection({
saveModelCompatFlags,
compatSavingModelId,
onModelsChanged,
}) {
}: CompatibleModelsSectionProps) {
const [newModel, setNewModel] = useState("");
const [adding, setAdding] = useState(false);
const [importing, setImporting] = useState(false);
@@ -2579,7 +2755,7 @@ CompatibleModelsSection.propTypes = {
onModelsChanged: PropTypes.func,
};
function CooldownTimer({ until }) {
function CooldownTimer({ until }: CooldownTimerProps) {
const [remaining, setRemaining] = useState("");
useEffect(() => {
@@ -2792,7 +2968,7 @@ function ConnectionRow({
proxyHost,
onRefreshToken,
isRefreshing,
}) {
}: ConnectionRowProps) {
const t = useTranslations("providers");
const displayName = isOAuth
? connection.name || connection.email || connection.displayName || t("oauthAccount")
@@ -3117,7 +3293,7 @@ function AddApiKeyModal({
isAnthropic,
onSave,
onClose,
}) {
}: AddApiKeyModalProps) {
const t = useTranslations("providers");
const isBailian = provider === "bailian-coding-plan";
const defaultBailianUrl = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1";
@@ -3325,7 +3501,7 @@ function normalizeAndValidateHttpBaseUrl(rawValue, fallbackUrl) {
}
}
function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnectionModalProps) {
const t = useTranslations("providers");
const [formData, setFormData] = useState({
name: "",
@@ -3348,7 +3524,8 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
useEffect(() => {
if (connection) {
const existingBaseUrl = connection.providerSpecificData?.baseUrl;
const rawBaseUrl = connection.providerSpecificData?.baseUrl;
const existingBaseUrl = typeof rawBaseUrl === "string" ? rawBaseUrl : "";
setFormData({
name: connection.name || "",
priority: connection.priority || 1,
@@ -3470,7 +3647,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
updates.providerSpecificData.baseUrl = validatedBailianBaseUrl;
}
}
const error = await onSave(updates);
const error = (await onSave(updates)) as void | unknown;
if (error) {
setSaveError(typeof error === "string" ? error : t("failedSaveConnection"));
}
@@ -3680,7 +3857,13 @@ EditConnectionModal.propTypes = {
onClose: PropTypes.func.isRequired,
};
function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic }) {
function EditCompatibleNodeModal({
isOpen,
node,
onSave,
onClose,
isAnthropic,
}: EditCompatibleNodeModalProps) {
const t = useTranslations("providers");
const [formData, setFormData] = useState({
name: "",

View File

@@ -7,6 +7,11 @@ import {
getModelCompatOverrides,
mergeModelCompatOverride,
} from "@/lib/localDb";
import {
AI_PROVIDERS,
isOpenAICompatibleProvider,
isAnthropicCompatibleProvider,
} from "@/shared/constants/providers";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { providerModelMutationSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
@@ -32,9 +37,9 @@ export async function GET(request) {
const modelCompatOverrides = provider ? getModelCompatOverrides(provider) : [];
return Response.json({ models, modelCompatOverrides });
} catch (error) {
} catch {
return Response.json(
{ error: { message: error.message, type: "server_error" } },
{ error: { message: "Failed to fetch provider models", type: "server_error" } },
{ status: 500 }
);
}
@@ -146,20 +151,36 @@ export async function PUT(request) {
) &&
("normalizeToolCallId" in raw || "preserveOpenAIDeveloperRole" in raw);
if (compatOnly) {
const knownProvider =
!!provider &&
(Object.prototype.hasOwnProperty.call(
AI_PROVIDERS as Record<string, unknown>,
provider
) ||
isOpenAICompatibleProvider(provider) ||
isAnthropicCompatibleProvider(provider));
if (!knownProvider) {
return Response.json(
{ error: { message: "Unknown provider", type: "validation_error" } },
{ status: 400 }
);
}
const patch: {
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean;
preserveOpenAIDeveloperRole?: boolean | null;
} = {};
if ("normalizeToolCallId" in raw && typeof normalizeToolCallId === "boolean") {
patch.normalizeToolCallId = normalizeToolCallId;
}
if (
"preserveOpenAIDeveloperRole" in raw &&
typeof preserveOpenAIDeveloperRole === "boolean"
) {
patch.preserveOpenAIDeveloperRole = preserveOpenAIDeveloperRole;
if ("preserveOpenAIDeveloperRole" in raw) {
patch.preserveOpenAIDeveloperRole =
preserveOpenAIDeveloperRole === null || typeof preserveOpenAIDeveloperRole === "boolean"
? preserveOpenAIDeveloperRole
: undefined;
}
if (Object.keys(patch).length > 0) {
mergeModelCompatOverride(provider, modelId, patch);
}
mergeModelCompatOverride(provider, modelId, patch);
return Response.json({
ok: true,
modelCompatOverrides: getModelCompatOverrides(provider),

137
src/instrumentation-node.ts Normal file
View File

@@ -0,0 +1,137 @@
/**
* Node.js-only instrumentation logic.
*
* Separated from instrumentation.ts so that Turbopack's Edge bundler
* does not trace into Node.js-only modules (fs, path, os, better-sqlite3, etc.)
* and emit spurious "not supported in Edge Runtime" warnings.
*/
function getRandomBytes(byteLength: number): Uint8Array {
const bytes = new Uint8Array(byteLength);
globalThis.crypto.getRandomValues(bytes);
return bytes;
}
function toBase64(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes));
}
function toHex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
async function ensureSecrets(): Promise<void> {
let getPersistedSecret = (_key: string): string | null => null;
let persistSecret = (_key: string, _value: string): void => {};
try {
({ getPersistedSecret, persistSecret } = await import("@/lib/db/secrets"));
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(
"[STARTUP] Secret persistence unavailable; falling back to process-local secrets:",
msg
);
}
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.trim() === "") {
const persisted = getPersistedSecret("jwtSecret");
if (persisted) {
process.env.JWT_SECRET = persisted;
console.log("[STARTUP] JWT_SECRET restored from persistent store");
} else {
const generated = toBase64(getRandomBytes(48));
process.env.JWT_SECRET = generated;
persistSecret("jwtSecret", generated);
console.log("[STARTUP] JWT_SECRET auto-generated and persisted (random 64-char secret)");
}
}
if (!process.env.API_KEY_SECRET || process.env.API_KEY_SECRET.trim() === "") {
const persisted = getPersistedSecret("apiKeySecret");
if (persisted) {
process.env.API_KEY_SECRET = persisted;
} else {
const generated = toHex(getRandomBytes(32));
process.env.API_KEY_SECRET = generated;
persistSecret("apiKeySecret", generated);
console.log(
"[STARTUP] API_KEY_SECRET auto-generated and persisted (random 64-char hex secret)"
);
}
}
}
export async function registerNodejs(): Promise<void> {
await ensureSecrets();
const { initConsoleInterceptor } = await import("@/lib/consoleInterceptor");
initConsoleInterceptor();
const [
{ initGracefulShutdown },
{ initApiBridgeServer },
{ startBackgroundRefresh },
{ getSettings },
] = await Promise.all([
import("@/lib/gracefulShutdown"),
import("@/lib/apiBridgeServer"),
import("@/domain/quotaCache"),
import("@/lib/db/settings"),
]);
initGracefulShutdown();
initApiBridgeServer();
startBackgroundRefresh();
console.log("[STARTUP] Quota cache background refresh started");
try {
const [{ setCustomAliases }, { setDefaultFastServiceTierEnabled }] = await Promise.all([
import("@omniroute/open-sse/services/modelDeprecation.ts"),
import("@omniroute/open-sse/executors/codex.ts"),
]);
const settings = await getSettings();
if (settings.modelAliases) {
const aliases =
typeof settings.modelAliases === "string"
? JSON.parse(settings.modelAliases)
: settings.modelAliases;
if (aliases && typeof aliases === "object") {
setCustomAliases(aliases);
console.log(
`[STARTUP] Restored ${Object.keys(aliases).length} custom model alias(es) from settings`
);
}
}
const persisted =
typeof settings.codexServiceTier === "string"
? JSON.parse(settings.codexServiceTier)
: settings.codexServiceTier;
if (typeof persisted?.enabled === "boolean") {
setDefaultFastServiceTierEnabled(persisted.enabled);
console.log(
`[STARTUP] Restored Codex fast service tier: ${persisted.enabled ? "on" : "off"}`
);
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.warn("[STARTUP] Could not restore runtime settings:", msg);
}
try {
const { initAuditLog, cleanupExpiredLogs } = await import("@/lib/compliance/index");
initAuditLog();
console.log("[COMPLIANCE] Audit log table initialized");
const cleanup = cleanupExpiredLogs();
if (cleanup.deletedUsage || cleanup.deletedCallLogs || cleanup.deletedAuditLogs) {
console.log("[COMPLIANCE] Expired log cleanup:", cleanup);
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.warn("[COMPLIANCE] Could not initialize audit log:", msg);
}
}

View File

@@ -2,141 +2,19 @@
* Next.js Instrumentation Hook
*
* Called once when the server starts (both dev and production).
* Used to initialize graceful shutdown handlers, console log capture,
* and compliance features (audit log table, expired log cleanup).
* All Node.js-specific logic lives in ./instrumentation-node.ts to prevent
* Turbopack's Edge bundler from tracing into native modules (fs, path, os, etc.)
*
* @see https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
*/
function getRandomBytes(byteLength: number): Uint8Array {
const bytes = new Uint8Array(byteLength);
globalThis.crypto.getRandomValues(bytes);
return bytes;
}
function toBase64(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes));
}
function toHex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
async function ensureSecrets(): Promise<void> {
let getPersistedSecret = (_key: string): string | null => null;
let persistSecret = (_key: string, _value: string): void => {};
try {
({ getPersistedSecret, persistSecret } = await import("@/lib/db/secrets"));
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(
"[STARTUP] Secret persistence unavailable; falling back to process-local secrets:",
msg
);
}
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.trim() === "") {
const persisted = getPersistedSecret("jwtSecret");
if (persisted) {
process.env.JWT_SECRET = persisted;
console.log("[STARTUP] JWT_SECRET restored from persistent store");
} else {
const generated = toBase64(getRandomBytes(48));
process.env.JWT_SECRET = generated;
persistSecret("jwtSecret", generated);
console.log("[STARTUP] JWT_SECRET auto-generated and persisted (random 64-char secret)");
}
}
if (!process.env.API_KEY_SECRET || process.env.API_KEY_SECRET.trim() === "") {
const persisted = getPersistedSecret("apiKeySecret");
if (persisted) {
process.env.API_KEY_SECRET = persisted;
} else {
const generated = toHex(getRandomBytes(32));
process.env.API_KEY_SECRET = generated;
persistSecret("apiKeySecret", generated);
console.log(
"[STARTUP] API_KEY_SECRET auto-generated and persisted (random 64-char hex secret)"
);
}
}
}
export async function register() {
// Only run on the server (not during build or in Edge runtime)
if (process.env.NEXT_RUNTIME === "nodejs") {
await ensureSecrets();
// Console log file capture (must be first — before any logging occurs)
const { initConsoleInterceptor } = await import("@/lib/consoleInterceptor");
initConsoleInterceptor();
const { initGracefulShutdown } = await import("@/lib/gracefulShutdown");
initGracefulShutdown();
const { initApiBridgeServer } = await import("@/lib/apiBridgeServer");
initApiBridgeServer();
// Quota cache: start background refresh for quota-aware account selection
// Dynamic import required — quotaCache depends on better-sqlite3 (Node-only),
// and instrumentation.ts is bundled for all runtimes including Edge.
const { startBackgroundRefresh } = await import("@/domain/quotaCache");
startBackgroundRefresh();
console.log("[STARTUP] Quota cache background refresh started");
// Model aliases: restore persisted custom aliases into in-memory state (#316)
// Custom aliases are saved to settings.modelAliases on PUT /api/settings/model-aliases
// but the in-memory _customAliases resets to {} on every restart — load them here.
try {
const { getSettings } = await import("@/lib/db/settings");
const { setCustomAliases } = await import("@omniroute/open-sse/services/modelDeprecation.ts");
const { setDefaultFastServiceTierEnabled } =
await import("@omniroute/open-sse/executors/codex.ts");
const settings = await getSettings();
if (settings.modelAliases) {
const aliases =
typeof settings.modelAliases === "string"
? JSON.parse(settings.modelAliases)
: settings.modelAliases;
if (aliases && typeof aliases === "object") {
setCustomAliases(aliases);
console.log(
`[STARTUP] Restored ${Object.keys(aliases).length} custom model alias(es) from settings`
);
}
}
const persisted =
typeof settings.codexServiceTier === "string"
? JSON.parse(settings.codexServiceTier)
: settings.codexServiceTier;
if (typeof persisted?.enabled === "boolean") {
setDefaultFastServiceTierEnabled(persisted.enabled);
console.log(
`[STARTUP] Restored Codex fast service tier: ${persisted.enabled ? "on" : "off"}`
);
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.warn("[STARTUP] Could not restore runtime settings:", msg);
}
// Compliance: Initialize audit_log table + cleanup expired logs
try {
const { initAuditLog, cleanupExpiredLogs } = await import("@/lib/compliance/index");
initAuditLog();
console.log("[COMPLIANCE] Audit log table initialized");
const cleanup = cleanupExpiredLogs();
if (cleanup.deletedUsage || cleanup.deletedCallLogs || cleanup.deletedAuditLogs) {
console.log("[COMPLIANCE] Expired log cleanup:", cleanup);
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.warn("[COMPLIANCE] Could not initialize audit log:", msg);
}
// Computed path prevents Turbopack from statically resolving the import
// for the Edge instrumentation bundle, avoiding spurious warnings about
// Node.js modules not being available in the Edge Runtime.
const nodeMod = "./instrumentation-" + "node";
const { registerNodejs } = await import(nodeMod);
await registerNodejs();
}
}

View File

@@ -1,4 +1,4 @@
import { randomUUID } from "node:crypto";
import { randomUUID } from "crypto";
export type ApiErrorType = "invalid_request" | "not_found" | "conflict" | "server_error";

View File

@@ -16,7 +16,9 @@ import { dirname, resolve } from "path";
const logToFile = process.env.LOG_TO_FILE !== "false";
const logFilePath = resolve(process.env.LOG_FILE_PATH || "logs/application/app.log");
let initialized = false;
declare global {
var __omnirouteConsoleInterceptorInit: boolean | undefined;
}
/**
* Map console method names to log levels.
@@ -92,7 +94,7 @@ function writeEntry(level: string, args: unknown[]) {
* Safe to call multiple times — only initializes once.
*/
export function initConsoleInterceptor(): void {
if (!logToFile || initialized) return;
if (!logToFile || globalThis.__omnirouteConsoleInterceptorInit) return;
try {
ensureDir();
@@ -101,7 +103,7 @@ export function initConsoleInterceptor(): void {
return;
}
initialized = true;
globalThis.__omnirouteConsoleInterceptorInit = true;
// Save original methods
const originalMethods = {

View File

@@ -24,6 +24,35 @@ let _lastBackupAt = 0;
const BACKUP_THROTTLE_MS = 60 * 60 * 1000; // 60 minutes
const MAX_DB_BACKUPS = 20;
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function unlinkFileWithRetry(
filePath: string,
options?: { maxAttempts?: number; retryableCodes?: string[]; baseDelayMs?: number }
) {
const maxAttempts = Math.max(1, options?.maxAttempts ?? 10);
const retryableCodes = new Set(options?.retryableCodes ?? ["EBUSY", "EPERM"]);
const baseDelayMs = Math.max(0, options?.baseDelayMs ?? 100);
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
return;
} catch (err: unknown) {
const code =
err && typeof err === "object" && "code" in err ? (err as NodeJS.ErrnoException).code : "";
if (code === "ENOENT") return;
if (retryableCodes.has(String(code)) && attempt < maxAttempts - 1) {
await sleep(baseDelayMs * (attempt + 1));
} else {
throw err;
}
}
}
}
// ──────────────── Backup ────────────────
export function backupDbFile(reason = "auto") {
@@ -197,9 +226,22 @@ export async function restoreDbBackup(backupId: string) {
throw new Error(`Backup file is corrupt: ${message}`);
}
// Force pre-restore backup (bypass throttle)
// Force pre-restore backup (bypass throttle) and await so the DB is not closed while backup runs
_lastBackupAt = 0;
backupDbFile("pre-restore");
const backupDirForPre = DB_BACKUPS_DIR || path.join(DATA_DIR, "db_backups");
if (SQLITE_FILE && fs.existsSync(SQLITE_FILE)) {
const stat = fs.statSync(SQLITE_FILE);
if (stat.size >= 4096) {
if (!fs.existsSync(backupDirForPre)) fs.mkdirSync(backupDirForPre, { recursive: true });
const preBackupPath = path.join(
backupDirForPre,
`db_${new Date().toISOString().replace(/[:.]/g, "-")}_pre-restore.sqlite`
);
const dbForBackup = getDbInstance();
await dbForBackup.backup(preBackupPath);
_lastBackupAt = Date.now();
}
}
// Close and reset current connection
resetDbInstance();
@@ -212,7 +254,11 @@ export async function restoreDbBackup(backupId: string) {
throw new Error("SQLITE_FILE is unavailable in local backup restore");
}
// On Windows, the file handle may be released asynchronously after close; give it a moment.
await sleep(500);
// Remove main file and WAL sidecars to avoid stale frame replay after restore.
// Retry unlink on EBUSY/EPERM (Windows may hold the handle briefly).
const sqliteFilesToReplace = [
sqliteFile,
`${sqliteFile}-wal`,
@@ -221,9 +267,7 @@ export async function restoreDbBackup(backupId: string) {
];
for (const filePath of sqliteFilesToReplace) {
if (!filePath) continue;
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
await unlinkFileWithRetry(filePath);
}
// Copy backup over current DB

View File

@@ -302,8 +302,24 @@ export function cleanNulls(obj: unknown): JsonRecord {
}
// ──────────────── Singleton DB Instance ────────────────
// Use globalThis to survive Next.js dev HMR module re-evaluation.
// Module-level `let` resets on every webpack recompile, causing connection leaks.
let _db: SqliteDatabase | null = null;
declare global {
var __omnirouteDb: import("better-sqlite3").Database | undefined;
}
function getDb(): SqliteDatabase | null {
return globalThis.__omnirouteDb ?? null;
}
function setDb(db: SqliteDatabase | null): void {
if (db) {
globalThis.__omnirouteDb = db;
} else {
delete globalThis.__omnirouteDb;
}
}
function ensureProviderConnectionsColumns(db: SqliteDatabase) {
try {
@@ -361,7 +377,8 @@ function ensureUsageHistoryColumns(db: SqliteDatabase) {
}
export function getDbInstance(): SqliteDatabase {
if (_db) return _db;
const existing = getDb();
if (existing) return existing;
if (isCloud || isBuildPhase) {
if (isBuildPhase) {
@@ -371,7 +388,7 @@ export function getDbInstance(): SqliteDatabase {
memoryDb.pragma("journal_mode = WAL");
memoryDb.exec(SCHEMA_SQL);
ensureUsageHistoryColumns(memoryDb);
_db = memoryDb;
setDb(memoryDb);
return memoryDb;
}
@@ -382,6 +399,7 @@ export function getDbInstance(): SqliteDatabase {
const jsonDbFile = JSON_DB_FILE;
// Detect and handle old schema format — preserve data when possible (#146)
// Uses a single probe connection that becomes the real connection when possible.
if (fs.existsSync(sqliteFile)) {
try {
const probe = new Database(sqliteFile, { readonly: true });
@@ -390,7 +408,6 @@ export function getDbInstance(): SqliteDatabase {
.get();
if (hasOldSchema) {
// Check if the DB has actual data we should preserve
let hasData = false;
try {
const count = probe.prepare("SELECT COUNT(*) as c FROM provider_connections").get() as
@@ -403,15 +420,12 @@ export function getDbInstance(): SqliteDatabase {
probe.close();
if (hasData) {
// Data exists — preserve it! Just drop the old migration tracking table
// and let our new migration system (CREATE TABLE IF NOT EXISTS) take over
console.log(
`[DB] Old schema_migrations table found but data exists — preserving data (#146)`
);
const fixDb = new Database(sqliteFile);
try {
fixDb.exec("DROP TABLE IF EXISTS schema_migrations");
// Clean up WAL/SHM files that might be stale
fixDb.pragma("wal_checkpoint(TRUNCATE)");
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
@@ -420,7 +434,6 @@ export function getDbInstance(): SqliteDatabase {
fixDb.close();
}
} else {
// No data — safe to rename and start fresh
const oldPath = sqliteFile + ".old-schema";
console.log(
`[DB] Old incompatible schema detected (empty) — renaming to ${path.basename(oldPath)}`
@@ -481,7 +494,7 @@ export function getDbInstance(): SqliteDatabase {
);
versionStmt.run();
_db = db;
setDb(db);
console.log(`[DB] SQLite database ready: ${sqliteFile}`);
return db;
}
@@ -490,9 +503,10 @@ export function getDbInstance(): SqliteDatabase {
* Reset the singleton (used by restore).
*/
export function resetDbInstance() {
if (_db) {
_db.close();
_db = null;
const db = getDb();
if (db) {
db.close();
setDb(null);
}
}

View File

@@ -55,7 +55,10 @@ export function getModelCompatOverrides(providerId: string): ModelCompatOverride
export function mergeModelCompatOverride(
providerId: string,
modelId: string,
patch: Partial<Pick<ModelCompatOverride, "normalizeToolCallId" | "preserveOpenAIDeveloperRole">>
patch: Partial<{
normalizeToolCallId: boolean;
preserveOpenAIDeveloperRole: boolean | null;
}>
) {
const list = readCompatList(providerId);
const idx = list.findIndex((e) => e.id === modelId);
@@ -66,7 +69,11 @@ export function mergeModelCompatOverride(
else delete next.normalizeToolCallId;
}
if ("preserveOpenAIDeveloperRole" in patch) {
next.preserveOpenAIDeveloperRole = Boolean(patch.preserveOpenAIDeveloperRole);
if (patch.preserveOpenAIDeveloperRole === null) {
delete next.preserveOpenAIDeveloperRole; // unset: revert to default (undefined at read time)
} else {
next.preserveOpenAIDeveloperRole = Boolean(patch.preserveOpenAIDeveloperRole);
}
}
const filtered = list.filter((e) => e.id !== modelId);
const hasPreserveFlag = Object.prototype.hasOwnProperty.call(next, "preserveOpenAIDeveloperRole");
@@ -274,7 +281,7 @@ export async function updateCustomModel(
if (index === -1) return null;
const current = models[index];
const next = {
const next: JsonRecord = {
...current,
...(updates.modelName !== undefined ? { name: updates.modelName || current.name } : {}),
...(updates.apiFormat !== undefined ? { apiFormat: updates.apiFormat } : {}),
@@ -284,10 +291,14 @@ export async function updateCustomModel(
...(updates.normalizeToolCallId !== undefined
? { normalizeToolCallId: Boolean(updates.normalizeToolCallId) }
: {}),
...(updates.preserveOpenAIDeveloperRole !== undefined
? { preserveOpenAIDeveloperRole: Boolean(updates.preserveOpenAIDeveloperRole) }
: {}),
};
if (Object.prototype.hasOwnProperty.call(updates, "preserveOpenAIDeveloperRole")) {
if (updates.preserveOpenAIDeveloperRole === null) {
delete next.preserveOpenAIDeveloperRole;
} else {
next.preserveOpenAIDeveloperRole = Boolean(updates.preserveOpenAIDeveloperRole);
}
}
models[index] = next;

View File

@@ -1,4 +1,4 @@
import { randomUUID } from "node:crypto";
import { randomUUID } from "crypto";
import { getDbInstance } from "./core";
import { backupDbFile } from "./backup";

View File

@@ -12,21 +12,28 @@
* @module lib/gracefulShutdown
*/
/** Whether we are currently shutting down */
let isShuttingDown = false;
/** Number of in-flight requests being tracked */
let activeRequests = 0;
/** Grace period before forced exit (default 30s, configurable) */
const SHUTDOWN_TIMEOUT_MS = parseInt(process.env.SHUTDOWN_TIMEOUT_MS || "30000", 10);
declare global {
var __omnirouteShutdown:
| { init: boolean; shuttingDown: boolean; activeRequests: number }
| undefined;
}
function getShutdownState() {
if (!globalThis.__omnirouteShutdown) {
globalThis.__omnirouteShutdown = { init: false, shuttingDown: false, activeRequests: 0 };
}
return globalThis.__omnirouteShutdown;
}
/**
* Check if the server is currently shutting down.
* Route handlers can use this to reject new requests.
*/
export function isDraining(): boolean {
return isShuttingDown;
return getShutdownState().shuttingDown;
}
/**
@@ -34,12 +41,13 @@ export function isDraining(): boolean {
* Returns a done callback.
*/
export function trackRequest(): () => void {
activeRequests++;
const state = getShutdownState();
state.activeRequests++;
let called = false;
return () => {
if (!called) {
called = true;
activeRequests--;
state.activeRequests--;
}
};
}
@@ -48,19 +56,20 @@ export function trackRequest(): () => void {
* Get current active request count (for monitoring/health endpoints).
*/
export function getActiveRequestCount(): number {
return activeRequests;
return getShutdownState().activeRequests;
}
/**
* Wait for all in-flight requests to complete, with timeout.
*/
async function waitForDrain(): Promise<void> {
const state = getShutdownState();
const start = Date.now();
const CHECK_INTERVAL_MS = 250;
return new Promise((resolve) => {
const check = () => {
if (activeRequests <= 0) {
if (state.activeRequests <= 0) {
console.log("[Shutdown] All in-flight requests drained.");
resolve();
return;
@@ -68,13 +77,13 @@ async function waitForDrain(): Promise<void> {
if (Date.now() - start > SHUTDOWN_TIMEOUT_MS) {
console.warn(
`[Shutdown] Timeout after ${SHUTDOWN_TIMEOUT_MS}ms with ${activeRequests} active requests. Forcing exit.`
`[Shutdown] Timeout after ${SHUTDOWN_TIMEOUT_MS}ms with ${state.activeRequests} active requests. Forcing exit.`
);
resolve();
return;
}
console.log(`[Shutdown] Waiting for ${activeRequests} in-flight request(s)...`);
console.log(`[Shutdown] Waiting for ${state.activeRequests} in-flight request(s)...`);
setTimeout(check, CHECK_INTERVAL_MS);
};
@@ -87,7 +96,6 @@ async function waitForDrain(): Promise<void> {
*/
async function cleanup(): Promise<void> {
try {
// Close SQLite database — import dynamically to avoid circular deps
const { getDbInstance } = await import("@/lib/db/core");
const db = getDbInstance();
if (db && typeof db.close === "function") {
@@ -104,11 +112,15 @@ async function cleanup(): Promise<void> {
* Should be called once during server startup.
*/
export function initGracefulShutdown(): void {
const shutdown = async (signal: string) => {
if (isShuttingDown) return; // Prevent double-shutdown
isShuttingDown = true;
const state = getShutdownState();
if (state.init) return;
state.init = true;
console.log(`\n[Shutdown] Received ${signal}. Draining ${activeRequests} request(s)...`);
const shutdown = async (signal: string) => {
if (state.shuttingDown) return;
state.shuttingDown = true;
console.log(`\n[Shutdown] Received ${signal}. Draining ${state.activeRequests} request(s)...`);
await waitForDrain();
await cleanup();

View File

@@ -32,11 +32,32 @@ const CHECK_TIMEOUT_MS = 5_000;
const INITIAL_DELAY_MS = 15_000; // Wait for server boot before first sweep
const LOG_PREFIX = "[LocalHealthCheck]";
// ── State ────────────────────────────────────────────────────────────────
// ── State (globalThis survives HMR re-evaluation) ───────────────────────
const healthCache = new Map<string, HealthStatus>();
let initialized = false;
let sweepTimer: ReturnType<typeof setTimeout> | null = null;
declare global {
var __omnirouteLocalHC:
| {
initialized: boolean;
sweepTimer: ReturnType<typeof setTimeout> | null;
healthCache: Map<string, HealthStatus>;
sweepInProgress: boolean;
}
| undefined;
}
function getLHCState() {
if (!globalThis.__omnirouteLocalHC) {
globalThis.__omnirouteLocalHC = {
initialized: false,
sweepTimer: null,
healthCache: new Map(),
sweepInProgress: false,
};
}
return globalThis.__omnirouteLocalHC;
}
const healthCache = getLHCState().healthCache;
// ── Helpers ──────────────────────────────────────────────────────────────
@@ -101,12 +122,11 @@ async function checkNode(node: {
}
}
let sweepInProgress = false;
/** Single sweep: check all local provider_nodes in parallel. */
export async function sweep(): Promise<void> {
if (sweepInProgress) return; // Prevent concurrent sweeps
sweepInProgress = true;
const state = getLHCState();
if (state.sweepInProgress) return;
state.sweepInProgress = true;
try {
let nodes: Array<{ id: string; prefix: string; baseUrl: string }>;
@@ -149,15 +169,15 @@ export async function sweep(): Promise<void> {
}
}
} finally {
sweepInProgress = false;
// Schedule next sweep with backoff based on worst-case failure count
state.sweepInProgress = false;
scheduleSweep();
}
}
function scheduleSweep(): void {
if (!initialized) return; // Don't schedule if stopped
if (sweepTimer) clearTimeout(sweepTimer);
const state = getLHCState();
if (!state.initialized) return;
if (state.sweepTimer) clearTimeout(state.sweepTimer);
// Use the maximum consecutive failures across all nodes to determine interval
let maxFailures = 0;
@@ -168,7 +188,7 @@ function scheduleSweep(): void {
}
const interval = getNextInterval(maxFailures);
sweepTimer = setTimeout(sweep, interval);
state.sweepTimer = setTimeout(sweep, interval);
}
// ── Public API ───────────────────────────────────────────────────────────
@@ -191,27 +211,28 @@ export function getAllHealthStatuses(): Record<string, HealthStatus> {
/** Start the health check scheduler (idempotent). */
export function initLocalHealthCheck(): void {
if (initialized) return;
initialized = true;
const state = getLHCState();
if (state.initialized) return;
state.initialized = true;
console.log(
LOG_PREFIX,
`Starting local provider health check (initial delay ${INITIAL_DELAY_MS / 1000}s)`
);
// Delay first sweep to let the server finish booting
sweepTimer = setTimeout(() => {
state.sweepTimer = setTimeout(() => {
sweep().catch((err) => console.error(LOG_PREFIX, "Initial sweep failed:", err));
}, INITIAL_DELAY_MS);
}
/** Stop the scheduler (for tests / hot-reload). */
export function stopLocalHealthCheck(): void {
if (sweepTimer) {
clearTimeout(sweepTimer);
sweepTimer = null;
const state = getLHCState();
if (state.sweepTimer) {
clearTimeout(state.sweepTimer);
state.sweepTimer = null;
}
initialized = false;
state.initialized = false;
}
// Auto-initialize on first import (same pattern as tokenHealthCheck.ts:272)

View File

@@ -99,23 +99,34 @@ export function clearHealthCheckLogCache() {
cacheTimestamp = 0;
}
// ── Singleton guard ──────────────────────────────────────────────────────────
let initialized = false;
let intervalHandle = null;
// ── Singleton guard (globalThis survives HMR re-evaluation) ─────────────────
declare global {
var __omnirouteTokenHC:
| { initialized: boolean; interval: ReturnType<typeof setInterval> | null }
| undefined;
}
function getHCState() {
if (!globalThis.__omnirouteTokenHC) {
globalThis.__omnirouteTokenHC = { initialized: false, interval: null };
}
return globalThis.__omnirouteTokenHC;
}
/**
* Start the health-check scheduler (idempotent).
*/
export function initTokenHealthCheck() {
if (initialized || isHealthCheckDisabled()) return;
initialized = true;
const state = getHCState();
if (state.initialized || isHealthCheckDisabled()) return;
state.initialized = true;
log(`${LOG_PREFIX} Starting proactive token health-check (tick every ${TICK_MS / 1000}s)`);
// Run first sweep after a short delay so the server finishes booting
setTimeout(() => {
sweep();
intervalHandle = setInterval(sweep, TICK_MS);
state.interval = setInterval(sweep, TICK_MS);
}, 10_000);
}
@@ -123,11 +134,12 @@ export function initTokenHealthCheck() {
* Stop the scheduler (useful for tests / hot-reload).
*/
export function stopTokenHealthCheck() {
if (intervalHandle) {
clearInterval(intervalHandle);
intervalHandle = null;
const state = getHCState();
if (state.interval) {
clearInterval(state.interval);
state.interval = null;
}
initialized = false;
state.initialized = false;
}
// ── Core sweep ───────────────────────────────────────────────────────────────

View File

@@ -348,7 +348,7 @@ export const providerModelMutationSchema = z.object({
apiFormat: z.enum(["chat-completions", "responses"]).default("chat-completions"),
supportedEndpoints: z.array(z.enum(["chat", "embeddings", "images", "audio"])).default(["chat"]),
normalizeToolCallId: z.boolean().optional(),
preserveOpenAIDeveloperRole: z.boolean().optional(),
preserveOpenAIDeveloperRole: z.boolean().nullable().optional(),
});
const pricingFieldsSchema = z

View File

@@ -125,7 +125,6 @@ test.describe("Bailian Coding Plan Provider", () => {
});
test("invalid URL blocks save with validation error", async ({ page }) => {
let validationErrorCaptured = false;
let createAttempted = false;
await page.route("**/api/providers", async (route) => {
@@ -227,24 +226,25 @@ test.describe("Bailian Coding Plan Provider", () => {
.last();
await saveButton.click();
const errorLocator = page
.locator("text=/invalid.*url|url.*invalid|must be a valid url/i")
.or(
page
.locator(".text-red-500")
.or(page.locator('[class*="error"]').or(page.locator('[class*="text-destructive"]')))
);
// Wait for React to process the validation and re-render
await page.waitForTimeout(1000);
const errorVisible = await errorLocator.isVisible({ timeout: 5000 }).catch(() => false);
// Check for the validation error scoped to the dialog to avoid strict-mode
// violations from broad selectors matching unrelated page elements.
const errorTextLocator = dialog
.locator("text=/invalid.*url|url.*invalid|must be a valid url|must use http/i")
.first();
const errorClassLocator = dialog.locator(".text-red-500").first();
let errorVisible =
(await errorTextLocator.isVisible().catch(() => false)) ||
(await errorClassLocator.isVisible().catch(() => false));
if (!errorVisible) {
// Fallback: if the dialog stays open after clicking save, it means the
// client-side validation prevented submission (which is the desired behavior).
await page.waitForTimeout(2000);
const modalStillOpen = await dialog.isVisible();
if (modalStillOpen) {
validationErrorCaptured = true;
}
errorVisible = await dialog.isVisible().catch(() => false);
}
expect(errorVisible).toBe(true);

View File

@@ -44,6 +44,7 @@ function withTempEnv(fn) {
test("bootstrapEnv prefers ~/.omniroute/.env over server.env", () => {
withTempEnv(({ dataDir }) => {
process.env.DATA_DIR = dataDir;
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(
path.join(dataDir, ".env"),
@@ -65,6 +66,7 @@ test("bootstrapEnv prefers ~/.omniroute/.env over server.env", () => {
test("bootstrapEnv refuses to generate a new key over encrypted data", () => {
withTempEnv(({ dataDir }) => {
process.env.DATA_DIR = dataDir;
fs.mkdirSync(dataDir, { recursive: true });
const db = new Database(path.join(dataDir, "storage.sqlite"));
try {
@@ -77,8 +79,10 @@ test("bootstrapEnv refuses to generate a new key over encrypted data", () => {
id_token TEXT
);
`);
db.prepare("INSERT INTO provider_connections (id, access_token) VALUES (?, ?)")
.run("conn-1", "enc:v1:deadbeef:feedface:cafebabe");
db.prepare("INSERT INTO provider_connections (id, access_token) VALUES (?, ?)").run(
"conn-1",
"enc:v1:deadbeef:feedface:cafebabe"
);
} finally {
db.close();
}
@@ -92,17 +96,16 @@ test("bootstrapEnv refuses to generate a new key over encrypted data", () => {
test("bootstrapEnv fails closed when existing database cannot be inspected", () => {
withTempEnv(({ dataDir }) => {
process.env.DATA_DIR = dataDir;
fs.mkdirSync(path.join(dataDir, "storage.sqlite"), { recursive: true });
assert.throws(
() => bootstrapEnv({ quiet: true }),
/Unable to inspect existing database/
);
assert.throws(() => bootstrapEnv({ quiet: true }), /Unable to inspect existing database/);
});
});
test("bootstrapEnv ignores blank dataDirOverride values", () => {
withTempEnv(({ dataDir }) => {
process.env.DATA_DIR = dataDir;
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(path.join(dataDir, ".env"), "JWT_SECRET=jwt-from-dot-env\n", "utf8");

View File

@@ -1,26 +1,51 @@
import { describe, it, beforeEach, afterEach } from "node:test";
import { describe, it, beforeEach, afterEach, after } from "node:test";
import assert from "node:assert/strict";
import Database from "better-sqlite3";
import path from "node:path";
import fs from "node:fs";
import os from "node:os";
// ─── Test Setup: Use temp DB ────────────────────────
function assertAlmostEqual(actual, expected, epsilon = 1e-9, message = "") {
assert.ok(
Math.abs(actual - expected) <= epsilon,
message || `expected ${actual} to be within ${epsilon} of ${expected}`
);
}
let tmpDir;
let originalEnv;
// ─── Test Setup: Single temp dir for whole file (core caches DATA_DIR at first import) ────────
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-domain-test-"));
originalEnv = process.env.DATA_DIR;
process.env.DATA_DIR = tmpDir;
const fileTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-domain-test-"));
const originalDataDir = process.env.DATA_DIR;
process.env.DATA_DIR = fileTmpDir;
async function removeStorageFiles(dir) {
const storage = path.join(dir, "storage.sqlite");
try {
const core = await import("../../src/lib/db/core.ts");
core.resetDbInstance();
} catch {
/* core may not be loaded yet */
}
for (const suffix of ["", "-wal", "-shm", "-journal"]) {
const p = storage + suffix;
try {
if (fs.existsSync(p)) fs.unlinkSync(p);
} catch {}
}
}
beforeEach(async () => {
await removeStorageFiles(fileTmpDir);
});
afterEach(() => {
process.env.DATA_DIR = originalEnv;
if (tmpDir && fs.existsSync(tmpDir)) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
afterEach(async () => {
const core = await import("../../src/lib/db/core.ts");
core.resetDbInstance();
});
after(() => {
process.env.DATA_DIR = originalDataDir;
if (fs.existsSync(fileTmpDir)) fs.rmSync(fileTmpDir, { recursive: true, force: true });
});
// ─── Fallback Policy Tests ────────────────────────
@@ -151,7 +176,7 @@ describe("costRules persistence", () => {
recordCost("key2", 1.0);
const total = getDailyTotal("key2");
assert.ok(total >= 4.5);
assertAlmostEqual(total, 4.5, 1e-9, `daily total ${total} should equal 4.5 (3.5 + 1.0)`);
// Should still be allowed
const check = checkBudget("key2", 0);
@@ -187,8 +212,18 @@ describe("costRules persistence", () => {
recordCost("key3", 2.5);
const summary = getCostSummary("key3");
assert.ok(summary.dailyTotal >= 4.0);
assert.ok(summary.monthlyTotal >= 4.0);
assertAlmostEqual(
summary.dailyTotal,
4.0,
1e-9,
`dailyTotal ${summary.dailyTotal} should equal 4.0 (1.5 + 2.5)`
);
assertAlmostEqual(
summary.monthlyTotal,
4.0,
1e-9,
`monthlyTotal ${summary.monthlyTotal} should equal 4.0`
);
assert.equal(summary.budget.dailyLimitUsd, 100);
resetCostData();

View File

@@ -4,6 +4,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const isWindows = process.platform === "win32";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-fixes-"));
process.env.DATA_DIR = TEST_DATA_DIR;
@@ -39,7 +40,20 @@ async function withEnv(name, value, fn) {
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
for (let attempt = 0; attempt < 10; attempt++) {
try {
if (fs.existsSync(TEST_DATA_DIR)) {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
}
break;
} catch (err) {
if ((err?.code === "EBUSY" || err?.code === "EPERM") && attempt < 9) {
await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
} else {
throw err;
}
}
}
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -87,38 +101,88 @@ test("token refresh dedupe key avoids collision for same-prefix tokens", async (
}
});
test("restoreDbBackup clears stale sqlite sidecars before reopen", async () => {
await resetStorage();
test(
"restoreDbBackup clears stale sqlite sidecars before reopen",
{ skip: isWindows },
async () => {
await resetStorage();
const db = core.getDbInstance();
const now = new Date().toISOString();
db.prepare(
"INSERT INTO provider_connections (id, provider, auth_type, name, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
).run("restore-test-conn", "openai", "apikey", "restore-test", 1, now, now);
const db = core.getDbInstance();
const now = new Date().toISOString();
db.prepare(
"INSERT INTO provider_connections (id, provider, auth_type, name, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
).run("restore-test-conn", "openai", "apikey", "restore-test", 1, now, now);
const backupId = "db_2000-01-01T00-00-00-000Z_manual.sqlite";
const backupPath = path.join(core.DB_BACKUPS_DIR, backupId);
fs.mkdirSync(core.DB_BACKUPS_DIR, { recursive: true });
await db.backup(backupPath);
const backupId = "db_2000-01-01T00-00-00-000Z_manual.sqlite";
const backupPath = path.join(core.DB_BACKUPS_DIR, backupId);
fs.mkdirSync(core.DB_BACKUPS_DIR, { recursive: true });
await db.backup(backupPath);
fs.writeFileSync(`${core.SQLITE_FILE}-wal`, "STALE-WAL-MARKER");
fs.writeFileSync(`${core.SQLITE_FILE}-shm`, "STALE-SHM-MARKER");
fs.writeFileSync(`${core.SQLITE_FILE}-journal`, "STALE-JOURNAL-MARKER");
core.resetDbInstance();
fs.writeFileSync(`${core.SQLITE_FILE}-wal`, "STALE-WAL-MARKER");
fs.writeFileSync(`${core.SQLITE_FILE}-shm`, "STALE-SHM-MARKER");
fs.writeFileSync(`${core.SQLITE_FILE}-journal`, "STALE-JOURNAL-MARKER");
await backupDb.restoreDbBackup(backupId);
await backupDb.restoreDbBackup(backupId);
for (const suffix of ["-wal", "-shm", "-journal"]) {
const sidecarPath = `${core.SQLITE_FILE}${suffix}`;
if (!fs.existsSync(sidecarPath)) continue;
const text = fs.readFileSync(sidecarPath, "utf8");
assert.equal(text.includes("STALE-"), false, `sidecar ${suffix} still contains stale marker`);
for (const suffix of ["-wal", "-shm", "-journal"]) {
const sidecarPath = `${core.SQLITE_FILE}${suffix}`;
if (!fs.existsSync(sidecarPath)) continue;
const text = fs.readFileSync(sidecarPath, "utf8");
assert.equal(text.includes("STALE-"), false, `sidecar ${suffix} still contains stale marker`);
}
const reopenedDb = core.getDbInstance();
const row = reopenedDb
.prepare("SELECT COUNT(*) AS cnt FROM provider_connections WHERE id = ?")
.get("restore-test-conn");
assert.equal(row.cnt, 1);
}
);
const reopenedDb = core.getDbInstance();
const row = reopenedDb
.prepare("SELECT COUNT(*) AS cnt FROM provider_connections WHERE id = ?")
.get("restore-test-conn");
assert.equal(row.cnt, 1);
test("unlinkFileWithRetry retries EBUSY/EPERM and eventually succeeds", async () => {
const target = path.join(TEST_DATA_DIR, "retry-target.tmp");
fs.writeFileSync(target, "retry-me");
const originalExistsSync = fs.existsSync;
const originalUnlinkSync = fs.unlinkSync;
const seenCodes = [];
let attempts = 0;
fs.existsSync = (filePath) => {
if (filePath === target) return attempts < 3 || originalExistsSync(filePath);
return originalExistsSync(filePath);
};
fs.unlinkSync = (filePath) => {
if (filePath === target) {
attempts++;
if (attempts === 1) {
const err = new Error("busy");
err.code = "EBUSY";
seenCodes.push(err.code);
throw err;
}
if (attempts === 2) {
const err = new Error("perm");
err.code = "EPERM";
seenCodes.push(err.code);
throw err;
}
}
return originalUnlinkSync(filePath);
};
try {
await backupDb.unlinkFileWithRetry(target, { maxAttempts: 5, baseDelayMs: 1 });
assert.equal(attempts, 3);
assert.deepEqual(seenCodes, ["EBUSY", "EPERM"]);
assert.equal(fs.existsSync(target), false);
} finally {
fs.existsSync = originalExistsSync;
fs.unlinkSync = originalUnlinkSync;
if (originalExistsSync(target)) originalUnlinkSync(target);
}
});
test("provider connection persists rateLimitProtection across reopen", async () => {