Compare commits

...

46 Commits

Author SHA1 Message Date
diegosouzapw c74ed29739 chore(release): v2.9.0 — cross-platform machineId, per-key rate limits, streaming cache, Alibaba DashScope, search analytics, ZWS v5, 8 issues closed
Build Electron Desktop App / Validate version (push) Failing after 33s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
2026-03-20 20:12:34 -03:00
diegosouzapw 6c8501f122 fix: cross-platform machineId without process.platform branching (#506)
Rewrite getMachineIdRaw() to use a try/catch waterfall instead of
process.platform conditionals. Next.js SWC bundler evaluates
process.platform at BUILD time, so when built on Linux, the win32
branch was dead-code-eliminated — causing 'head is not recognized'
errors on Windows.

New approach:
1. Try Windows REG.exe (existsSync check, not platform check)
2. Try macOS ioreg command
3. Try reading /etc/machine-id directly (no head/pipe)
4. Try hostname command
5. Fallback to os.hostname()

Also eliminates the patch-machine-id.cjs post-install workaround.
2026-03-20 20:07:19 -03:00
diegosouzapw 941e945f74 Merge branch 'feat/zws-v5' 2026-03-20 19:36:25 -03:00
diegosouzapw f2844d59e4 Merge branch 'feat/search-provider-routing' 2026-03-20 19:36:17 -03:00
diegosouzapw 047ff187f6 Merge branch 'feat/custom-endpoint-paths'
# Conflicts:
#	src/shared/constants/providers.ts
2026-03-20 19:34:10 -03:00
diegosouzapw 1136c40811 Merge branch 'fix/tools-filter-claude-format' 2026-03-20 19:33:08 -03:00
diegosouzapw 5a78dc864f Merge branch 'fix/issue-456-458-combo-schema-mitm-windows' 2026-03-20 19:33:08 -03:00
diegosouzapw 15c98c3048 Merge branch 'fix/developer-role-param-error' 2026-03-20 19:33:07 -03:00
diegosouzapw 0a5b005ce5 fix: resolve multiple issues (#493, #490, #452)
- #493: Fix custom provider model naming — removed incorrect prefix
  stripping in DefaultExecutor.transformRequest() that broke org-scoped
  model IDs like 'zai-org/GLM-5-FP8'

- #490: Enable context cache protection for streaming responses using
  TransformStream to inject omniModel tag as final SSE content delta
  before [DONE] marker

- #452: Add per-API-key request-count limits (max_requests_per_day,
  max_requests_per_minute) with in-memory sliding window counter,
  schema auto-migration, and Check 5 in enforceApiKeyPolicy()
2026-03-20 19:26:21 -03:00
diegosouzapw 4d64e64127 fix: KIRO MITM card text + v2.8.9 release (#505)
Build Electron Desktop App / Validate version (push) Failing after 38s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
2026-03-20 16:14:49 -03:00
Diego Rodrigues de Sa e Souza 5470c70cd0 Merge pull request #497 from zhangqiang8vip/feat/zws-v5
fix(perf): resolve dev-mode HMR resource leaks, Edge warnings, and Windows test stability
2026-03-20 16:13:27 -03:00
diegosouzapw 47959ee395 Merge branch 'main' into feat/zws-v5 2026-03-20 16:10:59 -03:00
Diego Rodrigues de Sa e Souza 7c34c178cd Merge pull request #503 from diegosouzapw/dependabot/github_actions/docker/login-action-4
chore(deps): bump docker/login-action from 3 to 4
2026-03-20 16:07:00 -03:00
Diego Rodrigues de Sa e Souza ac7cb41483 Merge pull request #502 from diegosouzapw/dependabot/github_actions/docker/setup-qemu-action-4
chore(deps): bump docker/setup-qemu-action from 3 to 4
2026-03-20 16:06:58 -03:00
Diego Rodrigues de Sa e Souza 0ab388b88e Merge pull request #501 from diegosouzapw/dependabot/github_actions/peter-evans/dockerhub-description-5
chore(deps): bump peter-evans/dockerhub-description from 4 to 5
2026-03-20 16:06:56 -03:00
Diego Rodrigues de Sa e Souza 54448902f1 Merge pull request #500 from diegosouzapw/dependabot/github_actions/actions/checkout-6
chore(deps): bump actions/checkout from 4 to 6
2026-03-20 16:06:53 -03:00
Diego Rodrigues de Sa e Souza 12107a02fd Merge pull request #499 from diegosouzapw/dependabot/github_actions/docker/build-push-action-7
chore(deps): bump docker/build-push-action from 6 to 7
2026-03-20 16:06:50 -03:00
Diego Rodrigues de Sa e Souza eace06efdc Merge pull request #498 from Sajid11194/fix/windows-machine-id-undefined-reg-exe
Thanks @Sajid11194 for fixing the Windows machine ID crash! Merged and will be part of v2.8.9. 🎉
2026-03-20 16:06:15 -03:00
dependabot[bot] ee0afa1eec chore(deps): bump docker/login-action from 3 to 4
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-20 18:26:04 +00:00
dependabot[bot] 83cdd0dafe chore(deps): bump docker/setup-qemu-action from 3 to 4
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-20 18:25:58 +00:00
dependabot[bot] 5be025f1d1 chore(deps): bump peter-evans/dockerhub-description from 4 to 5
Bumps [peter-evans/dockerhub-description](https://github.com/peter-evans/dockerhub-description) from 4 to 5.
- [Release notes](https://github.com/peter-evans/dockerhub-description/releases)
- [Commits](https://github.com/peter-evans/dockerhub-description/compare/v4...v5)

---
updated-dependencies:
- dependency-name: peter-evans/dockerhub-description
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-20 18:25:55 +00:00
dependabot[bot] c651842ea1 chore(deps): bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-20 18:25:51 +00:00
dependabot[bot] 423abe6788 chore(deps): bump docker/build-push-action from 6 to 7
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-20 18:25:45 +00:00
diegosouzapw 4003c38fd1 fix: OAuth batch test crash + Test All button on provider pages (v2.8.8)
Build Electron Desktop App / Validate version (push) Failing after 35s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
2026-03-20 15:09:48 -03:00
Sajid 3e0c322fd4 fix: address Gemini code review — use execFileSync and optional chaining
- Replace execSync template string with execFileSync + args array on Windows
  to prevent command injection via SystemRoot/windir environment variables
- Add optional chaining (?.) and nullish coalescing (?? "") on Windows
  REG_SZ output parsing to prevent crash if REG.exe output is unexpected
- Add optional chaining on macOS IOPlatformUUID parsing for the same reason

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:44:15 +06:00
zhang-qiang 7fcdd4abdd fix(ci): resolve t11 any-budget false positive and e2e bailian validation test
- Replace 'any other path' with 'all other paths' in translator comment to avoid false match by the \bany\b regex in check-t11-any-budget

- Scope e2e error locator to dialog and use .first() to prevent Playwright strict-mode violations from broad page-level selectors

- Fix fallback logic: treat dialog-still-open as validation success signal

Made-with: Cursor
2026-03-21 01:19:44 +08:00
zhang-qiang 3f3280b2d4 Merge remote-tracking branch 'upstream/main' into feat/zws-v5 2026-03-21 00:55:57 +08:00
zhang-qiang aae2399631 fix(perf): resolve HMR singleton leaks, Edge warnings, and test stability
- Use globalThis singleton guards for DB connection, HealthCheck timers, console interceptor, and graceful shutdown to survive Webpack HMR re-evaluation (fixes 485+ leaked DB connections per session)

- Split instrumentation.ts into instrumentation-node.ts with computed import path to prevent Turbopack Edge bundler from tracing Node.js modules (eliminates 10+ spurious warnings per hot compile)

- Parallelize startup imports in instrumentation-node.ts (3 batch Promise.all instead of 9 serial awaits)

- Add OMNIROUTE_USE_TURBOPACK=1 env switch in run-next.mjs (default behavior unchanged)

- Replace node:crypto with crypto in proxies.ts and errorResponse.ts to fix UnhandledSchemeError

- Add unlinkFileWithRetry with EBUSY/EPERM retry for Windows file handle timing in backup restore

- Fix pre-restore backup to await completion before closing DB

- Fix bootstrap-env, domain-persistence, and fixes-p1 test stability on Windows

Made-with: Cursor
2026-03-21 00:50:07 +08:00
Sajid 03bd2b6803 fix: resolve Windows machine ID failure due to node-machine-id bundle-time platform detection
Problem:
node-machine-id constructs the REG.exe command path at module load time
using process.platform. When Next.js bundles this module, process.platform
is "" (not "win32") in the webpack/build context, so the lookup returns
undefined and bakes "undefined\REG.exe ..." permanently into the compiled
chunk. At runtime on Windows this causes:

  Error: Command failed: undefined\REG.exe QUERY HKEY_LOCAL_MACHINE\...
  The system cannot find the path specified.

Fix:
Remove the node-machine-id dependency from machineId.ts and replace it
with a direct execSync implementation that resolves process.env.SystemRoot
at call time (not load time), so the correct Windows path is always used
regardless of when or how the module was bundled.

Platform support is preserved for Windows, macOS, and Linux/FreeBSD using
the same underlying OS queries that node-machine-id used internally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 22:01:48 +06:00
diegosouzapw 48754fd999 release: v2.8.7 — Bottleneck 429 drop (PR #495), custom embedding provider fix (#496)
Build Electron Desktop App / Validate version (push) Failing after 33s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
2026-03-20 12:57:08 -03:00
Diego Rodrigues de Sa e Souza c496ebdef9 Merge pull request #495 from xandr0s/fix/429-drop-bottleneck-queue
fix: drop Bottleneck queue on 429 instead of infinite wait
2026-03-20 12:53:31 -03:00
Oleg Saprykin c009c40606 refactor: use .finally() to always delete limiter from Map
Address bot review feedback: use .finally() instead of .then()/.catch()
so limiters.delete() runs regardless of whether stop() succeeds or
throws (e.g. already stopped by concurrent 429).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 18:31:36 +03:00
Oleg Saprykin b29456c8e5 fix: catch stop() already called on concurrent 429s
Multiple concurrent requests can receive 429 simultaneously, causing
stop() to be called on an already-stopped limiter. Add .catch() to
prevent unhandled rejection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 18:27:46 +03:00
diegosouzapw 38266bf2ff release: v2.8.6 — MiniMax role fix (PR #494), KIRO MITM card (#487), triage 8 issues
Build Electron Desktop App / Validate version (push) Failing after 29s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
2026-03-20 12:26:27 -03:00
Diego Rodrigues de Sa e Souza c2e51f8948 Merge pull request #494 from zhangqiang8vip/fix/developer-role-param-error
fix: resolve 422 "role param error" when forwarding OpenAI Responses API to MiniMax (developer → system)
2026-03-20 12:21:57 -03:00
diegosouzapw c54a57838e fix: cleanup PR #494 — remove ZWS_README, fix KIRO MITM card (#487), generify AntigravityToolCard 2026-03-20 12:19:33 -03:00
Oleg Saprykin 64f040bddd fix: drop Bottleneck queue on 429 instead of waiting for reservoir refresh
When a provider returns 429 (rate limit exceeded), the rate limit manager
was setting reservoir=0 and waiting for reservoirRefreshInterval before
releasing queued requests. For providers with long rate limit windows
(e.g. Codex with hours-long resets), this caused all queued requests to
hang indefinitely — they never timed out or returned an error.

This prevented upstream callers (e.g. LiteLLM) from triggering fallback
to alternative providers, effectively making the entire model unavailable
until the rate limit window expired.

Fix: on 429, call limiter.stop({ dropWaitingJobs: true }) to immediately
fail all queued requests, then delete the limiter from the Map so
getLimiter() creates a fresh instance for subsequent requests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 18:07:56 +03:00
zhang-qiang 1a099ea2f2 feat(zws-v2): model compat, provider-models hardening, provider page types
- roleNormalizer/translator: ZWS v2 role handling and comments

- models + schemas: compat overrides, nullable preserveOpenAIDeveloperRole

- provider-models API: generic GET 500; compatOnly validates known provider

- providers [id] page: typed props; minimal saveModelCompatFlags PATCH

Made-with: Cursor
2026-03-20 23:03:52 +08:00
zhang-qiang dfbb9d5fff docs: add ZWS_README_V2 — developer role fix documentation
Made-with: Cursor
2026-03-20 21:47:02 +08:00
zhang-qiang a7fe369ea0 fix: resolve role param error for Responses API + MiniMax (developer→system)
- Add preserveDeveloperRole option and model compat override

- Normalize developer→system in roleNormalizer when not preserving

- Translator runs normalizeRoles for Responses API with option

- UI: ModelCompatPopover with do not preserve developer toggle

- Add ZWS_README_V2 documenting cause and fix

Made-with: Cursor
2026-03-20 21:06:10 +08:00
diegosouzapw b62e6c5a69 release: v2.8.5 — fix zombie SSE, context cache tag, KIRO MITM
Build Electron Desktop App / Validate version (push) Failing after 26s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
Bug Fixes:
- #473: Reduce STREAM_IDLE_TIMEOUT_MS 300s→120s for faster zombie stream fallback
- #474: Fix injectModelTag() to handle first-turn (no assistant messages)
- #481: Change KIRO configType guide→mitm for dashboard MITM controls
- CI: Fix E2E test modal overlay interception

Closed External Issues:
- #468: Gemini CLI remote (superseded by #462 deprecation)
- #438: Claude write files (external CLI issue)
- #439: AppImage (documented libfuse2 workaround)
- #402: ARM64 DMG damaged (documented xattr -cr workaround)
- #460: Windows CLI PATH (documented fix)
2026-03-19 20:29:14 -03:00
diegosouzapw 92e29a6ad7 fix(e2e): dismiss pre-existing modal overlay in providers E2E test
The Bailian Coding Plan provider page may render a dialog on load
that blocks pointer events on the Add API Key button. Add pre-dialog
dismissal (Escape key) before attempting to click.

Also triages #485 (Claude Code tool calls — needs-info).
2026-03-19 20:05:51 -03:00
diegosouzapw 00df10c29a "fix: resolved UI combo setting schema strip (#458)"
"fix: safe crypto fallback for MITM on windows (#456)"
2026-03-18 17:16:30 -03:00
diegosouzapw 41d91d628a feat(search/analytics): add Search tab to analytics dashboard + GET /api/v1/search/analytics
- SearchAnalyticsTab: provider breakdown, cache hit rate, cost summary, KPI cards
- /api/v1/search/analytics: query call_logs (request_type='search') for stats
- analytics/page.tsx: added 'Search' tab alongside Overview and Evals

Closes missing dashboard tracking identified in PR review.
2026-03-17 16:15:28 -03:00
diegosouzapw 605c3f9be1 feat(provider): add Alibaba Cloud DashScope + path validation for custom endpoint paths
- Add Alibaba Cloud (DashScope) as OpenAI-compatible provider with 12 Qwen models:
  qwen-max, qwen-plus, qwen-turbo, qwen3-coder-plus/flash, qwq-plus,
  qwq-32b, qwen3-32b, qwen3-235b-a22b
  International endpoint: dashscope-intl.aliyuncs.com/compatible-mode/v1
  Auth: Bearer API key (same as groq/xai/mistral)

- Add path traversal protection to custom endpoint paths (PR #400):
  sanitizePath() validates chatPath/modelsPath values:
  must start with '/', no '..' segments, no null bytes, max 512 chars

Closes #400 (custom endpoint paths), part of Alibaba provider integration
2026-03-16 09:44:17 -03:00
diegosouzapw 2f0894c220 test: add unit tests for Anthropic-format tools filter fix (PR #397)
8 tests covering:
- Valid OpenAI format tools (tool.function.name) preserved
- Valid Anthropic format tools (tool.name) preserved
- Empty names in both formats filtered
- Mixed format array handling
- Null/whitespace edge cases

Regression tests verify the fix from PR #397 prevents all anthropic-
format tools from being silently dropped by the empty-name filter.
2026-03-16 09:38:34 -03:00
51 changed files with 2939 additions and 461 deletions
+5 -5
View File
@@ -21,18 +21,18 @@ jobs:
IMAGE_NAME: diegosouzapw/omniroute
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
- name: Set up QEMU (for multi-arch builds)
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -50,7 +50,7 @@ jobs:
echo "Publishing Docker image: $IMAGE_NAME:$VERSION"
- name: Build and push multi-arch image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
target: runner-base
@@ -70,7 +70,7 @@ jobs:
docker buildx imagetools inspect "${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}"
- name: Update Docker Hub description
uses: peter-evans/dockerhub-description@v4
uses: peter-evans/dockerhub-description@v5
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+140
View File
@@ -4,6 +4,146 @@
---
## [2.9.0] — 2026-03-20
> Sprint: Cross-platform machineId fix, per-API-key rate limits, streaming context cache, Alibaba DashScope, search analytics, ZWS v5, and 8 issues closed.
### ✨ New Features
- **feat(search)**: Search Analytics tab in `/dashboard/analytics` — provider breakdown, cache hit rate, cost tracking. New API: `GET /api/v1/search/analytics` (#feat/search-provider-routing)
- **feat(provider)**: Alibaba Cloud DashScope added with custom endpoint path validation — configurable `chatPath` and `modelsPath` per node (#feat/custom-endpoint-paths)
- **feat(api)**: Per-API-key request-count limits — `max_requests_per_day` and `max_requests_per_minute` columns with in-memory sliding-window enforcement returning HTTP 429 (#452)
- **feat(dev)**: ZWS v5 — HMR leak fix (485 DB connections → 1), memory 2.4GB → 195MB, `globalThis` singletons, Edge Runtime warning fix (@zhangqiang8vip)
### 🐛 Bug Fixes
- **fix(#506)**: Cross-platform `machineId``getMachineIdRaw()` rewritten with try/catch waterfall (Windows REG.exe → macOS ioreg → Linux file read → hostname → `os.hostname()`). Eliminates `process.platform` branching that Next.js bundler dead-code-eliminated, fixing `'head' is not recognized` on Windows. Also fixes #466.
- **fix(#493)**: Custom provider model naming — removed incorrect prefix stripping in `DefaultExecutor.transformRequest()` that mangled org-scoped model IDs like `zai-org/GLM-5-FP8`.
- **fix(#490)**: Streaming + context cache protection — `TransformStream` intercepts SSE to inject `<omniModel>` tag before `[DONE]` marker, enabling context cache protection for streaming responses.
- **fix(#458)**: Combo schema validation — `system_message`, `tool_filter_regex`, `context_cache_protection` fields now pass Zod validation on save.
- **fix(#487)**: KIRO MITM card cleanup — removed ZWS_README, generified `AntigravityToolCard` to use dynamic tool metadata.
### 🧪 Tests
- Added Anthropic-format tools filter unit tests (PR #397) — 8 regression tests for `tool.name` without `.function` wrapper
- Test suite: **821 tests, 0 failures** (up from 813)
### 📋 Issues Closed (8)
- **#506** — Windows machineId `head` not recognized (fixed)
- **#493** — Custom provider model naming (fixed)
- **#490** — Streaming context cache (fixed)
- **#452** — Per-API-key request limits (implemented)
- **#466** — Windows login failure (same root cause as #506)
- **#504** — MITM inactive (expected behavior)
- **#462** — Gemini CLI PSA (resolved)
- **#434** — Electron app crash (duplicate of #402)
## [2.8.9] — 2026-03-20
> Sprint: Merge community PRs, fix KIRO MITM card, dependency updates.
### Merged PRs
- **PR #498** (@Sajid11194): Fix Windows machine ID crash (`undefined\REG.exe`). Replaces `node-machine-id` with native OS registry queries. **Closes #486.**
- **PR #497** (@zhangqiang8vip): Fix dev-mode HMR resource leaks — 485 leaked DB connections → 1, memory 2.4GB → 195MB. `globalThis` singletons, Edge Runtime warning fix, Windows test stability. (+1168/-338 across 22 files)
- **PRs #499-503** (Dependabot): GitHub Actions updates — `docker/build-push-action@7`, `actions/checkout@6`, `peter-evans/dockerhub-description@5`, `docker/setup-qemu-action@4`, `docker/login-action@4`.
### Bug Fixes
- **#505** — KIRO MITM card now displays tool-specific instructions (`api.anthropic.com`) instead of Antigravity-specific text.
- **#504** — Responded with UX clarification (MITM "Inactive" is expected behavior when proxy is not running).
---
## [2.8.8] — 2026-03-20
> Sprint: Fix OAuth batch test crash, add "Test All" button to individual provider pages.
### Bug Fixes
- **OAuth batch test crash** (ERR_CONNECTION_REFUSED): Replaced sequential for-loop with 5-connection concurrency limit + 30s per-connection timeout via `Promise.race()` + `Promise.allSettled()`. Prevents server crash when testing large OAuth provider groups (~30+ connections).
### Features
- **"Test All" button on provider pages**: Individual provider pages (e.g., `/providers/codex`) now show a "Test All" button in the Connections header when there are 2+ connections. Uses `POST /api/providers/test-batch` with `{mode: "provider", providerId}`. Results displayed in a modal with pass/fail summary and per-connection diagnosis.
---
## [2.8.7] — 2026-03-20
> Sprint: Merge PR #495 (Bottleneck 429 drop), fix #496 (custom embedding providers), triage features.
### Bug Fixes
- **Bottleneck 429 infinite wait** (PR #495 by @xandr0s): On 429, `limiter.stop({ dropWaitingJobs: true })` immediately fails all queued requests so upstream callers can trigger fallback. Limiter is deleted from Map so next request creates a fresh instance.
- **Custom embedding models unresolvable** (#496): `POST /v1/embeddings` now resolves custom embedding models from ALL provider_nodes (not just localhost). Enables models like `google/gemini-embedding-001` added via dashboard.
### Issues Responded
- **#452** — Per-API-key request-count limits (acknowledged, on roadmap)
- **#464** — Auto-issue API keys with provider/account limits (needs more detail)
- **#488** — Auto-update model lists (acknowledged, on roadmap)
- **#496** — Custom embedding provider resolution (fixed)
---
## [2.8.6] — 2026-03-20
> Sprint: Merge PR #494 (MiniMax role fix), fix KIRO MITM dashboard, triage 8 issues.
### Features
- **MiniMax developer→system role fix** (PR #494 by @zhangqiang8vip): Per-model `preserveDeveloperRole` toggle. Adds "Compatibility" UI in providers page. Fixes 422 "role param error" for MiniMax and similar gateways.
- **roleNormalizer**: `normalizeDeveloperRole()` now accepts `preserveDeveloperRole` parameter with tri-state behavior (undefined=keep, true=keep, false=convert).
- **DB**: New `getModelPreserveOpenAIDeveloperRole()` and `mergeModelCompatOverride()` in `models.ts`.
### Bug Fixes
- **KIRO MITM dashboard** (#481/#487): `CLIToolsPageClient` now routes any `configType: "mitm"` tool to `AntigravityToolCard` (MITM Start/Stop controls). Previously only Antigravity was hardcoded.
- **AntigravityToolCard generic**: Uses `tool.image`, `tool.description`, `tool.id` instead of hardcoded Antigravity values. Guards against missing `defaultModels`.
### Cleanup
- Removed `ZWS_README_V2.md` (development-only docs from PR #494).
### Issues Triaged (8)
- **#487** — Closed (KIRO MITM fixed in this release)
- **#486** — needs-info (Windows REG.exe PATH issue)
- **#489** — needs-info (Antigravity projectId missing, OAuth reconnect needed)
- **#492** — needs-info (missing app/server.js on mise-managed Node)
- **#490** — Acknowledged (streaming + context cache blocking, fix planned)
- **#491** — Acknowledged (Codex auth state inconsistency)
- **#493** — Acknowledged (Modal provider model name prefix, workaround provided)
- **#488** — Feature request backlog (auto-update model lists)
---
## [2.8.5] — 2026-03-19
> Sprint: Fix zombie SSE streams, context cache first-turn, KIRO MITM, and triage 5 external issues.
### Bug Fixes
- **Zombie SSE Streams** (#473): Reduce `STREAM_IDLE_TIMEOUT_MS` from 300s → 120s for faster combo fallback when providers hang mid-stream. Configurable via env var.
- **Context Cache Tag** (#474): Fix `injectModelTag()` to handle first-turn requests (no assistant messages) — context cache protection now works from the very first response.
- **KIRO MITM** (#481): Change KIRO `configType` from `guide``mitm` so the dashboard renders MITM Start/Stop controls.
- **E2E Test** (CI): Fix `providers-bailian-coding-plan.spec.ts` — dismiss pre-existing modal overlay before clicking Add API Key button.
### Closed Issues
- #473 — Zombie SSE streams bypass combo fallback
- #474 — Context cache `<omniModel>` tag missing on first turn
- #481 — MITM for KIRO not activatable from dashboard
- #468 — Gemini CLI remote server (superseded by #462 deprecation)
- #438 — Claude unable to write files (external CLI issue)
- #439 — AppImage doesn't work (documented libfuse2 workaround)
- #402 — ARM64 DMG "damaged" (documented xattr -cr workaround)
- #460 — CLI not runnable on Windows (documented PATH fix)
---
## [2.8.4] — 2026-03-19
> Sprint: Gemini CLI deprecation, VM guide i18n fix, dependabot security fix, provider schema expansion.
+374
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 秒。
---
## 二、根因分析
### 根因 1(P0):模块级单例在 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` 中声明为服务端外部包。
### 根因 4P1):Edge 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()` 调用(警告会重新出现但不影响功能)。
- **生产环境**:以上修复对生产构建无负面影响——生产环境不存在 HMR,globalThis 单例仅在首次调用时初始化一次。计算 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` 重试删除 |
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 2.8.4
version: 2.9.0
description: |
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
endpoint that routes requests to multiple AI providers with load balancing,
+3 -3
View File
@@ -4,9 +4,9 @@ import { loadProviderCredentials } from "./credentialLoader.ts";
export const FETCH_TIMEOUT_MS = parseInt(process.env.FETCH_TIMEOUT_MS || "120000", 10);
// Idle timeout for SSE streams (ms). Closes stream if no data for this duration.
// Default: 300s to support extended-thinking models (claude-opus-4-6, o3, etc.)
// that may pause for >60s during deep reasoning phases. Override with STREAM_IDLE_TIMEOUT_MS env var.
export const STREAM_IDLE_TIMEOUT_MS = parseInt(process.env.STREAM_IDLE_TIMEOUT_MS || "300000", 10);
// Default: 120s balances deep-reasoning pauses with fast zombie stream detection (#473).
// Extended-thinking models rarely pause >90s between chunks. Override with STREAM_IDLE_TIMEOUT_MS env var.
export const STREAM_IDLE_TIMEOUT_MS = parseInt(process.env.STREAM_IDLE_TIMEOUT_MS || "120000", 10);
// Provider configurations
// OAuth credentials read from env vars with hardcoded fallbacks for backward compatibility.
+29
View File
@@ -1125,6 +1125,35 @@ export const REGISTRY: Record<string, RegistryEntry> = {
{ id: "claude-sonnet-4-5@20251101", name: "Claude Sonnet 4.5 (Vertex)" },
],
},
alibaba: {
id: "alibaba",
alias: "ali",
format: "openai",
executor: "default",
// DashScope international OpenAI-compatible endpoint.
// China users should set providerSpecificData.baseUrl to:
// https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions
baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
modelsUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "qwen-max", name: "Qwen Max" },
{ id: "qwen-max-2025-01-25", name: "Qwen Max (2025-01-25)" },
{ id: "qwen-plus", name: "Qwen Plus" },
{ id: "qwen-plus-2025-07-14", name: "Qwen Plus (2025-07-14)" },
{ id: "qwen-turbo", name: "Qwen Turbo" },
{ id: "qwen-turbo-2025-11-01", name: "Qwen Turbo (2025-11-01)" },
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "qwen3-coder-flash", name: "Qwen3 Coder Flash" },
{ id: "qwq-plus", name: "QwQ Plus (Reasoning)" },
{ id: "qwq-32b", name: "QwQ 32B" },
{ id: "qwen3-32b", name: "Qwen3 32B" },
{ id: "qwen3-235b-a22b", name: "Qwen3 235B A22B" },
],
passthroughModels: true,
},
};
// ── Generator Functions ───────────────────────────────────────────────────
+17 -1
View File
@@ -2,6 +2,20 @@ import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts";
import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts";
import { getRotatingApiKey } from "../services/apiKeyRotator.ts";
/**
* Sanitizes a custom API path to prevent path traversal attacks.
* Valid paths must start with '/', contain no '..' segments,
* no null bytes, and be reasonable in length.
*/
function sanitizePath(path: string): boolean {
if (typeof path !== "string") return false;
if (!path.startsWith("/")) return false;
if (path.includes("\0")) return false; // null byte
if (path.includes("..")) return false; // path traversal
if (path.length > 512) return false; // sanity limit
return true;
}
type JsonRecord = Record<string, unknown>;
export type ProviderConfig = {
@@ -103,7 +117,9 @@ export class BaseExecutor {
const psd = credentials?.providerSpecificData;
const baseUrl = typeof psd?.baseUrl === "string" ? psd.baseUrl : "https://api.openai.com/v1";
const normalized = baseUrl.replace(/\/$/, "");
const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null;
// Sanitize custom path: must start with '/', no path traversal, no null bytes
const rawPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null;
const customPath = rawPath && sanitizePath(rawPath) ? rawPath : null;
if (customPath) return `${normalized}${customPath}`;
const path = this.provider.includes("responses") ? "/responses" : "/chat/completions";
return `${normalized}${path}`;
+6 -10
View File
@@ -80,18 +80,14 @@ export class DefaultExecutor extends BaseExecutor {
}
/**
* For compatible providers, ensure the model name sent upstream
* is the clean model name without internal routing prefixes.
* e.g. "openapi-chat-anti/claude-opus-4-6-thinking" → "claude-opus-4-6-thinking"
* For compatible providers, the model name is already clean by the time
* it reaches the executor (chatCore sets body.model = modelInfo.model,
* which is the parsed model ID without any internal routing prefix).
*
* Models may legitimately contain "/" as part of their ID (e.g. "zai-org/GLM-5-FP8",
* "org/model-name") — we must NOT strip path segments. (Fix #493)
*/
transformRequest(model, body, stream, credentials) {
if (
this.provider?.startsWith?.("openai-compatible-") ||
this.provider?.startsWith?.("anthropic-compatible-")
) {
const cleanModel = model.includes("/") ? model.split("/").slice(1).join("/") : model;
return { ...body, model: cleanModel };
}
return body;
}
+6 -2
View File
@@ -23,7 +23,7 @@ import {
appendRequestLog,
saveCallLog,
} from "@/lib/usageDb";
import { getModelNormalizeToolCallId } from "@/lib/db/models";
import { getModelNormalizeToolCallId, getModelPreserveOpenAIDeveloperRole } from "@/lib/localDb";
import { getExecutor } from "../executors/index.ts";
import { translateNonStreamingResponse } from "./responseTranslator.ts";
import { extractUsageFromResponse } from "./usageExtractor.ts";
@@ -318,6 +318,10 @@ export async function handleChatCore({
}
const normalizeToolCallId = getModelNormalizeToolCallId(provider || "", model || "");
const preserveDeveloperRole = getModelPreserveOpenAIDeveloperRole(
provider || "",
model || ""
);
translatedBody = translateRequest(
sourceFormat,
targetFormat,
@@ -327,7 +331,7 @@ export async function handleChatCore({
credentials,
provider,
reqLogger,
{ normalizeToolCallId }
{ normalizeToolCallId, preserveDeveloperRole }
);
}
} catch (error) {
+70 -4
View File
@@ -447,8 +447,10 @@ export async function handleComboChat({
const handleSingleModelWrapped = combo.context_cache_protection
? async (b, modelStr) => {
const res = await handleSingleModel(b, modelStr);
// Inject tag only on success and only for non-streaming non-binary responses
if (res.ok && !b.stream) {
if (!res.ok) return res;
// Non-streaming: inject tag into JSON response (existing logic)
if (!b.stream) {
try {
const json = await res.clone().json();
const msgs = Array.isArray(json?.messages) ? json.messages : [];
@@ -460,10 +462,74 @@ export async function handleComboChat({
});
}
} catch {
/* non-JSON or stream — skip tagging */
/* non-JSON — skip tagging */
}
return res;
}
return res;
// Streaming (Fix #490): append omniModel tag as a final SSE content delta
// before the [DONE] marker using TransformStream for zero-copy passthrough
if (!res.body) return res;
const tagContent = `\n<omniModel>${modelStr}</omniModel>`;
const encoder = new TextEncoder();
const decoder = new TextDecoder();
let buffer = "";
const transform = new TransformStream({
transform(chunk, controller) {
// Decode chunk and check for [DONE] marker
const text = decoder.decode(chunk, { stream: true });
buffer += text;
// Check if buffer contains the [DONE] marker
const doneIdx = buffer.indexOf("data: [DONE]");
if (doneIdx === -1) {
// No [DONE] yet — flush buffer as-is (keep passthrough latency low)
controller.enqueue(encoder.encode(buffer));
buffer = "";
return;
}
// Found [DONE] — inject tag content delta before it
const beforeDone = buffer.slice(0, doneIdx);
const afterDone = buffer.slice(doneIdx);
// Build a synthetic SSE content delta chunk with the tag
const tagChunk = `data: ${JSON.stringify({
choices: [
{
delta: { content: tagContent },
index: 0,
finish_reason: null,
},
],
})}\n\n`;
controller.enqueue(encoder.encode(beforeDone + tagChunk + afterDone));
buffer = "";
},
flush(controller) {
// If stream ends without [DONE], flush remaining buffer + tag
if (buffer.length > 0) {
const tagChunk = `data: ${JSON.stringify({
choices: [
{
delta: { content: tagContent },
index: 0,
finish_reason: null,
},
],
})}\n\n`;
controller.enqueue(encoder.encode(buffer + tagChunk));
}
},
});
const transformedStream = res.body.pipeThrough(transform);
return new Response(transformedStream, {
status: res.status,
headers: res.headers,
});
}
: handleSingleModel;
// ─────────────────────────────────────────────────────────────────────────
+9 -1
View File
@@ -52,7 +52,15 @@ export function injectModelTag(messages: Message[], providerModel: string): Mess
// Find last assistant message with string content
const lastAssistantIdx = cleaned.map((m) => m.role).lastIndexOf("assistant");
if (lastAssistantIdx === -1) return cleaned;
// #474: If no assistant message exists yet (first turn), append a synthetic one
// so the tag is present when the client sends the next request with the response.
if (lastAssistantIdx === -1) {
return [
...cleaned,
{ role: "assistant", content: `\n<omniModel>${providerModel}</omniModel>` },
];
}
const msg = cleaned[lastAssistantIdx];
if (typeof msg.content !== "string") return cleaned;
+10 -5
View File
@@ -339,14 +339,19 @@ export function updateFromHeaders(provider, connectionId, headers, status, model
// Handle 429 — rate limited
if (status === 429) {
const retryAfterMs = parseResetTime(retryAfterStr) || 60000; // Default 60s
const counts = limiter.counts();
const limiterKey = `${provider}:${connectionId}`;
console.log(
`🚫 [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — 429 received, pausing for ${Math.ceil(retryAfterMs / 1000)}s`
`🚫 [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — 429 received, pausing for ${Math.ceil(retryAfterMs / 1000)}s, dropping ${counts.QUEUED} queued request(s)`
);
limiter.updateSettings({
reservoir: 0,
reservoirRefreshAmount: limit || 60,
reservoirRefreshInterval: retryAfterMs,
// Stop the limiter and drop all waiting jobs so they fail immediately
// instead of hanging in the queue until reservoir refreshes (which can
// be hours for providers like Codex with long rate limit windows).
// This lets upstream callers (e.g. LiteLLM) trigger fallback to other providers.
// After stop, delete from Map so getLimiter() creates a fresh instance.
limiter.stop({ dropWaitingJobs: true }).finally(() => {
limiters.delete(limiterKey);
});
return;
}
+28 -19
View File
@@ -76,26 +76,35 @@ function supportsSystemRole(provider: string, model: string): boolean {
}
/**
* Normalize the `developer` role to `system` for non-OpenAI providers.
* OpenAI introduced `developer` as a replacement for `system` in newer models,
* but most other providers still expect `system`.
* Normalize the `developer` role to `system` when the upstream does not support it.
* 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")
* @returns Modified messages array
* @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,
targetFormat: string
targetFormat: string,
preserveDeveloperRole?: boolean
): NormalizedMessage[] | unknown {
if (!Array.isArray(messages)) return messages;
// For OpenAI format, keep developer role as-is (it's valid)
// For all other formats, convert developer → system
if (targetFormat === "openai") return messages;
if (targetFormat === "openai" && preserveDeveloperRole !== false) return messages;
return messages.map((msg: NormalizedMessage) => {
if (msg.role === "developer") {
if (!msg || typeof msg !== "object") return msg;
const role = typeof msg.role === "string" ? msg.role : "";
if (role.toLowerCase() === "developer") {
return { ...msg, role: "system" };
}
return msg;
@@ -169,25 +178,25 @@ export function normalizeSystemRole(
/**
* Full role normalization pipeline.
* Call this before sending the request to the provider.
* Applies developersystem (when needed) then systemuser for providers/models that do not support system role.
*
* @param messages - Array of messages
* @param provider - Provider name/id
* @param model - Model name
* @param targetFormat - Target API format
* @returns Normalized messages array
* @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,
provider: string,
model: string,
targetFormat: string
targetFormat: string,
preserveDeveloperRole?: boolean
): NormalizedMessage[] | unknown {
if (!Array.isArray(messages)) return messages;
// Step 1: Normalize developer → system (for non-OpenAI formats)
let result = normalizeDeveloperRole(messages, targetFormat);
// Step 2: Normalize system → user (for providers that don't support system role)
let result = normalizeDeveloperRole(messages, targetFormat, preserveDeveloperRole);
result = normalizeSystemRole(result, provider, model);
return result;
+31 -3
View File
@@ -67,6 +67,7 @@ function normalizeOpenAIResponsesRequest(body) {
}
/** @param options.normalizeToolCallId - When true, use 9-char tool call ids (e.g. Mistral); when false, leave ids as-is */
/** @param options.preserveDeveloperRole - undefined/true: keep developer for OpenAI format (default); false: map to system */
// Translate request: source -> openai -> target
export function translateRequest(
sourceFormat,
@@ -77,10 +78,11 @@ export function translateRequest(
credentials = null,
provider = null,
reqLogger = null,
options?: { normalizeToolCallId?: boolean }
options?: { normalizeToolCallId?: boolean; preserveDeveloperRole?: boolean }
) {
let result = body;
const use9CharId = options?.normalizeToolCallId === true;
const preserveDeveloperRole = options?.preserveDeveloperRole;
// Phase 2: Apply thinking budget control before normalization
result = applyThinkingBudget(result);
@@ -94,9 +96,17 @@ export function translateRequest(
// Fix missing tool responses (insert empty tool_result if needed)
fixMissingToolResponses(result);
// Normalize roles: developer→system for non-OpenAI, 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, provider || "", model || "", targetFormat);
result.messages = normalizeRoles(
result.messages,
provider || "",
model || "",
targetFormat,
preserveDeveloperRole
);
}
// If same format, skip translation steps
@@ -143,6 +153,24 @@ export function translateRequest(
result = normalizeOpenAIResponsesRequest(result);
}
// 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 &&
Array.isArray(result.messages)
) {
result.messages = normalizeRoles(
result.messages,
provider || "",
model || "",
targetFormat,
preserveDeveloperRole
);
}
// Ensure unique tool_call ids on final payload (translators may have introduced duplicates)
ensureToolCallIds(result, { use9CharId });
fixMissingToolResponses(result);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "2.8.4",
"version": "2.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "2.8.4",
"version": "2.9.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "2.8.4",
"version": "2.9.0",
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
"type": "module",
"bin": {
+2 -1
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");
}
@@ -0,0 +1,196 @@
/**
* Search Analytics Tab
*
* Shows search request stats from call_logs (request_type = 'search'),
* provider breakdown, cache hit rate, and cost summary.
*/
"use client";
import { useEffect, useState } from "react";
interface SearchStats {
total: number;
today: number;
cached: number;
errors: number;
totalCostUsd: number;
byProvider: Record<string, { count: number; costUsd: number }>;
last24h: Array<{ hour: string; count: number }>;
cacheHitRate: number;
avgDurationMs: number;
}
function StatCard({
icon,
label,
value,
sub,
}: {
icon: string;
label: string;
value: string | number;
sub?: string;
}) {
return (
<div className="card p-4 flex flex-col gap-1">
<div className="flex items-center gap-2 text-text-muted text-sm">
<span className="material-symbols-outlined text-[18px]">{icon}</span>
{label}
</div>
<div className="text-2xl font-bold text-text">{value}</div>
{sub && <div className="text-xs text-text-muted">{sub}</div>}
</div>
);
}
function ProviderBar({
provider,
count,
total,
costUsd,
}: {
provider: string;
count: number;
total: number;
costUsd: number;
}) {
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
return (
<div className="flex flex-col gap-1">
<div className="flex justify-between text-sm">
<span className="font-medium text-text">{provider}</span>
<span className="text-text-muted">
{count} queries · ${costUsd.toFixed(4)}
</span>
</div>
<div className="h-2 rounded-full bg-bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${pct}%` }}
/>
</div>
<div className="text-xs text-text-muted text-right">{pct}%</div>
</div>
);
}
export default function SearchAnalyticsTab() {
const [stats, setStats] = useState<SearchStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/v1/search/analytics")
.then((r) => r.json())
.then((d) => {
setStats(d);
setLoading(false);
})
.catch((e) => {
setError(e.message);
setLoading(false);
});
}, []);
if (loading) {
return (
<div className="flex items-center justify-center py-16 text-text-muted">
<span className="material-symbols-outlined animate-spin mr-2">progress_activity</span>
Loading search analytics
</div>
);
}
if (error || !stats) {
return (
<div className="card p-6 text-center text-text-muted">
<span className="material-symbols-outlined text-[32px] mb-2 block">search_off</span>
{error || "No search data available yet."}
<p className="text-xs mt-2">
Search requests will appear here after the first search via /v1/search.
</p>
</div>
);
}
const providers = Object.entries(stats.byProvider).sort(([, a], [, b]) => b.count - a.count);
return (
<div className="flex flex-col gap-6">
{/* KPI Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
icon="manage_search"
label="Total Searches"
value={stats.total.toLocaleString()}
sub={`${stats.today} today`}
/>
<StatCard
icon="cached"
label="Cache Hit Rate"
value={`${stats.cacheHitRate}%`}
sub={`${stats.cached} cached requests`}
/>
<StatCard
icon="attach_money"
label="Total Cost"
value={`$${stats.totalCostUsd.toFixed(4)}`}
sub="search API costs"
/>
<StatCard
icon="timer"
label="Avg Response"
value={`${stats.avgDurationMs}ms`}
sub={stats.errors > 0 ? `${stats.errors} errors` : "No errors"}
/>
</div>
{/* Provider Breakdown */}
{providers.length > 0 && (
<div className="card p-5">
<h3 className="font-semibold text-text mb-4 flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[20px]">hub</span>
Provider Breakdown
</h3>
<div className="flex flex-col gap-4">
{providers.map(([prov, data]) => (
<ProviderBar
key={prov}
provider={prov}
count={data.count}
total={stats.total}
costUsd={data.costUsd}
/>
))}
</div>
</div>
)}
{/* Empty state */}
{stats.total === 0 && (
<div className="card p-8 text-center text-text-muted">
<span className="material-symbols-outlined text-[48px] mb-3 block text-primary opacity-50">
travel_explore
</span>
<p className="font-medium text-text">No searches yet</p>
<p className="text-sm mt-1">
Use <code className="bg-bg-muted px-1 rounded">POST /v1/search</code> to start routing
web searches.
</p>
</div>
)}
{/* Free tier note */}
<div className="text-xs text-text-muted border border-border rounded-lg p-3 flex items-start gap-2">
<span className="material-symbols-outlined text-[16px] text-green-500 mt-0.5">
check_circle
</span>
<span>
<strong>Free tier available:</strong> Serper (2,500/mo), Brave (2,000/mo), Exa (1,000/mo),
Tavily (1,000/mo) total 6,500+ free searches/month with automatic failover.
</span>
</div>
</div>
);
}
@@ -3,15 +3,17 @@
import { useState, Suspense } from "react";
import { UsageAnalytics, CardSkeleton, SegmentedControl } from "@/shared/components";
import EvalsTab from "../usage/components/EvalsTab";
import SearchAnalyticsTab from "./SearchAnalyticsTab";
import { useTranslations } from "next-intl";
export default function AnalyticsPage() {
const [activeTab, setActiveTab] = useState("overview");
const t = useTranslations("analytics");
const tabDescriptions = {
const tabDescriptions: Record<string, string> = {
overview: t("overviewDescription"),
evals: t("evalsDescription"),
search: "Search request analytics — provider breakdown, cache hit rate, and cost tracking.",
};
return (
@@ -29,6 +31,7 @@ export default function AnalyticsPage() {
options={[
{ value: "overview", label: t("overview") },
{ value: "evals", label: t("evals") },
{ value: "search", label: "Search" },
]}
value={activeTab}
onChange={setActiveTab}
@@ -40,6 +43,7 @@ export default function AnalyticsPage() {
</Suspense>
)}
{activeTab === "evals" && <EvalsTab />}
{activeTab === "search" && <SearchAnalyticsTab />}
</div>
);
}
@@ -267,6 +267,18 @@ export default function CLIToolsPageClient({ machineId }) {
/>
);
default:
// #487: Any tool with configType "mitm" should use the MITM card (Start/Stop controls)
if (tool.configType === "mitm") {
return (
<AntigravityToolCard
key={toolId}
{...commonProps}
activeProviders={getActiveProviders()}
hasActiveProviders={hasActiveProviders}
cloudEnabled={cloudEnabled}
/>
);
}
return (
<DefaultToolCard
key={toolId}
@@ -41,7 +41,7 @@ export default function AntigravityToolCard({
const loadSavedMappings = async () => {
try {
const res = await fetch("/api/cli-tools/antigravity-mitm/alias?tool=antigravity");
const res = await fetch(`/api/cli-tools/antigravity-mitm/alias?tool=${tool.id}`);
if (res.ok) {
const data = await res.json();
const aliases = data.aliases || {};
@@ -187,7 +187,7 @@ export default function AntigravityToolCard({
const res = await fetch("/api/cli-tools/antigravity-mitm/alias", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tool: "antigravity", mappings: modelMappings }),
body: JSON.stringify({ tool: tool.id, mappings: modelMappings }),
});
if (!res.ok) {
@@ -211,7 +211,7 @@ export default function AntigravityToolCard({
<div className="flex items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image
src="/providers/antigravity.png"
src={tool.image || "/providers/antigravity.png"}
alt={tool.name}
width={32}
height={32}
@@ -235,7 +235,7 @@ export default function AntigravityToolCard({
</Badge>
)}
</div>
<p className="text-xs text-text-muted truncate">{t("toolDescriptions.antigravity")}</p>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span
@@ -306,7 +306,7 @@ export default function AntigravityToolCard({
)}
</div>
{tool.defaultModels.map((model) => (
{(tool.defaultModels || []).map((model) => (
<div key={model.alias} className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
{model.name}
@@ -355,25 +355,33 @@ export default function AntigravityToolCard({
)}
{/* When stopped: how it works */}
{!isRunning && (
<div className="flex flex-col gap-1.5 px-1">
<p className="text-xs text-text-muted">
<span className="font-medium text-text-main">{t("howItWorks")}</span>{" "}
{t("antigravityHowWorksDesc")}
</p>
<div className="flex flex-col gap-0.5 text-[11px] text-text-muted">
<span>{t("antigravityStep1")}</span>
<span>
{t("antigravityStep2Prefix")}{" "}
<code className="text-[10px] bg-surface px-1 rounded">
daily-cloudcode-pa.googleapis.com
</code>{" "}
{t("antigravityStep2Suffix")}
</span>
<span>{t("antigravityStep3")}</span>
</div>
</div>
)}
{!isRunning &&
(() => {
// Dynamic MITM instructions per tool (#505)
const mitmDomains: Record<string, string> = {
antigravity: "daily-cloudcode-pa.googleapis.com",
kiro: "api.anthropic.com",
};
const toolName = tool.name || tool.id;
const domain = mitmDomains[tool.id] || mitmDomains.antigravity;
return (
<div className="flex flex-col gap-1.5 px-1">
<p className="text-xs text-text-muted">
<span className="font-medium text-text-main">{t("howItWorks")}</span>{" "}
{t("mitmHowWorksDesc", { toolName })}
</p>
<div className="flex flex-col gap-0.5 text-[11px] text-text-muted">
<span>{t("mitmStep1")}</span>
<span>
{t("mitmStep2Prefix")}{" "}
<code className="text-[10px] bg-surface px-1 rounded">{domain}</code>{" "}
{t("mitmStep2Suffix")}
</span>
<span>{t("mitmStep3", { toolName })}</span>
</div>
</div>
);
})()}
</div>
)}
File diff suppressed because it is too large Load Diff
+70 -8
View File
@@ -4,7 +4,14 @@ import {
addCustomModel,
removeCustomModel,
updateCustomModel,
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";
@@ -27,11 +34,12 @@ export async function GET(request) {
const provider = searchParams.get("provider");
const models = provider ? await getCustomModels(provider) : await getAllCustomModels();
const modelCompatOverrides = provider ? getModelCompatOverrides(provider) : [];
return Response.json({ models });
} catch (error) {
return Response.json({ models, modelCompatOverrides });
} catch {
return Response.json(
{ error: { message: error.message, type: "server_error" } },
{ error: { message: "Failed to fetch provider models", type: "server_error" } },
{ status: 500 }
);
}
@@ -113,17 +121,71 @@ export async function PUT(request) {
return Response.json({ error: validation.error }, { status: 400 });
}
const { provider, modelId, modelName, apiFormat, supportedEndpoints, normalizeToolCallId } =
validation.data;
const model = await updateCustomModel(provider, modelId, {
const {
provider,
modelId,
modelName,
apiFormat,
supportedEndpoints,
normalizeToolCallId,
});
preserveOpenAIDeveloperRole,
} = validation.data;
const raw = rawBody as Record<string, unknown>;
const updates: Record<string, unknown> = {};
if ("modelName" in raw) updates.modelName = modelName;
if ("apiFormat" in raw) updates.apiFormat = apiFormat;
if ("supportedEndpoints" in raw) updates.supportedEndpoints = supportedEndpoints;
if ("normalizeToolCallId" in raw) updates.normalizeToolCallId = normalizeToolCallId;
if ("preserveOpenAIDeveloperRole" in raw)
updates.preserveOpenAIDeveloperRole = preserveOpenAIDeveloperRole;
const model = await updateCustomModel(provider, modelId, updates);
if (!model) {
const rawKeys = Object.keys(raw);
const compatOnly =
rawKeys.length > 0 &&
rawKeys.every((k) =>
["provider", "modelId", "normalizeToolCallId", "preserveOpenAIDeveloperRole"].includes(k)
) &&
("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 | null;
} = {};
if ("normalizeToolCallId" in raw && typeof normalizeToolCallId === "boolean") {
patch.normalizeToolCallId = normalizeToolCallId;
}
if ("preserveOpenAIDeveloperRole" in raw) {
patch.preserveOpenAIDeveloperRole =
preserveOpenAIDeveloperRole === null || typeof preserveOpenAIDeveloperRole === "boolean"
? preserveOpenAIDeveloperRole
: undefined;
}
if (Object.keys(patch).length > 0) {
mergeModelCompatOverride(provider, modelId, patch);
}
return Response.json({
ok: true,
modelCompatOverrides: getModelCompatOverrides(provider),
});
}
return Response.json(
{ error: { message: "Model not found", type: "not_found" } },
{ status: 404 }
+48 -8
View File
@@ -90,13 +90,23 @@ export async function POST(request) {
});
}
// Test each connection sequentially via direct function call (no HTTP self-call)
const results = [];
// Test each connection with timeout and concurrency limits (prevents server crash on large groups)
const PER_CONNECTION_TIMEOUT = 30_000; // 30s per connection
const CONCURRENCY = 5; // max parallel tests
for (const conn of connectionsToTest) {
const testOne = async (conn) => {
try {
const data = await testSingleConnection(conn.id);
results.push({
const result = await Promise.race([
testSingleConnection(conn.id),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error("Connection test timed out after 30s")),
PER_CONNECTION_TIMEOUT
)
),
]);
const data = result as any;
return {
provider: conn.provider,
connectionId: conn.id,
connectionName: conn.name || conn.email || conn.provider,
@@ -107,9 +117,9 @@ export async function POST(request) {
diagnosis: data.diagnosis || null,
statusCode: data.statusCode || null,
testedAt: data.testedAt || new Date().toISOString(),
});
};
} catch (error) {
results.push({
return {
provider: conn.provider,
connectionId: conn.id,
connectionName: conn.name || conn.email || conn.provider,
@@ -120,7 +130,37 @@ export async function POST(request) {
diagnosis: { type: "network_error", source: "local", code: null, message: error.message },
statusCode: null,
testedAt: new Date().toISOString(),
});
};
}
};
// Execute with concurrency limit
const results = [];
for (let i = 0; i < connectionsToTest.length; i += CONCURRENCY) {
const batch = connectionsToTest.slice(i, i + CONCURRENCY);
const batchResults = await Promise.allSettled(batch.map(testOne));
for (const r of batchResults) {
results.push(
r.status === "fulfilled"
? r.value
: {
provider: "unknown",
connectionId: "unknown",
connectionName: "unknown",
authType: "unknown",
valid: false,
latencyMs: 0,
error: r.reason?.message || "Test failed",
diagnosis: {
type: "network_error",
source: "local",
code: null,
message: r.reason?.message || "Test failed",
},
statusCode: null,
testedAt: new Date().toISOString(),
}
);
}
}
+33 -3
View File
@@ -12,6 +12,7 @@ import {
getEmbeddingProvider,
buildDynamicEmbeddingProvider,
type EmbeddingProviderNodeRow,
type EmbeddingProvider,
} from "@omniroute/open-sse/config/embeddingRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
@@ -116,9 +117,9 @@ export async function POST(request) {
// Load local provider_nodes for embedding routing (only localhost — prevents auth bypass/SSRF)
let dynamicProviders: ReturnType<typeof buildDynamicEmbeddingProvider>[] = [];
try {
const nodes = await getProviderNodes();
const nodes = (await getProviderNodes()) as unknown as EmbeddingProviderNodeRow[];
dynamicProviders = (Array.isArray(nodes) ? nodes : [])
.filter((n: EmbeddingProviderNodeRow) => {
.filter((n) => {
// provider_nodes apiType is "chat" or "responses" (not "embeddings") — local OpenAI-compatible
// backends expose /embeddings under the same base URL as chat, so we build the URL as baseUrl + /embeddings.
if (n.apiType !== "chat" && n.apiType !== "responses") return false;
@@ -157,9 +158,38 @@ export async function POST(request) {
}
// Resolve provider config — dynamic first (local override), then hardcoded
const providerConfig =
let providerConfig: EmbeddingProvider | null =
dynamicProviders.find((dp) => dp.id === provider) || getEmbeddingProvider(provider) || null;
// #496: Fallback — resolve from ALL provider_nodes (not just localhost)
// This enables custom embedding models (e.g. google/gemini-embedding-001) whose
// providers have remote baseUrls. Safe because getProviderCredentials() authenticates.
if (!providerConfig) {
try {
const allNodes = (await getProviderNodes()) as unknown as EmbeddingProviderNodeRow[];
const matchingNode = (Array.isArray(allNodes) ? allNodes : []).find(
(n) =>
n.prefix === provider && (n.apiType === "chat" || n.apiType === "responses") && n.baseUrl
);
if (matchingNode) {
const baseUrl = String(matchingNode.baseUrl).replace(/\/+$/, "");
providerConfig = {
id: matchingNode.prefix,
baseUrl: `${baseUrl}/embeddings`,
authType: "apikey",
authHeader: "bearer",
models: [],
};
log.info(
"EMBED",
`Resolved custom embedding provider: ${provider}${providerConfig.baseUrl}`
);
}
} catch (err) {
log.error("EMBED", `Failed to resolve custom embedding provider ${provider}: ${err}`);
}
}
if (!providerConfig) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
+103
View File
@@ -0,0 +1,103 @@
/**
* GET /api/v1/search/analytics
*
* Returns search request statistics from call_logs (request_type = 'search').
* Includes provider breakdown, cache hit rate, cost summary, and error count.
*/
import { NextResponse } from "next/server";
import { getDbInstance } from "@/lib/db/core";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
export async function GET(req: Request) {
const policy = await enforceApiKeyPolicy(req, "analytics");
if (policy.rejection) return policy.rejection;
try {
const db = getDbInstance();
// Total search requests
const totalRow = db
.prepare(`SELECT COUNT(*) as cnt FROM call_logs WHERE request_type = 'search'`)
.get() as { cnt: number };
const total = totalRow?.cnt ?? 0;
// Today's searches (UTC date)
const todayStart = new Date();
todayStart.setUTCHours(0, 0, 0, 0);
const todayRow = db
.prepare(
`SELECT COUNT(*) as cnt FROM call_logs WHERE request_type = 'search' AND timestamp >= ?`
)
.get(todayStart.toISOString()) as { cnt: number };
const today = todayRow?.cnt ?? 0;
// Errors
const errRow = db
.prepare(
`SELECT COUNT(*) as cnt FROM call_logs WHERE request_type = 'search' AND (status >= 400 OR error IS NOT NULL)`
)
.get() as { cnt: number };
const errors = errRow?.cnt ?? 0;
// Avg duration
const durRow = db
.prepare(
`SELECT AVG(duration) as avg FROM call_logs WHERE request_type = 'search' AND duration > 0`
)
.get() as { avg: number | null };
const avgDurationMs = Math.round(durRow?.avg ?? 0);
// Per-provider breakdown (provider column stores search provider id)
const provRows = db
.prepare(
`SELECT provider, COUNT(*) as cnt
FROM call_logs WHERE request_type = 'search'
GROUP BY provider ORDER BY cnt DESC`
)
.all() as Array<{ provider: string; cnt: number }>;
// Cost per search provider (matching searchRegistry.ts rates)
const COST_PER_QUERY: Record<string, number> = {
"serper-search": 0.001,
"brave-search": 0.003,
"perplexity-search": 0.005,
"exa-search": 0.01,
"tavily-search": 0.004,
};
const byProvider: Record<string, { count: number; costUsd: number }> = {};
let totalCostUsd = 0;
for (const row of provRows) {
const cost = (COST_PER_QUERY[row.provider] ?? 0.001) * row.cnt;
byProvider[row.provider] = { count: row.cnt, costUsd: cost };
totalCostUsd += cost;
}
// Cached: very fast responses (< 5ms) indicate cache hits
const cachedRow = db
.prepare(
`SELECT COUNT(*) as cnt FROM call_logs
WHERE request_type = 'search' AND duration > 0 AND duration < 5`
)
.get() as { cnt: number };
const cached = cachedRow?.cnt ?? 0;
const cacheHitRate = total > 0 ? Math.round((cached / total) * 100) : 0;
return NextResponse.json({
total,
today,
cached,
errors,
totalCostUsd,
byProvider,
cacheHitRate,
avgDurationMs,
last24h: [],
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.error("[/api/v1/search/analytics]", msg);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
+15
View File
@@ -437,6 +437,11 @@
"antigravityStep2Prefix": "2. Add",
"antigravityStep2Suffix": "to your hosts file as 127.0.0.1.",
"antigravityStep3": "3. Open Antigravity and requests will be proxied.",
"mitmHowWorksDesc": "{toolName} sends requests to its provider endpoint. MITM intercepts and redirects them to OmniRoute.",
"mitmStep1": "1. Start MITM to route requests through OmniRoute.",
"mitmStep2Prefix": "2. Add",
"mitmStep2Suffix": "to your hosts file as 127.0.0.1.",
"mitmStep3": "3. Open {toolName} and requests will be proxied.",
"sudoPasswordRequiredTitle": "Sudo Password Required",
"sudoPasswordHint": "Administrator password is required to modify hosts file and system proxy settings.",
"enterSudoPassword": "Enter sudo password",
@@ -1382,6 +1387,8 @@
"addFirstConnectionHint": "Add your first connection to get started",
"addConnection": "Add Connection",
"availableModels": "Available Models",
"builtInModels": "Built-in models",
"builtInModelsHint": "Registry models for this provider. Use the pencil to set compatibility options.",
"pageAutoRefresh": "Page will refresh automatically...",
"statusDisabled": "disabled",
"statusConnected": "connected",
@@ -1422,6 +1429,14 @@
"openRouterModelPlaceholder": "anthropic/claude-3-opus",
"customModels": "Custom Models",
"customModelsHint": "Add model IDs not in the default list. These will be available for routing.",
"normalizeToolCallIdLabel": "Normalize tool call IDs to 9 characters (e.g. Mistral)",
"preserveDeveloperRoleLabel": "Keep OpenAI Responses developer role (do not map to system)",
"compatAdjustmentsTitle": "Compatibility",
"compatButtonLabel": "Compatibility",
"compatToolIdShort": "Tool ID 9",
"compatDeveloperShort": "Developer role",
"compatDoNotPreserveDeveloper": "Do not preserve developer role",
"compatBadgeNoPreserve": "No preserve",
"modelId": "Model ID",
"customModelPlaceholder": "e.g. gpt-4.5-turbo",
"loading": "Loading...",
+10
View File
@@ -1382,6 +1382,8 @@
"addFirstConnectionHint": "添加您的第一个连接以开始使用",
"addConnection": "添加连接",
"availableModels": "可用模型",
"builtInModels": "内置模型",
"builtInModelsHint": "该提供商的注册表模型。点击铅笔可设置兼容选项。",
"pageAutoRefresh": "页面会自动刷新...",
"statusDisabled": "已禁用",
"statusConnected": "已连接",
@@ -1422,6 +1424,14 @@
"openRouterModelPlaceholder": "anthropic/claude-3-opus",
"customModels": "自定义模型",
"customModelsHint": "添加默认列表中没有的模型 ID,这些模型也能参与路由。",
"normalizeToolCallIdLabel": "将工具调用 ID 规范为 9 位(如 Mistral",
"preserveDeveloperRoleLabel": "保留 Responses 的 developer 角色(不映射为 system",
"compatAdjustmentsTitle": "兼容性",
"compatButtonLabel": "兼容性",
"compatToolIdShort": "工具 ID 9 位",
"compatDeveloperShort": "Developer 角色",
"compatDoNotPreserveDeveloper": "不保留 developer 角色",
"compatBadgeNoPreserve": "不保留",
"modelId": "模型 ID",
"customModelPlaceholder": "例如:gpt-4.5-turbo",
"loading": "正在加载...",
+137
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);
}
}
+8 -130
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();
}
}
+1 -1
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";
+5 -3
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 = {
+35 -2
View File
@@ -38,6 +38,8 @@ interface ApiKeyMetadata {
autoResolve: boolean;
isActive: boolean;
accessSchedule: AccessSchedule | null;
maxRequestsPerDay: number | null;
maxRequestsPerMinute: number | null;
}
interface ApiKeyRow extends JsonRecord {
@@ -187,6 +189,14 @@ function ensureApiKeysColumns(db: ApiKeysDbLike) {
db.exec("ALTER TABLE api_keys ADD COLUMN access_schedule TEXT");
console.log("[DB] Added api_keys.access_schedule column");
}
if (!columnNames.has("max_requests_per_day")) {
db.exec("ALTER TABLE api_keys ADD COLUMN max_requests_per_day INTEGER");
console.log("[DB] Added api_keys.max_requests_per_day column");
}
if (!columnNames.has("max_requests_per_minute")) {
db.exec("ALTER TABLE api_keys ADD COLUMN max_requests_per_minute INTEGER");
console.log("[DB] Added api_keys.max_requests_per_minute column");
}
_schemaChecked = true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -212,7 +222,7 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements {
_stmtGetKeyById = db.prepare<ApiKeyRow>("SELECT * FROM api_keys WHERE id = ?");
_stmtValidateKey = db.prepare<JsonRecord>("SELECT 1 FROM api_keys WHERE key = ?");
_stmtGetKeyMetadata = db.prepare<ApiKeyRow>(
"SELECT id, name, machine_id, allowed_models, allowed_connections, no_log, auto_resolve, is_active, access_schedule FROM api_keys WHERE key = ?"
"SELECT id, name, machine_id, allowed_models, allowed_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute FROM api_keys WHERE key = ?"
);
_stmtInsertKey = db.prepare(
"INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
@@ -406,6 +416,8 @@ export async function updateApiKeyPermissions(
autoResolve?: boolean;
isActive?: boolean;
accessSchedule?: AccessSchedule | null;
maxRequestsPerDay?: number | null;
maxRequestsPerMinute?: number | null;
}
) {
const db = getDbInstance() as ApiKeysDbLike;
@@ -422,6 +434,8 @@ export async function updateApiKeyPermissions(
autoResolve: update.autoResolve,
isActive: update.isActive,
accessSchedule: update.accessSchedule,
maxRequestsPerDay: update.maxRequestsPerDay,
maxRequestsPerMinute: update.maxRequestsPerMinute,
};
if (
@@ -431,7 +445,9 @@ export async function updateApiKeyPermissions(
normalized.noLog === undefined &&
normalized.autoResolve === undefined &&
normalized.isActive === undefined &&
normalized.accessSchedule === undefined
normalized.accessSchedule === undefined &&
normalized.maxRequestsPerDay === undefined &&
normalized.maxRequestsPerMinute === undefined
) {
return false;
}
@@ -446,6 +462,8 @@ export async function updateApiKeyPermissions(
autoResolve?: number;
isActive?: number;
accessSchedule?: string | null;
maxRequestsPerDay?: number | null;
maxRequestsPerMinute?: number | null;
} = { id };
if (normalized.name !== undefined) {
@@ -486,6 +504,16 @@ export async function updateApiKeyPermissions(
normalized.accessSchedule !== null ? JSON.stringify(normalized.accessSchedule) : null;
}
if (normalized.maxRequestsPerDay !== undefined) {
updates.push("max_requests_per_day = @maxRequestsPerDay");
params.maxRequestsPerDay = normalized.maxRequestsPerDay;
}
if (normalized.maxRequestsPerMinute !== undefined) {
updates.push("max_requests_per_minute = @maxRequestsPerMinute");
params.maxRequestsPerMinute = normalized.maxRequestsPerMinute;
}
const result = db.prepare(`UPDATE api_keys SET ${updates.join(", ")} WHERE id = @id`).run(params);
if (result.changes === 0) return false;
@@ -574,6 +602,9 @@ export async function getApiKeyMetadata(
const machineIdRaw = record.machine_id ?? record.machineId;
const metadataMachineId = typeof machineIdRaw === "string" ? machineIdRaw : null;
const rawMaxRPD = record.max_requests_per_day ?? record.maxRequestsPerDay;
const rawMaxRPM = record.max_requests_per_minute ?? record.maxRequestsPerMinute;
const metadata: ApiKeyMetadata = {
id: metadataId,
name: metadataName,
@@ -586,6 +617,8 @@ export async function getApiKeyMetadata(
autoResolve: parseAutoResolve(record.auto_resolve ?? record.autoResolve),
isActive: parseIsActive(record.is_active ?? record.isActive),
accessSchedule: parseAccessSchedule(record.access_schedule ?? record.accessSchedule),
maxRequestsPerDay: typeof rawMaxRPD === "number" && rawMaxRPD > 0 ? rawMaxRPD : null,
maxRequestsPerMinute: typeof rawMaxRPM === "number" && rawMaxRPM > 0 ? rawMaxRPM : null,
};
if (!metadata.id) {
+49 -5
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
+26 -12
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);
}
}
+142 -14
View File
@@ -7,6 +7,89 @@ import { backupDbFile } from "./backup";
type JsonRecord = Record<string, unknown>;
/** Built-in / alias models: tool-call + developer-role flags without a full custom row */
const MODEL_COMPAT_NAMESPACE = "modelCompatOverrides";
export type ModelCompatOverride = {
id: string;
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean;
};
function readCompatList(providerId: string): ModelCompatOverride[] {
const db = getDbInstance();
const row = db
.prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
.get(MODEL_COMPAT_NAMESPACE, providerId);
const value = getKeyValue(row).value;
if (!value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function writeCompatList(providerId: string, list: ModelCompatOverride[]) {
const db = getDbInstance();
if (list.length === 0) {
db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run(
MODEL_COMPAT_NAMESPACE,
providerId
);
} else {
db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
MODEL_COMPAT_NAMESPACE,
providerId,
JSON.stringify(list)
);
}
backupDbFile("pre-write");
}
export function getModelCompatOverrides(providerId: string): ModelCompatOverride[] {
return readCompatList(providerId);
}
export function mergeModelCompatOverride(
providerId: string,
modelId: string,
patch: Partial<{
normalizeToolCallId: boolean;
preserveOpenAIDeveloperRole: boolean | null;
}>
) {
const list = readCompatList(providerId);
const idx = list.findIndex((e) => e.id === modelId);
const prev = idx >= 0 ? { ...list[idx] } : { id: modelId };
const next: ModelCompatOverride = { ...prev, id: modelId };
if ("normalizeToolCallId" in patch) {
if (patch.normalizeToolCallId) next.normalizeToolCallId = true;
else delete next.normalizeToolCallId;
}
if ("preserveOpenAIDeveloperRole" in patch) {
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");
if (next.normalizeToolCallId || hasPreserveFlag) {
filtered.push(next);
}
writeCompatList(providerId, filtered);
}
export function removeModelCompatOverride(providerId: string, modelId: string) {
const list = readCompatList(providerId);
const filtered = list.filter((e) => e.id !== modelId);
if (filtered.length === list.length) return;
writeCompatList(providerId, filtered);
}
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
@@ -174,11 +257,16 @@ export async function removeCustomModel(providerId, modelId) {
);
}
removeModelCompatOverride(providerId, modelId);
backupDbFile("pre-write");
return true;
}
export async function updateCustomModel(providerId, modelId, updates = {}) {
export async function updateCustomModel(
providerId: string,
modelId: string,
updates: Record<string, unknown> = {}
) {
const db = getDbInstance();
const row = db
.prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?")
@@ -193,7 +281,7 @@ export async function updateCustomModel(providerId, modelId, updates = {}) {
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 } : {}),
@@ -204,6 +292,13 @@ export async function updateCustomModel(providerId, modelId, updates = {}) {
? { normalizeToolCallId: Boolean(updates.normalizeToolCallId) }
: {}),
};
if (Object.prototype.hasOwnProperty.call(updates, "preserveOpenAIDeveloperRole")) {
if (updates.preserveOpenAIDeveloperRole === null) {
delete next.preserveOpenAIDeveloperRole;
} else {
next.preserveOpenAIDeveloperRole = Boolean(updates.preserveOpenAIDeveloperRole);
}
}
models[index] = next;
@@ -216,24 +311,57 @@ export async function updateCustomModel(providerId, modelId, updates = {}) {
return next;
}
/**
* Whether the given provider/model has "normalize tool call id" (9-char Mistral-style) enabled.
* Only custom models can have this set; returns false for built-in models.
*/
export function getModelNormalizeToolCallId(providerId: string, modelId: string): boolean {
/** Single custom model row from key_value customModels, or null */
function getCustomModelRow(providerId: string, modelId: string): JsonRecord | null {
const db = getDbInstance();
const row = db
.prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?")
.get(providerId);
const value = getKeyValue(row).value;
if (!value) return false;
let models: { id: string; normalizeToolCallId?: boolean }[];
if (!value) return null;
try {
models = JSON.parse(value);
const models = JSON.parse(value) as unknown;
if (!Array.isArray(models)) return null;
const m = models.find((x: unknown) => {
if (!x || typeof x !== "object" || Array.isArray(x)) return false;
return (x as { id?: string }).id === modelId;
}) as JsonRecord | undefined;
return m ?? null;
} catch {
return false;
return null;
}
if (!Array.isArray(models)) return false;
const m = models.find((x: { id: string }) => x.id === modelId);
return Boolean(m?.normalizeToolCallId);
}
/**
* Whether the given provider/model has "normalize tool call id" (9-char Mistral-style) enabled.
* Custom model row wins; otherwise {@link getModelCompatOverrides}.
*/
export function getModelNormalizeToolCallId(providerId: string, modelId: string): boolean {
const m = getCustomModelRow(providerId, modelId);
if (m) return Boolean(m.normalizeToolCallId);
const co = readCompatList(providerId).find((e) => e.id === modelId);
return Boolean(co?.normalizeToolCallId);
}
/**
* Explicit preserve-openai-developer preference for this provider/model.
* `undefined` = unset routing keeps legacy default (preserve developer for OpenAI format).
* `false` = map developer system (e.g. MiniMax). `true` = keep developer.
*/
export function getModelPreserveOpenAIDeveloperRole(
providerId: string,
modelId: string
): boolean | undefined {
const m = getCustomModelRow(providerId, modelId);
if (m) {
if (Object.prototype.hasOwnProperty.call(m, "preserveOpenAIDeveloperRole")) {
return Boolean(m.preserveOpenAIDeveloperRole);
}
return undefined;
}
const co = readCompatList(providerId).find((e) => e.id === modelId);
if (co && Object.prototype.hasOwnProperty.call(co, "preserveOpenAIDeveloperRole")) {
return Boolean(co.preserveOpenAIDeveloperRole);
}
return undefined;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { randomUUID } from "node:crypto";
import { randomUUID } from "crypto";
import { getDbInstance } from "./core";
import { backupDbFile } from "./backup";
+30 -18
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();
+5
View File
@@ -41,6 +41,11 @@ export {
addCustomModel,
removeCustomModel,
updateCustomModel,
getModelCompatOverrides,
mergeModelCompatOverride,
removeModelCompatOverride,
getModelNormalizeToolCallId,
getModelPreserveOpenAIDeveloperRole,
} from "./db/models";
export {
+42 -21
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)
+23 -11
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 ───────────────────────────────────────────────────────────────
+2 -2
View File
@@ -193,8 +193,8 @@ export const CLI_TOOLS = {
image: "/providers/kiro.png",
icon: "psychology_alt",
color: "#FF6B35",
description: "Amazon Kiro — AI-powered IDE",
configType: "guide",
description: "Amazon Kiro — AI-powered IDE with MITM",
configType: "mitm",
guideSteps: [
{ step: 1, title: "Open Kiro Settings", desc: "Go to Settings → AI Provider" },
{ step: 2, title: "Base URL", value: "{{baseUrl}}", copyable: true },
+10
View File
@@ -490,6 +490,16 @@ export const APIKEY_PROVIDERS = {
website: "https://tavily.com",
authHint: "API key from app.tavily.com (format: tvly-...)",
},
alibaba: {
id: "alibaba",
alias: "ali",
name: "Alibaba Cloud (DashScope)",
icon: "cloud_queue",
color: "#FF6600",
textIcon: "AL",
website: "https://dashscope-intl.aliyuncs.com",
hasFree: false,
},
};
export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
+77
View File
@@ -35,6 +35,8 @@ export interface ApiKeyMetadata {
usedBudget?: number;
isActive?: boolean;
accessSchedule?: AccessSchedule | null;
maxRequestsPerDay?: number | null;
maxRequestsPerMinute?: number | null;
}
/**
@@ -103,6 +105,65 @@ function isWithinSchedule(schedule: AccessSchedule): boolean {
return localMinutes >= fromMinutes && localMinutes < untilMinutes;
}
// ── In-memory request counter for per-key rate limits (#452) ──
/** Sliding-window request timestamps per API key */
const _requestTimestamps = new Map<string, number[]>();
const REQUEST_COUNTER_MAX_KEYS = 5000;
const REQUEST_DAY_MS = 24 * 60 * 60 * 1000;
const REQUEST_MINUTE_MS = 60 * 1000;
/** Record a request and check per-key limits. Returns null if OK, or an error message. */
function checkRequestCountLimits(
apiKeyId: string,
maxPerDay: number | null | undefined,
maxPerMinute: number | null | undefined
): string | null {
if (!maxPerDay && !maxPerMinute) return null;
const now = Date.now();
// Get or create timestamp array for this key
let timestamps = _requestTimestamps.get(apiKeyId);
if (!timestamps) {
timestamps = [];
_requestTimestamps.set(apiKeyId, timestamps);
// Prevent unbounded growth
if (_requestTimestamps.size > REQUEST_COUNTER_MAX_KEYS) {
const firstKey = _requestTimestamps.keys().next().value;
if (firstKey) _requestTimestamps.delete(firstKey);
}
}
// Prune timestamps older than 24h
const dayAgo = now - REQUEST_DAY_MS;
while (timestamps.length > 0 && timestamps[0] < dayAgo) {
timestamps.shift();
}
// Check per-minute limit (before recording this request)
if (maxPerMinute && maxPerMinute > 0) {
const minuteAgo = now - REQUEST_MINUTE_MS;
const recentCount = timestamps.filter((t) => t >= minuteAgo).length;
if (recentCount >= maxPerMinute) {
return `Per-minute request limit exceeded (${maxPerMinute} RPM). Try again in a few seconds.`;
}
}
// Check per-day limit
if (maxPerDay && maxPerDay > 0) {
if (timestamps.length >= maxPerDay) {
return `Daily request limit exceeded (${maxPerDay} RPD). Resets in ${Math.ceil(
(timestamps[0] + REQUEST_DAY_MS - now) / 60000
)} minutes.`;
}
}
// All checks passed — record this request
timestamps.push(now);
return null;
}
export interface ApiKeyPolicyResult {
/** API key string (null if no key provided) */
apiKey: string | null;
@@ -224,5 +285,21 @@ export async function enforceApiKeyPolicy(
}
}
// ── Check 5: Request-count limits (#452) ──
if (apiKeyInfo.id && (apiKeyInfo.maxRequestsPerDay || apiKeyInfo.maxRequestsPerMinute)) {
const limitError = checkRequestCountLimits(
apiKeyInfo.id,
apiKeyInfo.maxRequestsPerDay,
apiKeyInfo.maxRequestsPerMinute
);
if (limitError) {
return {
apiKey,
apiKeyInfo,
rejection: errorResponse(HTTP_STATUS.RATE_LIMITED, limitError),
};
}
}
return { apiKey, apiKeyInfo, rejection: null };
}
+87 -6
View File
@@ -1,17 +1,99 @@
import { machineIdSync } from "node-machine-id";
import { execSync, execFileSync } from "child_process";
import { existsSync, readFileSync } from "fs";
/**
* Get consistent machine ID using node-machine-id with salt
* Get raw machine ID using OS-specific methods.
*
* IMPORTANT: We do NOT use `if (process.platform === ...)` branching here.
* Next.js SWC bundler evaluates `process.platform` at BUILD time, so when the
* project is built on Linux, the win32/darwin branches get dead-code-eliminated
* and the Linux fallback (which uses `head`) runs on Windows at runtime.
*
* Instead, we use a try/catch waterfall: try each OS method and fall through
* to the next on failure. The correct method always succeeds on the target OS.
*/
function getMachineIdRaw(): string {
// Strategy 1: Windows — REG.exe query for MachineGuid
try {
const sysRoot = process.env.SystemRoot || process.env.windir || "C:\\Windows";
const regPath = `${sysRoot}\\System32\\REG.exe`;
if (existsSync(regPath)) {
const output = execFileSync(
regPath,
["QUERY", "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"],
{ encoding: "utf8", timeout: 5000 }
);
const id = output
.split("REG_SZ")[1]
?.replace(/\r+|\n+|\s+/gi, "")
?.toLowerCase();
if (id && id.length > 8) return id;
}
} catch {
// Not Windows or REG.exe failed — continue
}
// Strategy 2: macOS — ioreg IOPlatformUUID
try {
const output = execSync("ioreg -rd1 -c IOPlatformExpertDevice", {
encoding: "utf8",
timeout: 5000,
});
if (output.includes("IOPlatformUUID")) {
const id = output
.split("IOPlatformUUID")[1]
?.split("\n")[0]
?.replace(/=|\s+|"/gi, "")
?.toLowerCase();
if (id && id.length > 8) return id;
}
} catch {
// Not macOS or ioreg not available — continue
}
// Strategy 3: Linux — read machine-id files directly (no `head` or pipe)
try {
for (const filePath of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
if (existsSync(filePath)) {
const content = readFileSync(filePath, "utf8").trim().toLowerCase();
if (content.length > 8) return content;
}
}
} catch {
// Files not readable — continue
}
// Strategy 4: Hostname fallback (works on all platforms)
try {
const hostname = execSync("hostname", { encoding: "utf8", timeout: 5000 });
const id = hostname.trim().toLowerCase();
if (id) return id;
} catch {
// hostname failed — continue
}
// Strategy 5: Node.js os.hostname() (no exec needed)
try {
const os = require("os");
return os.hostname().toLowerCase();
} catch {
// Final fallback
}
return "unknown-machine";
}
/**
* Get consistent machine ID using native registry/OS query with salt
* This ensures the same physical machine gets the same ID across runs
*
* @param {string} salt - Optional salt to use (defaults to environment variable)
* @returns {Promise<string>} Machine ID (16-character base32)
*/
export async function getConsistentMachineId(salt = null) {
// For server-side, use node-machine-id with salt
const saltValue = salt || process.env.MACHINE_ID_SALT || "endpoint-proxy-salt";
try {
const rawMachineId = machineIdSync();
const rawMachineId = getMachineIdRaw();
// Create consistent ID using salt
const crypto = await import("crypto");
const hashedMachineId = crypto
@@ -41,9 +123,8 @@ export async function getConsistentMachineId(salt = null) {
* @returns {Promise<string>} Raw machine ID
*/
export async function getRawMachineId() {
// For server-side, use raw node-machine-id
try {
return machineIdSync();
return getMachineIdRaw();
} catch (error) {
console.log("Error getting raw machine ID:", error);
// Fallback to random ID if node-machine-id fails
+1
View File
@@ -348,6 +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().nullable().optional(),
});
const pricingFieldsSchema = z
+28 -14
View File
@@ -63,6 +63,13 @@ test.describe("Bailian Coding Plan Provider", () => {
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
// Dismiss any pre-existing dialog/overlay that may appear on page load
const preExistingDialog = page.getByRole("dialog").first();
if (await preExistingDialog.isVisible({ timeout: 2000 }).catch(() => false)) {
await page.keyboard.press("Escape");
await preExistingDialog.waitFor({ state: "hidden", timeout: 3000 }).catch(() => {});
}
const addKeyButton = page.getByRole("button", {
name: /add.*api.*key|add.*key|add.*connection|connect/i,
});
@@ -118,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) => {
@@ -175,6 +181,13 @@ test.describe("Bailian Coding Plan Provider", () => {
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
// Dismiss any pre-existing dialog/overlay that may appear on page load
const preExistingDialog = page.getByRole("dialog").first();
if (await preExistingDialog.isVisible({ timeout: 2000 }).catch(() => false)) {
await page.keyboard.press("Escape");
await preExistingDialog.waitFor({ state: "hidden", timeout: 3000 }).catch(() => {});
}
const addKeyButton = page.getByRole("button", {
name: /add.*api.*key|add.*key|add.*connection|connect/i,
});
@@ -213,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);
+9 -6
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");
+51 -16
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();
+90 -26
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 () => {
@@ -0,0 +1,93 @@
/**
* Unit tests for PR #397 Anthropic-format tools filter fix (#346)
*
* Verifies that tools arriving in Anthropic format (`tool.name` without `.function`)
* are NOT dropped by the empty-name filter in chatCore.ts.
* Before the fix, ALL anthropic-format tools were silently dropped, causing
* `400: tool_choice.any may only be specified while providing tools` from Anthropic.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
// Inline the filter logic from chatCore.ts (lines 225-231 after #397 fix)
function filterEmptyNameTools(tools) {
return tools.filter((tool) => {
const fn = tool.function;
const name = fn?.name ?? tool.name;
return name && String(name).trim().length > 0;
});
}
describe("tools empty-name filter — #346 / PR #397", () => {
it("should keep tools with valid OpenAI format name (tool.function.name)", () => {
const tools = [
{ type: "function", function: { name: "get_weather", description: "Get weather" } },
];
assert.equal(filterEmptyNameTools(tools).length, 1);
});
it("should keep tools with valid Anthropic format name (tool.name)", () => {
const tools = [
{ name: "get_weather", description: "Get weather", input_schema: { type: "object" } },
];
assert.equal(filterEmptyNameTools(tools).length, 1);
});
it("should drop tools with empty OpenAI format name (tool.function.name = '')", () => {
const tools = [{ type: "function", function: { name: "" } }];
assert.equal(filterEmptyNameTools(tools).length, 0);
});
it("should drop tools with empty Anthropic format name (tool.name = '')", () => {
const tools = [{ name: "", description: "Ghost tool", input_schema: { type: "object" } }];
assert.equal(filterEmptyNameTools(tools).length, 0);
});
it("should NOT drop Anthropic-format tools when function wrapper is absent (regression for PR #397)", () => {
// Before fix: fn was undefined, fn?.name was undefined, filter returned false → ALL tools dropped
// After fix: fn?.name ?? tool.name → falls back to tool.name → keeps valid tools
const tools = [
{
name: "search",
description: "Search the web",
input_schema: { type: "object", properties: {} },
},
{ name: "code_exec", description: "Execute code", input_schema: { type: "object" } },
];
const result = filterEmptyNameTools(tools);
assert.equal(result.length, 2, "Both anthropic-format tools should be preserved");
});
it("should handle mixed format tools in the same array", () => {
const tools = [
{ type: "function", function: { name: "openai_tool" } }, // OpenAI format
{ name: "anthropic_tool", input_schema: { type: "object" } }, // Anthropic format
{ type: "function", function: { name: "" } }, // Empty OpenAI — should be dropped
{ name: "", input_schema: { type: "object" } }, // Empty Anthropic — should be dropped
];
const result = filterEmptyNameTools(tools);
assert.equal(result.length, 2, "Should keep 2 valid tools (one of each format)");
assert.ok(
result.some((t) => t.function?.name === "openai_tool"),
"OpenAI tool preserved"
);
assert.ok(
result.some((t) => t.name === "anthropic_tool"),
"Anthropic tool preserved"
);
});
it("should handle tools with whitespace-only names", () => {
const tools = [{ name: " ", input_schema: { type: "object" } }];
assert.equal(filterEmptyNameTools(tools).length, 0);
});
it("should handle null/undefined tool.name gracefully", () => {
const tools = [
{ input_schema: { type: "object" } }, // Neither name nor function
{ name: null, input_schema: { type: "object" } },
];
assert.equal(filterEmptyNameTools(tools).length, 0);
});
});