Compare commits

...

52 Commits

Author SHA1 Message Date
diegosouzapw a3ad7c6c2e chore(release): v2.3.1
Build Electron Desktop App / Validate version (push) Failing after 39s
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
fix(ui): translate hardcoded PT-BR text in OAuthModal to English (#314, PR #325)
fix(ts): wrap unknown dataObj fields with toRecord() in usage.ts (Kimi parser)
fix(instrumentation): await getSettings() — property access on Promise (#316 follow-up)
2026-03-11 20:49:37 -03:00
Diego Rodrigues de Sa e Souza afc9362ca5 Merge pull request #325 from diegosouzapw/fix/issue-314-oauth-modal-pt-text
fix(ui): translate hardcoded PT-BR text in OAuthModal to English (#314)
2026-03-11 20:48:31 -03:00
diegosouzapw f6b125e8c2 fix(ui): translate hardcoded PT-BR text in OAuthModal to English (#314)
Two strings were hardcoded in Portuguese regardless of the user's language setting:
1. The redirect_uri_mismatch error message (line ~101)
2. The remote access info banner for Google OAuth providers (line ~515)

Both are now in English. The anchor href is updated from
'#oauth-em-servidor-remoto' to '#oauth-on-a-remote-server' to match
the EN README anchor.
2026-03-11 20:45:45 -03:00
diegosouzapw 5df3c22be8 fix(ts): wrap unknown dataObj fields with toRecord() in usage.ts (Kimi usage parser)
Six TypeScript errors on lines 921/922/925/926/939/948:
- dataObj.five_hour / seven_day are 'unknown', can't be passed directly to
  hasUtilization/createQuotaObject which expect JsonRecord — wrap with toRecord()
- dataObj.user is 'unknown', can't chain .membership?.level — use toRecord() first
2026-03-11 20:45:39 -03:00
diegosouzapw 11a0df5443 fix(instrumentation): await getSettings() — property access on Promise (#316 follow-up)
getSettings() is declared async so calling it without await left
settings as a Promise<Record<string, unknown>>, causing 4 TS errors
when accessing settings.modelAliases in the alias restore block.
2026-03-11 13:07:39 -03:00
diegosouzapw e27a2a0d55 chore(release): v2.3.0
Build Electron Desktop App / Validate version (push) Failing after 30s
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
fix(aliases): custom model aliases applied to routing + restored on startup (#315 #316, PR #317)
fix(cli): better-sqlite3 postinstall rebuild cross-platform macOS ARM (#312, PR #313 @ardaaltinors)
2026-03-11 12:43:50 -03:00
Diego Rodrigues de Sa e Souza dc8abe60ee Merge pull request #317 from diegosouzapw/fix/issue-315-316-alias-bugs
fix(aliases): resolve custom model aliases before routing + restore on startup (#315, #316)
2026-03-11 12:43:02 -03:00
diegosouzapw afe2ab37e4 fix(aliases): resolve custom model aliases before routing + restore on startup (#315, #316)
#315: Import and call resolveModelAlias() in chatCore.ts before the
getModelTargetFormat() lookup so that custom aliases configured in
Settings → Model Aliases → Pattern→Target are actually applied during
routing instead of being silently ignored.

#316: Load persisted custom model aliases from settings DB at server
startup (instrumentation.ts). Previously _customAliases started as an
empty object after every restart since setCustomAliases() was only
called by the PUT /api/settings/model-aliases handler — never at init.
Now aliases are restored from settings.modelAliases JSON field on boot.
2026-03-11 12:42:18 -03:00
Diego Rodrigues de Sa e Souza f7bd99f965 Merge pull request #313 from ardaaltinors/fix/better-sqlite3-postinstall-rebuild
fix(cli): improve better-sqlite3 postinstall rebuild for cross-platform installs
2026-03-11 12:39:03 -03:00
ardaaltinors f5238944b4 fix(cli): improve better-sqlite3 postinstall rebuild for cross-platform installs (#312)
Replace unreliable process.dlopen() platform detection with explicit
platform/arch comparison against the build target (linux-x64). On macOS,
dlopen can load an incompatible binary without throwing, causing the
postinstall script to skip the rebuild entirely.

- Detect platform mismatch via process.platform/arch instead of dlopen
- Fail the install (exit 1) if rebuild fails, instead of warning silently
- Verify rebuilt binary loads correctly after rebuild
- Add pre-flight binary check in CLI entry point as a safety net
2026-03-11 17:11:00 +03:00
diegosouzapw c7ae9c30c2 chore(release): v2.2.9
Build Electron Desktop App / Validate version (push) Failing after 36s
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
feat(providers): persist custom model endpoint edits (#307, PR #307 by @hijak)
fix(deps): add @swc/helpers as explicit dep to fix MODULE_NOT_FOUND (#306, PR #308)
fix(usage): correct Claude quota display — utilization = % used (#299, PR #309)
2026-03-11 08:46:16 -03:00
Diego Rodrigues de Sa e Souza 82f7a12a46 Merge pull request #309 from diegosouzapw/fix/issue-299-claude-quota-inversion
fix(usage): correct Claude quota display — utilization = % used (#299)
2026-03-11 08:45:05 -03:00
Diego Rodrigues de Sa e Souza f494a8531b Merge pull request #308 from diegosouzapw/fix/issue-306-swc-helpers-missing
fix(deps): add @swc/helpers as explicit dependency (#306)
2026-03-11 08:45:01 -03:00
Diego Rodrigues de Sa e Souza 36ed0499db Merge pull request #307 from hijak/fix/provider-model-endpoints-save
fix(providers): persist supported endpoints with explicit save
2026-03-11 08:44:58 -03:00
diegosouzapw 46cff2200d fix(usage): correct Claude quota display — utilization = % used, not % remaining (#299)
The Claude Code OAuth API returns 'utilization' as percent USED,
not percent remaining. The createQuotaObject function had them swapped:
it set remainingPercentage = utilization, which inverted the quota bar.

Confirmed by reporter: Claude.ai shows 87% used → OmniRoute was showing
87% remaining (green bar), should show 13% remaining (yellow/red bar).

Fix: used = utilization; remaining = 100 - utilization.
2026-03-11 08:42:44 -03:00
diegosouzapw 5ea6ad4a9e fix(deps): add @swc/helpers as explicit dependency (#306)
next@16 lists @swc/helpers@0.5.15 in its own dependencies but npm's
deduplication during global install fails to place it in the omniroute
app's node_modules when hoisted. This causes MODULE_NOT_FOUND for
@swc/helpers/esm/_interop_require_default.js on startup.

Fix: add @swc/helpers@0.5.19 to omniroute's top-level dependencies and
overrides so npm guarantees its presence regardless of hoisting strategy.
Reproducible on Windows (Node 22) and Linux.
2026-03-11 08:40:31 -03:00
jack 6cad4fae8e fix(providers): persist supported endpoints with explicit save for custom models 2026-03-11 11:20:25 +00:00
diegosouzapw 8df24c855b chore(release): v2.2.8
Build Electron Desktop App / Validate version (push) Failing after 32s
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
fix(docker): healthcheck now uses /api/monitoring/health (#296, PR #301)
fix(rate-limit): maxWait=120s on Bottleneck prevents endless queue (#297, PR #302)
2026-03-11 00:20:57 -03:00
Diego Rodrigues de Sa e Souza f25882c0e9 Merge pull request #302 from diegosouzapw/fix/issue-296-healthcheck-endpoint
fix(docker): use /api/monitoring/health for Docker healthcheck (#296)
2026-03-11 00:20:17 -03:00
Diego Rodrigues de Sa e Souza be6c769192 Merge pull request #301 from diegosouzapw/fix/issue-297-rate-limit-maxwait
fix(rate-limit): prevent endless queue with maxWait (#297)
2026-03-11 00:20:14 -03:00
diegosouzapw a4276444b5 fix(rate-limit): add maxWait to Bottleneck to prevent endless queuing (#297)
When all provider quotas are exhausted (reservoir=0 after repeated 429s),
Bottleneck's schedule() would queue requests indefinitely since no maxWait
was configured. Clients (Cursor, Claude Code, VS Code) would hang forever.

Fix: add maxWait=120000 (2min, configurable via RATE_LIMIT_MAX_WAIT_MS env)
to DEFAULT_SETTINGS and all three Bottleneck constructors. When a job waits
longer than maxWait, Bottleneck rejects with a BottleneckError which
propagates as a 502/503 error to the client — a clean fail-fast instead
of infinite hang.
2026-03-10 23:58:36 -03:00
diegosouzapw 0af27b8d8a fix(docker): use /api/monitoring/health for healthcheck (#296)
The healthcheck script was querying /api/settings which returns config
data rather than system health. Updated to /api/monitoring/health which
is the canonical health endpoint used across tests, SystemMonitor.tsx,
MaintenanceBanner.tsx, playwright config, and MCP tools.
2026-03-10 23:57:17 -03:00
diegosouzapw 542eb0e719 chore(release): v2.2.7
Build Electron Desktop App / Validate version (push) Failing after 31s
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
fix(docker): bootstrap-env.mjs missing in runtime image (#292, PR #293)
fix(google-cli): prefer OAuth projectId over stale body.project (PR #294)
fix(chat): strip empty name from messages/input before upstream (#291, PR #300)
deps: bump hono 4.12.4 → 4.12.7 (PR #298)
2026-03-10 23:34:19 -03:00
Diego Rodrigues de Sa e Souza c658b39270 Merge pull request #300 from diegosouzapw/fix/issue-291-strip-empty-name
fix(chat): strip empty name from messages/input before upstream (#291)
2026-03-10 23:33:04 -03:00
Diego Rodrigues de Sa e Souza 52ef3dfc7e Merge pull request #298 from diegosouzapw/dependabot/npm_and_yarn/hono-4.12.7
deps: bump hono from 4.12.4 to 4.12.7
2026-03-10 23:33:01 -03:00
Diego Rodrigues de Sa e Souza 57da407693 Merge pull request #294 from hijak/fix/google-cli-prefer-oauth-projectid
fix(google-cli): prefer OAuth projectId over request body project
2026-03-10 23:32:59 -03:00
Diego Rodrigues de Sa e Souza d2d6fc5883 Merge pull request #293 from hijak/fix/docker-bootstrap-env-missing
fix(docker): include bootstrap-env.mjs in runtime image
2026-03-10 23:32:57 -03:00
diegosouzapw 6a7a6022d4 fix(chat): strip empty name fields from messages/input before upstream (#291)
OpenAI-compatible providers (OpenAI, Codex) reject name:'' with 400 errors:
  - 'Unknown parameter: input[1].name'
  - 'Invalid tools[0].name: empty string'

Some clients (e.g. PocketPaw) forward assistant turns with name:'' in
the OpenAI Responses API input[] and chat completions messages[].

Fix: filter out name:'' from messages[] and input[] before translateRequest.
Non-empty non-null name values are preserved per OpenAI spec.
2026-03-10 23:31:31 -03:00
dependabot[bot] b53eafa615 deps: bump hono from 4.12.4 to 4.12.7
Bumps [hono](https://github.com/honojs/hono) from 4.12.4 to 4.12.7.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.4...v4.12.7)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.12.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-11 02:07:19 +00:00
jack c949214e99 feat(google-cli): add env escape hatch for body.project override 2026-03-10 22:15:26 +00:00
jack 887cf25b65 fix(google-cli): prefer OAuth projectId over client body project 2026-03-10 22:12:39 +00:00
jack dd6142196f fix(docker): copy bootstrap-env.mjs into runtime image 2026-03-10 21:55:21 +00:00
diegosouzapw 902c7244d1 chore(release): v2.2.6
Build Electron Desktop App / Validate version (push) Failing after 32s
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
fix(translator): map Claude thinking_delta to reasoning_content (#289)
- Close #289: thinking tokens now visible in Claude Code, Cursor, Windsurf
2026-03-10 16:21:20 -03:00
Diego Rodrigues de Sa e Souza 4f11762c68 Merge pull request #290 from diegosouzapw/fix/issue-289-thinking-tokens
fix(translator): map Claude thinking_delta to reasoning_content (#289)
2026-03-10 16:20:22 -03:00
diegosouzapw 8a7f7c1ba0 fix(translator): map Claude thinking_delta to reasoning_content not content (#289)
When proxying Claude responses through OmniRoute, thinking blocks were being
emitted as regular content (delta.content) with <think>...</think> XML tags.
Clients like Claude Code, Cursor, and Windsurf look for delta.reasoning_content
to render the thinking panel — not <think> tags inside content.

Root cause (claude-to-openai.ts):
  - content_block_start type:thinking → emitted { content: '<think>' }
  - content_block_delta thinking_delta → emitted { content: delta.thinking }
  - content_block_stop thinking block → emitted { content: '</think>' }

Fix:
  - content_block_start → emits { reasoning_content: '' } (signals block start)
  - thinking_delta → emits { reasoning_content: delta.thinking }
  - content_block_stop → no extra chunk needed (thinking streamed via reasoning_content)

This fix applies when sourceFormat=CLAUDE targetFormat=OPENAI (Antigravity OAuth,
direct Claude API providers). The user reported 'Thinking Budget: passthrough'
was enabled but thinking was invisible — this is the root cause.

Fixes #289
2026-03-10 15:25:31 -03:00
diegosouzapw af46f87eed feat(bootstrap): zero-config auto-generated secrets on first run
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
Resolves root cause of #252 (Electron black screen) and #249 (OAuth fail)
for users running with zero configuration (no .env needed).

New: scripts/bootstrap-env.mjs
- Auto-generates JWT_SECRET (64 bytes), STORAGE_ENCRYPTION_KEY (32 bytes),
  API_KEY_SECRET (32 bytes) if missing or empty
- Persists to {DATA_DIR}/server.env — survives restarts, Docker volume
  remounts, and upgrades without changing secrets
- Reads .env from CWD (user overrides), then merges process.env (highest prio)
- Logs friendly warnings for missing optional OAuth secrets

Updated: run-standalone.mjs + run-next.mjs
- Call bootstrapEnv() before spawning server — covers npm + Docker paths

Updated: electron/main.js (synchronous inline — CJS cannot await import ESM)
- Reads userData/server.env, generates missing secrets with crypto.randomBytes()
- Persists back to server.env, sets OMNIROUTE_BOOTSTRAPPED=true

New: BootstrapBanner.tsx + page.tsx update
- Dismissable amber banner on dashboard home when running in zero-config mode
- Shows where server.env is located and how to customize secrets
2026-03-10 15:15:07 -03:00
diegosouzapw fd749d1e0b fix(electron): auto-generate JWT_SECRET and STORAGE_ENCRYPTION_KEY if missing
In packaged Electron on macOS/Windows/Linux, there is no .env file.
The Next.js server needs JWT_SECRET and STORAGE_ENCRYPTION_KEY to start —
without them it crashes silently, causing ERR_CONNECTION_REFUSED
and a black screen in the Electron window.

Fix: Generate cryptographically random values with crypto.randomBytes()
on first launch, persist them in userData/electron-env.json, and pass
them to the spawned server.js process via the env option.

Root cause: macOS users reported 'app black screen' (#252) and
ERR_CONNECTION_REFUSED — this was the Next.js server crashing at startup
because these env vars don't exist in the desktop OS environment.
2026-03-10 15:06:57 -03:00
diegosouzapw 5046f90dfa docs(workflow): make openapi.yaml sync mandatory in generate-release
- Step 4 now marked ⚠️ MANDATORY with CI will fail warning
- Command is now auto-extracting version from package.json (no manual substitution)
- Step 4 has // turbo annotation for auto-execution
- Added 'Known CI Pitfalls' table: docs-sync failures, Electron fpm, Docker 502
2026-03-10 15:02:08 -03:00
diegosouzapw cf13e95610 fix(ci): bump openapi.yaml version to 2.2.4
check:docs-sync fails when openapi.yaml version != package.json version.
Updating to match after v2.2.4 release.

Systematic fix: openapi.yaml version must always be updated alongside
package.json during releases (see generate-release workflow step 4).
2026-03-10 14:43:17 -03:00
diegosouzapw 5763609008 feat(release): v2.2.4 — CI fixes (docs-sync, electron fpm, docker)
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
2026-03-10 14:37:04 -03:00
diegosouzapw 6d672ab09a fix(ci): docs-sync, electron linux fpm, docker cache env
CI Lint fixes:
- docs/openapi.yaml: bump version 2.2.0 → 2.2.3 (was out of sync with package.json)
- CHANGELOG.md: add '## [Unreleased]' as first section (required by check:docs-sync)

Electron Linux fix:
- electron-release.yml: add 'gem install fpm' step for Linux builds
  fpm is required by electron-builder to package .deb installers;
  ubuntu-latest runners don't have it pre-installed

Docker publish:
- docker-publish.yml: add DOCKER_BUILDKIT_INLINE_CACHE env; prev 502 was
  a transient Docker Hub network error, no code change needed
2026-03-10 14:31:48 -03:00
diegosouzapw ac68022233 feat(release): v2.2.3 — bug fixes from community PRs
Build Electron Desktop App / Validate version (push) Failing after 41s
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
fix(google-cli): remove fake projectId fallback causing permission/verification errors (#285)
- antigravity.ts, openai-to-gemini.ts, geminiHelper.ts
- Throws clear error instead of silently sending with random project IDs

fix(claude): extend empty tool name filter to all message roles (#288)
- Pass 1.4 now covers all roles, not just assistant
- Filters tool_result with missing tool_use_id
- Filters top-level body.tools with empty names
- Explicit @swc/helpers COPY in Dockerfile runner-base stage
2026-03-10 12:53:47 -03:00
Nina Gleichner c2b31f6b20 Fix empty tool name 400 errors from Claude API and missing @swc/helpers in Docker (#288)
* Initial plan

* fix: filter empty tool names and missing tool_use_id; add @swc/helpers to Docker

Co-authored-by: ngleichner1 <263653359+ngleichner1@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ngleichner1 <263653359+ngleichner1@users.noreply.github.com>
2026-03-10 12:46:17 -03:00
Jack 54b1d8c8de fix(google-cli): stop random project fallback and require real OAuth projectId (#285)
Co-authored-by: jack <jack@plutus-32g.local>
2026-03-10 12:46:15 -03:00
diegosouzapw cd1ab696b2 docs: add npm run system-info to Support and troubleshooting sections
Users are now directed to run 'npm run system-info' when reporting bugs.
Added to:
- ## Support → '🐛 Reporting a Bug?' subsection
- Pain point #10 '🐛 I can't diagnose errors' bullet list
2026-03-10 12:45:20 -03:00
diegosouzapw d9d0640f6e feat(release): v2.2.2 — system-info script, Kimi/Mistral/Llama aliases
Build Electron Desktop App / Validate version (push) Failing after 28s
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
- New npm run system-info command (#280)
- Kimi K2/K2.5 Fireworks path aliases (#265)
- Mistral/Llama short aliases (#278)
- Custom alias instructions via Settings → Model Aliases
2026-03-10 11:00:20 -03:00
Diego Rodrigues de Sa e Souza e19046116a feat: system-info.mjs script + Kimi/Mistral/Llama model aliases (#280 #265 #278) (#284)
System Info Script (#280):
- New scripts/system-info.mjs: collects Node.js version, OmniRoute version,
  OS info, Agent CLI tool versions (iflow, gemini, claude, codex, antigravity,
  droid, openclaw, kilo, cursor, aider), Docker/PM2 status, system packages
- Outputs system-info.txt for easy attachment to bug reports
- Added npm script: 'npm run system-info'

Model Aliases (#265 #278):
- Added Kimi K2/K2.5 Fireworks path aliases to built-in alias map
  (fireworks/accounts/fireworks/models/kimi-k2p5 → moonshotai/Kimi-K2.5)
- Added short aliases: kimi-k2p5, kimi-k2, mistral-large, mistral-small,
  codestral, llama-3.3, llama-3-70b, llama-3-8b
- Custom aliases can be added via Settings → Model Aliases tab in dashboard

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-03-10 10:59:27 -03:00
diegosouzapw 82a621ec08 feat(release): v2.2.1 — bug fixes, security patches, CI hardening
Build Electron Desktop App / Validate version (push) Failing after 30s
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:
- fix(imageRegistry): add gemini-3.1-flash-image-preview to antigravity (#273)
- fix(models-route): add ollama-cloud to PROVIDER_MODELS_CONFIG (#276)
- fix(models-route): clear 400 error when no apiKey configured (#277)

Security:
- fix(mitm): TLS rejectUnauthorized defaults to true, opt-out via env var
- fix(backup): path traversal guards (safePath, safeProfilePath, safeLogPath)
- fix(usageHistory): prototype pollution via Object.create(null) + hasOwnProperty
- fix(deps): dompurify ^3.3.2 (CVE-2026-0540)
- fix(ci): add global permissions: contents: read to ci.yml

CI:
- fix(ci): @swc/helpers override + regenerated package-lock.json
- fix(ci): npm-publish skip if version already exists on npm
- fix(ci): npm-publish use npm install instead of npm ci
- fix(lint): cursor.ts isToolBoundaryAbort any → unknown
2026-03-10 09:50:19 -03:00
Diego Rodrigues de Sa e Souza ce560ebe9d fix: resolve issues #273, #276, #277 — image routing, models route, missing-key error (#282)
Squash merge PR #282: bug fixes for #273 (Gemini image routing), #276 (Ollama Cloud models), #277 (missing apiKey error), lint fix, and all security code-scanning patches.
2026-03-10 09:48:50 -03:00
diegosouzapw f900a81ec9 fix(ci): use npm install in npm-publish to survive lock file drift on old tags
npm ci fails if the tag commit's lock file is out of sync (as happened
with v2.2.0 when @swc/helpers was missing). npm install is safe here
because the publish workflow only needs deps to run prepublish.mjs —
strict lock enforcement is not required for the publish step.
2026-03-10 09:48:35 -03:00
diegosouzapw 2a620b178d fix(ci): skip npm publish if version already exists on npm registry
Prevents E403 failures when a release event fires more than once for the
same version (e.g. re-running a failed workflow or duplicate tag event).

The publish step now checks whether the version is already on npm and
exits cleanly with a warning instead of failing the workflow.
2026-03-10 09:46:14 -03:00
diegosouzapw 5aaaad529b fix(ci): sync package-lock.json — add @swc/helpers override + regenerate lock
All 3 CI workflows (CI, npm-publish, docker-publish) were failing with:
  'Missing: @swc/helpers@0.5.19 from lock file'

Root cause: npm version bump ran without npm install, causing the lock file
to be out of sync with CI's npm resolver (which resolves @swc/helpers@0.5.19
while local npm 10.9.4 resolves 0.5.15).

Fix: added 'overrides': { '@swc/helpers': '^0.5.19' } to package.json and
regenerated package-lock.json with npm install.

Also updated .agents/workflows/generate-release.md to enforce:
- Always use 'npm version patch --no-git-tag-version' (never minor/major)
- Always run 'npm install' after bumping to keep lock file in sync
- Version threshold: 2.x.10 → 2.(x+1).0 (manual)
2026-03-10 09:25:35 -03:00
47 changed files with 1331 additions and 156 deletions
+61 -29
View File
@@ -6,61 +6,83 @@ description: Create a new release, bump version up to 1.x.10 threshold, update c
Bump version, finalize CHANGELOG, commit, tag, push, publish to npm, and create GitHub release.
> **VERSION RULE: Always use PATCH bumps (2.x.y → 2.x.y+1)**
> NEVER use `npm version minor` or `npm version major`.
> Always use: `npm version patch --no-git-tag-version`
> The threshold rule: when `y` reaches 10, bump to `2.(x+1).0` — e.g. `2.1.10` → `2.2.0`.
## Steps
### 1. Determine new version
Check current version in `package.json` and increment the patch number:
Check current version in `package.json` and increment the **patch** number only:
```bash
grep '"version"' package.json
```
Version format: `1.x.y`increment `y` for patch, `x` for minor (threshold: y=10 triggers x+1).
Version format: `2.x.y`examples:
### 2. Finalize CHANGELOG.md
Replace `[Unreleased]` header with the new version and date:
```markdown
## [1.x.y] — YYYY-MM-DD
```
### 3. Bump version in package.json
- `2.1.2``2.1.3` (patch)
- `2.1.9``2.1.10` (patch)
- `2.1.10``2.2.0` (minor threshold — do manually with `sed`)
```bash
sed -i 's/"version": "OLD"/"version": "NEW"/' package.json
# ALWAYS use patch:
npm version patch --no-git-tag-version
```
### 4. Stage, commit, and tag
### 2. Regenerate lock file (REQUIRED after version bump)
**Mandatory** — skipping causes `@swc/helpers` lock mismatch and CI failures:
```bash
npm install
```
### 3. Finalize CHANGELOG.md
Replace `[Unreleased]` header with the new version and date.
Keep an empty `## [Unreleased]` section above it.
```markdown
## [Unreleased]
---
## [2.x.y] — YYYY-MM-DD
```
### 4. Update openapi.yaml version ⚠️ MANDATORY
> **CI will fail** if `docs/openapi.yaml` version ≠ `package.json` version (`check:docs-sync` enforces this).
// turbo
```bash
VERSION=$(node -p "require('./package.json').version") && sed -i "s/ version: .*/ version: $VERSION/" docs/openapi.yaml && echo "✓ openapi.yaml → $VERSION"
```
### 5. Stage, commit, and tag
// turbo-all
```bash
git add -A
git commit -m "feat(release): vX.Y.Z — summary of changes"
git tag -a vX.Y.Z -m "Release vX.Y.Z — summary"
git add package.json package-lock.json CHANGELOG.md docs/openapi.yaml
git commit -m "chore(release): v2.x.y — summary of changes"
git tag -a v2.x.y -m "Release v2.x.y"
```
### 5. Push to GitHub
### 6. Push to GitHub
```bash
git push origin main
git push origin vX.Y.Z
git push origin main --tags
```
### 6. Publish to npm
```bash
npm publish
```
Wait for completion (prepublishOnly runs `npm run build:cli` automatically).
### 7. Create GitHub release
```bash
gh release create vX.Y.Z --title "Release vX.Y.Z" --notes-file /tmp/release_notes.md
gh release create v2.x.y --title "v2.x.y — summary" --notes "..."
```
### 8. Deploy to VPS (if requested)
@@ -68,7 +90,7 @@ gh release create vX.Y.Z --title "Release vX.Y.Z" --notes-file /tmp/release_note
See `/deploy-vps` workflow for Akamai VPS or use npm for local VPS:
```bash
ssh root@<VPS_IP> "npm install -g omniroute@X.Y.Z && pm2 restart omniroute"
ssh root@<VPS_IP> "npm install -g omniroute@2.x.y && pm2 restart omniroute"
```
## Notes
@@ -76,3 +98,13 @@ ssh root@<VPS_IP> "npm install -g omniroute@X.Y.Z && pm2 restart omniroute"
- Always run `/update-docs` BEFORE this workflow (ensures CHANGELOG and README are current)
- The `prepublishOnly` script runs `npm run build:cli` automatically during `npm publish`
- After npm publish, verify with `npm info omniroute version`
- Lock file sync errors are caused by skipping `npm install` after version bump
## Known CI Pitfalls
| CI failure | Cause | Fix |
| ------------------------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------- |
| `[docs-sync] FAIL - OpenAPI version differs from package.json` | Skipped step 4 — `docs/openapi.yaml` version not updated | Run step 4 (`sed -i ...`) and commit |
| `[docs-sync] FAIL - CHANGELOG.md first section must be "## [Unreleased]"` | `## [Unreleased]` missing or not at top of CHANGELOG | Add `## [Unreleased]\n\n---\n` before the first versioned `## [x.y.z]` |
| Electron Linux `.deb` build fails (`FpmTarget` error) | `fpm` Ruby gem not installed on `ubuntu-latest` runner | Already fixed in `electron-release.yml` (`gem install fpm` step) |
| Docker Hub `502 error writing layer blob` | Transient Docker Hub network error during ARM64 push | Re-run the Docker publish workflow; no code change needed |
+3
View File
@@ -10,6 +10,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
lint:
name: Lint
+3
View File
@@ -49,6 +49,9 @@ jobs:
${{ env.IMAGE_NAME }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
no-cache: false
env:
DOCKER_BUILDKIT_INLINE_CACHE: 1
- name: Inspect image
run: |
+4
View File
@@ -107,6 +107,10 @@ jobs:
"
echo "✓ electron/package.json version set to $VERSION_NO_V"
- name: Install fpm (Linux .deb packaging tool)
if: matrix.platform == 'linux'
run: sudo gem install fpm --no-document
- name: Install Electron dependencies
working-directory: electron
run: npm install --no-audit --no-fund
+9 -2
View File
@@ -23,7 +23,7 @@ jobs:
registry-url: https://registry.npmjs.org
- name: Install dependencies (skip scripts to avoid heavy build)
run: npm ci --ignore-scripts
run: npm install --ignore-scripts --no-audit --no-fund
- name: Sync version from release tag
run: |
@@ -39,6 +39,13 @@ jobs:
run: node scripts/prepublish.mjs
- name: Publish to npm
run: npm publish --access public
run: |
VERSION=$(node -p "require('./package.json').version")
# Check if this version is already published — skip instead of failing with E403
if npm view "omniroute@${VERSION}" version --silent 2>/dev/null | grep -q "^${VERSION}$"; then
echo "️⚠️ Version ${VERSION} is already published on npm — skipping."
exit 0
fi
npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
-1
View File
@@ -102,7 +102,6 @@ cloud/
security-analysis/
# Deploy workflow (contains sensitive VPS credentials)
.agent/workflows/deploy.md
clipr/
app.log
*.tgz
+170
View File
@@ -11,6 +11,176 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [2.3.1] — 2026-03-11
> ### TypeScript Fixes & UI Polish
### 🐛 Bug Fixes
- **OAuth Modal displayed Portuguese text regardless of language setting (#314)** — Two hardcoded PT-BR strings in `OAuthModal.tsx` (remote-access info banner and `redirect_uri_mismatch` error message) are now in English for all users (PR #325).
- **TypeScript errors in Kimi usage parser (`usage.ts`)** — `dataObj.five_hour`, `dataObj.seven_day`, and `dataObj.user` were typed as `unknown`. Wrapped with `toRecord()` before passing to typed functions — fixes 6 compiler errors on lines 921948.
- **`await` missing on `getSettings()` in `instrumentation.ts` (#316 follow-up)** — `getSettings()` is declared `async`; calling it without `await` made `settings` a `Promise` causing 4 TS errors when accessing `settings.modelAliases`.
---
## [2.3.0] — 2026-03-11
> ### Bug Fixes
### 🐛 Bug Fixes
- **Custom Model Alias (Pattern→Target) ignored during routing (#315)** — `chatCore.ts` now calls `resolveModelAlias()` before the routing format lookup so aliases configured in Settings → Model Aliases → Pattern→Target are applied correctly (PR #317).
- **Custom Model Aliases lost after server restart (#316)** — Next.js startup hook (`src/instrumentation.ts`) now restores custom aliases from `settings.modelAliases` in the DB at boot, preventing the in-memory state from resetting to empty on restart (PR #317).
- **`better-sqlite3` postinstall rebuild fails silently on macOS ARM (#312)** — Replace unreliable `process.dlopen()` detection with explicit `process.platform`/`process.arch` comparison. Rebuild now fail-fasts with a clear error on non-linux-x64 platforms (PR #313 by @ardaaltinors).
---
## [2.2.9] — 2026-03-11
> ### Features, Bug Fixes & Dependency Updates
### ✨ New Features
- **Edit custom model endpoints (#307)** — Provider detail page now shows per-row **Edit / Save / Cancel** controls for custom models. Changes to `apiFormat` and `supportedEndpoints` are now persisted via the new `PUT /api/provider-models` endpoint instead of resetting on navigation (PR #307 by @hijak).
### 🐛 Bug Fixes
- **`@swc/helpers` MODULE_NOT_FOUND on startup (#306)** — Added `@swc/helpers@0.5.19` as an explicit `dependency` and `override` in `package.json`. Global npm install (`npm install -g omniroute`) now reliably includes this transitive dependency on all platforms including Windows (PR #308).
- **Claude quota display inverted (#299)** — Claude Code's OAuth API returns `utilization` as _percent used_, not percent remaining. The quota bar was backwards: 87% used on Claude.ai = 87% "remaining" (green) in OmniRoute. Fixed `open-sse/services/usage.ts`: `remaining = 100 - utilization` (PR #309).
---
## [2.2.8] — 2026-03-11
> ### Bug Fixes
### Bug Fixes
- **Docker healthcheck wrong endpoint (#296)** — `scripts/healthcheck.mjs` now queries `/api/monitoring/health` instead of `/api/settings`. Aligns the healthcheck with all other health monitoring components (PR #301).
- **429 causes endless queue / requests hang forever (#297)** — Added `maxWait=120000` (2 min) to all Bottleneck instances. When all provider quotas are exhausted, requests now fail-fast with a clean error instead of queueing indefinitely. Configurable via `RATE_LIMIT_MAX_WAIT_MS` env var (PR #302).
---
## [2.2.7] — 2026-03-10
> ### Bug Fixes & Dependency Updates
### Bug Fixes
- **Docker startup crash (#292)** — Fixed missing `bootstrap-env.mjs` in the runtime image. The Dockerfile runner stage now copies the file from the builder stage (PR #293).
- **Google CLI stale projectId (#394)** — Antigravity and Gemini CLI executors now prefer the OAuth-stored `projectId` over `body.project` to prevent 403/404 errors from stale cached values. Includes type-safe body assignment (PR #294).
- **Tool-calling 400 errors (#291)** — Empty `name: ""` fields in `messages[]` and `input[]` are now stripped before forwarding to upstream providers (OpenAI, Codex) that reject them (PR #300).
### Dependencies
- Bump `hono` from 4.12.4 to 4.12.7 (security patch) (PR #298)
---
## [2.2.6] — 2026-03-10
> ### 🐛 Fix Claude Thinking Tokens Invisible in Passthrough Mode
### Bug Fixes
- **Claude thinking tokens not visible (#289)** — When routing through Antigravity OAuth or any Claude provider, thinking blocks were being emitted as regular `delta.content` with `<think>/<\/think>` XML wrappers. Fixed: now correctly maps `thinking_delta` events to `delta.reasoning_content` so clients like Claude Code, Cursor, and Windsurf display the thinking panel properly.
---
## [2.2.5] — 2026-03-10
> ### 🔧 Zero-Config Bootstrap · 🐛 Electron Black Screen Fix
### Features
- **Zero-config bootstrap (#252, #249)** — OmniRoute now auto-generates required secrets on first run across all deployment modes (npm, Docker, Electron Desktop App):
- `JWT_SECRET` (64-byte hex) — required for auth/sessions
- `STORAGE_ENCRYPTION_KEY` (32-byte hex) — required for SQLite encryption
- `API_KEY_SECRET` (32-byte hex) — required for API key signing
- Secrets are persisted to `{DATA_DIR}/server.env` and survive restarts, Docker volume remounts, and upgrades
- Friendly startup warnings if OAuth secrets (Antigravity, iFlow, Gemini) are not configured
- New **`scripts/bootstrap-env.mjs`** module — single source of truth for zero-config initialization
### Bug Fixes
- **Electron black screen on macOS/Windows/Linux** — The Next.js server was crashing silently because `JWT_SECRET` and `STORAGE_ENCRYPTION_KEY` are never present in desktop OS environments. Fixed by calling `bootstrapEnv()` before spawning `server.js`, with secrets persisted to Electron's `userData` directory.
- **Dashboard bootstrap banner** — Added dismissable amber warning banner on the dashboard home when running in zero-config mode, showing where `server.env` is stored and how to customize secrets.
### Note for Docker users
Previously, `--env-file .env` was required to pass secrets to the container. Now OmniRoute will generate and persist them automatically in the mounted volume. Existing `DATA_DIR` secrets are always respected.
---
## [2.2.4] — 2026-03-10
> ### 🔧 CI Fixes
### CI
- **docs-sync fix** — Updated `docs/openapi.yaml` version from `2.2.0` to `2.2.3` (was out of sync with `package.json`, causing CI lint failure)
- **CHANGELOG format** — Added required `## [Unreleased]` section at top of `CHANGELOG.md` (required by `check:docs-sync` script)
- **Electron Linux** — Added `gem install fpm` step to `electron-release.yml` Linux build job; `fpm` is required by `electron-builder` to package `.deb` installers but was not pre-installed on `ubuntu-latest` runners
- **Docker publish** — Added `DOCKER_BUILDKIT_INLINE_CACHE` env; previous `502 error writing layer blob` was a transient Docker Hub network error
---
## [2.2.3] — 2026-03-10
> ### 🐛 Bug Fixes · 🔧 Reliability
### Bug Fixes
- **Antigravity/Gemini CLI: remove fake projectId fallback (#285)** — OmniRoute was generating random fallback project IDs (e.g. `useful-fuze-a04c5`) when OAuth credentials lacked a real GCP `projectId`. This caused confusing `Permission denied on resource project` and `Verify your account` errors from Google. Now throws a clear actionable error: _reconnect OAuth so OmniRoute can load your real Cloud Code project_. Affects `antigravity.ts`, `openai-to-gemini.ts`, `geminiHelper.ts`.
- **Claude Code: filter empty-named tool_use blocks across all message roles (#288)** — Pass 1.4 only filtered empty tool names from `assistant` messages. Extended to all roles (user, system). Also filters `tool_result` blocks missing `tool_use_id`, and top-level `body.tools` declarations with empty names. Prevents `Invalid input[x].name: empty string` 400 errors from Claude API.
- **Docker: explicit @swc/helpers copy (#288)** — Added `COPY --from=builder /app/node_modules/@swc/helpers` to Dockerfile `runner-base` stage. The standalone tracer doesn't always include this package, causing runtime `MODULE_NOT_FOUND` crashloops.
---
## [2.2.2] — 2026-03-10
> ### ✨ New Features · 🔀 Model Aliases
### New Features
- **system-info.mjs (#280)** — New `npm run system-info` command that collects Node.js version, OmniRoute version, OS info, CLI tool versions (iflow, gemini, claude, codex, antigravity, droid, openclaw, kilo, cursor, aider), Docker/PM2 status, and system packages. Outputs `system-info.txt` for easy attachment to bug reports.
### Model Aliases
- **Kimi K2/K2.5 Fireworks aliases (#265)** — Built-in aliases added: `fireworks/accounts/fireworks/models/kimi-k2p5` and `kimi-k2p5``moonshotai/Kimi-K2.5`; same for `kimi-k2``moonshotai/Kimi-K2`. Fireworks long path model names now auto-resolve.
- **Mistral short aliases (#278)** — `mistral-large``mistral-large-latest`, `mistral-small``mistral-small-latest`, `codestral``codestral-latest`.
- **Llama short aliases** — `llama-3.3``llama-3.3-70b-versatile`, `llama-3-70b``llama-3.3-70b-versatile`, `llama-3-8b``llama3-8b-8192`.
- **Custom aliases** — Users can define their own aliases in **Settings → Model Aliases** tab. Example: `gpt-5.4``cx/gpt-5.4`.
---
## [2.2.1] — 2026-03-10
> ### 🐛 Bug Fixes · 🔐 Security · 🔧 CI
### Bug Fixes
- **Gemini image routing (#273)** — `gemini-3.1-flash-image-preview` was missing from the `antigravity` image provider registry in `imageRegistry.ts`, causing image generation to fall through to the chat handler. Added alongside `gemini-2.5-flash-preview-image-generation`.
- **Ollama Cloud model listing (#276)** — `ollama-cloud` was absent from `PROVIDER_MODELS_CONFIG` in the models route, causing 400 errors when listing models from `api.ollama.com`. Entry added.
- **Missing apiKey error clarity (#277)** — When login is disabled and a provider has no API key configured, the model import route now returns `400` with a clear message instead of a generic `401 Unauthorized`.
### Security
- **TLS validation re-enabled (GHSA-50)** — `mitm/server.ts`: `rejectUnauthorized` now defaults to `true`. Opt-out only via `MITM_DISABLE_TLS_VERIFY=1`.
- **Path traversal hardening (GHSA-4149)** — Added `safePath()`, `safeProfilePath()`, `safeLogPath()` helpers across `backupService.ts`, `db/backup.ts`, `codex-profiles/route.ts`, and `mitm/server.ts`. All user-supplied IDs/filenames are now anchored within their allowed directories using `path.resolve()` + bounds check.
- **Prototype pollution fix (GHSA-1820)** — `usageHistory.ts`: `pendingRequests` maps now use `Object.create(null)` + `hasOwnProperty` guards, preventing `__proto__` / `constructor` injection via crafted provider IDs.
- **Dependency: dompurify updated to ^3.3.2** — Resolves CVE-2026-0540 (XSS in rendered HTML).
- **GitHub Actions: added `permissions: contents: read`** — Prevents token over-permission in CI jobs.
### CI
- **Lock file sync** — Added `@swc/helpers: "^0.5.19"` override in `package.json`; regenerated `package-lock.json`. Fixes `npm ci` failures across `ci.yml` and `docker-publish.yml`.
- **npm-publish: skip if version exists** — Workflow now checks registry before publishing; exits cleanly with a warning instead of failing with `E403` if the version is already on npm.
- **npm-publish: use `npm install` instead of `npm ci`** — Prevents publish failures when a tag commit's lock file is slightly out of sync.
- **Lint: `cursor.ts` any-budget** — Replaced `any` with `unknown` + type narrowing in `isToolBoundaryAbort()`.
---
## [2.2.0] — 2026-03-10
> ### 🔧 Bug Fixes · Provider Support · CI Recovery
+3
View File
@@ -29,8 +29,11 @@ RUN mkdir -p /app/data
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/.next/standalone ./
# Explicitly copy @swc/helpers — not always traced by standalone output but needed at runtime
COPY --from=builder /app/node_modules/@swc/helpers ./node_modules/@swc/helpers
COPY --from=builder /app/scripts/run-standalone.mjs ./run-standalone.mjs
COPY --from=builder /app/scripts/runtime-env.mjs ./runtime-env.mjs
COPY --from=builder /app/scripts/bootstrap-env.mjs ./bootstrap-env.mjs
COPY --from=builder /app/scripts/healthcheck.mjs ./healthcheck.mjs
EXPOSE 20128
+11
View File
@@ -167,6 +167,16 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f
- **Contributing**: See [CONTRIBUTING.md](CONTRIBUTING.md), open a PR, or pick a `good first issue`
- **Original Project**: [9router by decolua](https://github.com/decolua/9router)
### 🐛 Reporting a Bug?
When opening an issue, please run the system-info command and attach the generated file:
```bash
npm run system-info
```
This generates a `system-info.txt` with your Node.js version, OmniRoute version, OS details, installed CLI tools (iflow, gemini, claude, codex, antigravity, droid, etc.), Docker/PM2 status, and system packages — everything we need to reproduce your issue quickly. Attach the file directly to your GitHub issue.
---
## 🔄 How It Works
@@ -358,6 +368,7 @@ When a call fails, the dev doesn't know if it was a rate limit, expired token, w
- **Translator Playground** — 4 debugging modes: Playground (format translation), Chat Tester (round-trip), Test Bench (batch), Live Monitor (real-time)
- **Request Telemetry** — p50/p95/p99 latency + X-Request-Id tracing
- **File-Based Logging with Rotation** — Console interceptor captures everything to JSON log with size-based rotation
- **System Info Report** — `npm run system-info` generates `system-info.txt` with your full environment (Node version, OmniRoute version, OS, CLI tools, Docker/PM2 status). Attach it when reporting issues for instant triage.
</details>
+33
View File
@@ -193,6 +193,39 @@ if (!existsSync(serverJs)) {
process.exit(1);
}
// ── Pre-flight: verify better-sqlite3 native binary ───────
// The published binary targets linux-x64. Check both platform match AND
// dlopen — on macOS, dlopen alone may succeed on an incompatible binary
// (false positive), so we check platform first as the primary signal.
const sqliteBinary = join(
APP_DIR,
"node_modules",
"better-sqlite3",
"build",
"Release",
"better_sqlite3.node"
);
if (existsSync(sqliteBinary)) {
let compatible = false;
try {
process.dlopen({ exports: {} }, sqliteBinary);
compatible = true;
} catch {
// dlopen failed — definitely incompatible
}
if (!compatible) {
console.error(
"\x1b[31m✖ better-sqlite3 native module is incompatible with this platform.\x1b[0m"
);
console.error(` Run: cd ${APP_DIR} && npm rebuild better-sqlite3`);
if (platform() === "darwin") {
console.error(" If build tools are missing: xcode-select --install");
}
process.exit(1);
}
}
// ── Start server ───────────────────────────────────────────
console.log(` \x1b[2m⏳ Starting server...\x1b[0m\n`);
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 2.2.0
version: 2.3.1
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,
+64 -1
View File
@@ -383,6 +383,69 @@ function startNextServer() {
return;
}
// ── Zero-config bootstrap: auto-generate required secrets ─────────────────
// Electron uses CJS — cannot dynamically import ESM bootstrap-env.mjs.
// This mirrors bootstrap-env.mjs logic synchronously:
// 1. Read persisted secrets from userData/server.env
// 2. Generate missing secrets with crypto.randomBytes()
// 3. Persist back to userData/server.env for future restarts
const crypto = require("crypto");
const userDataDir = app.getPath("userData");
const serverEnvPath = path.join(userDataDir, "server.env");
// Parse a simple KEY=VALUE file
function parseEnvFile(filePath) {
if (!fs.existsSync(filePath)) return {};
const env = {};
for (const line of fs.readFileSync(filePath, "utf8").split(/\r?\n/)) {
const t = line.trim();
if (!t || t.startsWith("#")) continue;
const eq = t.indexOf("=");
if (eq < 1) continue;
env[t.slice(0, eq).trim()] = t.slice(eq + 1).trim();
}
return env;
}
const persisted = parseEnvFile(serverEnvPath);
const serverEnv = { ...process.env, ...persisted };
let changed = false;
if (!serverEnv.JWT_SECRET) {
serverEnv.JWT_SECRET = persisted.JWT_SECRET = crypto.randomBytes(64).toString("hex");
changed = true;
console.log("[Electron] ✨ JWT_SECRET auto-generated");
}
if (!serverEnv.STORAGE_ENCRYPTION_KEY) {
serverEnv.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY = crypto
.randomBytes(32)
.toString("hex");
serverEnv.STORAGE_ENCRYPTION_KEY_VERSION = persisted.STORAGE_ENCRYPTION_KEY_VERSION = "v1";
changed = true;
console.log("[Electron] ✨ STORAGE_ENCRYPTION_KEY auto-generated");
}
if (!serverEnv.API_KEY_SECRET) {
serverEnv.API_KEY_SECRET = persisted.API_KEY_SECRET = crypto.randomBytes(32).toString("hex");
changed = true;
console.log("[Electron] ✨ API_KEY_SECRET auto-generated");
}
if (changed) {
serverEnv.OMNIROUTE_BOOTSTRAPPED = "true";
try {
fs.mkdirSync(userDataDir, { recursive: true });
const lines = [
"# Auto-generated by OmniRoute bootstrap",
"",
...Object.entries(persisted).map(([k, v]) => `${k}=${v}`),
"",
];
fs.writeFileSync(serverEnvPath, lines.join("\n"), "utf8");
console.log("[Electron] 📁 Secrets persisted to:", serverEnvPath);
} catch (e) {
console.warn("[Electron] Could not persist secrets:", e.message);
}
}
console.log("[Electron] Starting Next.js server on port", serverPort);
sendToRenderer("server-status", { status: "starting", port: serverPort });
@@ -390,7 +453,7 @@ function startNextServer() {
nextServer = spawn("node", [serverScript], {
cwd: NEXT_SERVER_PATH,
env: {
...process.env,
...serverEnv,
PORT: String(serverPort),
NODE_ENV: "production",
},
+4 -1
View File
@@ -63,7 +63,10 @@ export const IMAGE_PROVIDERS = {
authType: "oauth",
authHeader: "bearer",
format: "gemini-image", // Special format: uses Gemini generateContent API
models: [{ id: "gemini-2.5-flash-preview-image-generation", name: "Nano Banana" }],
models: [
{ id: "gemini-2.5-flash-preview-image-generation", name: "Gemini 2.5 Flash Image" },
{ id: "gemini-3.1-flash-image-preview", name: "Gemini 3.1 Flash Image Preview" },
],
supportedSizes: ["1024x1024"],
},
+10 -13
View File
@@ -38,14 +38,17 @@ export class AntigravityExecutor extends BaseExecutor {
transformRequest(model, body, stream, credentials) {
const bodyProjectId = body?.project;
const credentialsProjectId = credentials?.projectId;
const hasExplicitProject = !!(bodyProjectId || credentialsProjectId);
const projectId = bodyProjectId || credentialsProjectId || this.generateProjectId();
const allowBodyProjectOverride = process.env.OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE === "1";
if (!hasExplicitProject) {
console.warn(
`[Antigravity] ⚠️ No projectId provided via body or credentials — using generated fallback "${projectId}". ` +
`This may cause 404 errors if the account has no active GCP project. ` +
`Ensure the OAuth token includes a valid project or the request includes a project field.`
// Default: prefer OAuth-stored projectId over incoming body.project to avoid
// stale/wrong client-side values causing 404/403 from Cloud Code endpoints.
// Opt-in escape hatch: set OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE=1.
const projectId =
allowBodyProjectOverride && bodyProjectId ? bodyProjectId : credentialsProjectId || bodyProjectId;
if (!projectId) {
throw new Error(
"Missing Google projectId for Antigravity account. Please reconnect OAuth so OmniRoute can fetch your real Cloud Code project (loadCodeAssist)."
);
}
@@ -128,12 +131,6 @@ export class AntigravityExecutor extends BaseExecutor {
}
}
generateProjectId() {
const adj = ["useful", "bright", "swift", "calm", "bold"][Math.floor(Math.random() * 5)];
const noun = ["fuze", "wave", "spark", "flow", "core"][Math.floor(Math.random() * 5)];
return `${adj}-${noun}-${crypto.randomUUID().slice(0, 5)}`;
}
generateSessionId() {
return `-${Math.floor(Math.random() * 9_000_000_000_000_000_000)}`;
}
+10 -5
View File
@@ -148,12 +148,17 @@ function parseCursorJsonErrorFrame(text: string) {
}
}
function isToolBoundaryAbort(jsonError: any, toolCallCount: number) {
function isToolBoundaryAbort(jsonError: unknown, toolCallCount: number) {
if (!jsonError || toolCallCount <= 0) return false;
const code = jsonError?.error?.code || "";
const debugError = jsonError?.error?.details?.[0]?.debug?.error || "";
const title = jsonError?.error?.details?.[0]?.debug?.details?.title || "";
const detail = jsonError?.error?.details?.[0]?.debug?.details?.detail || "";
const e = jsonError as Record<string, unknown>;
const err = e?.error as Record<string, unknown> | undefined;
const details = (err?.details as Record<string, unknown>[] | undefined)?.[0];
const debug = details?.debug as Record<string, unknown> | undefined;
const debugDetails = debug?.details as Record<string, unknown> | undefined;
const code = (err?.code as string) || "";
const debugError = (debug?.error as string) || "";
const title = (debugDetails?.title as string) || "";
const detail = (debugDetails?.detail as string) || "";
const message = `${title} ${detail}`.toLowerCase();
const isAbortedCode = code === "aborted" || debugError === "ERROR_USER_ABORTED_REQUEST";
return isAbortedCode && message.includes("tool call ended before result was received");
+10 -1
View File
@@ -20,7 +20,16 @@ export class GeminiCLIExecutor extends BaseExecutor {
}
transformRequest(model, body, stream, credentials) {
if (!body.project && credentials?.projectId) {
const allowBodyProjectOverride = process.env.OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE === "1";
// Default: prefer OAuth-stored projectId. Incoming body.project can be stale
// when clients cache older Cloud Code project values.
// Opt-in escape hatch: set OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE=1.
if (allowBodyProjectOverride && body?.project) {
return body;
}
if (credentials?.projectId) {
body.project = credentials.projectId;
}
return body;
+29 -1
View File
@@ -12,6 +12,7 @@ import { addBufferToUsage, filterUsageForFormat, estimateUsage } from "../utils/
import { refreshWithRetry } from "../services/tokenRefresh.ts";
import { createRequestLogger } from "../utils/requestLogger.ts";
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts";
import { resolveModelAlias } from "../services/modelDeprecation.ts";
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.ts";
import { HTTP_STATUS } from "../config/constants.ts";
import { handleBypassRequest } from "../utils/bypassHandler.ts";
@@ -105,8 +106,13 @@ export async function handleChatCore({
// Detect source format and get target format
// Model-specific targetFormat takes priority over provider default
// Apply custom model aliases (Settings → Model Aliases → Pattern→Target) before routing (#315)
// Custom aliases take priority over built-in and must be resolved here so the
// downstream getModelTargetFormat() lookup uses the correct, aliased model ID.
const resolvedModel = resolveModelAlias(model);
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
const modelTargetFormat = getModelTargetFormat(alias, model);
const modelTargetFormat = getModelTargetFormat(alias, resolvedModel);
const targetFormat = modelTargetFormat || getTargetFormat(provider);
// Default to false unless client explicitly sets stream: true (OpenAI spec compliant)
@@ -158,6 +164,28 @@ export async function handleChatCore({
translatedBody = { ...translatedBody, _disableToolPrefix: true };
}
// ── #291: Strip empty name fields from messages/input items ──
// Upstream providers (OpenAI, Codex) reject name:"" with 400 errors.
// Clients like PocketPaw may forward empty name fields from assistant turns.
if (Array.isArray(body.messages)) {
body.messages = body.messages.map((msg: Record<string, unknown>) => {
if (msg.name === "") {
const { name: _n, ...rest } = msg;
return rest;
}
return msg;
});
}
if (Array.isArray(body.input)) {
body.input = body.input.map((item: Record<string, unknown>) => {
if (item.name === "") {
const { name: _n, ...rest } = item;
return rest;
}
return item;
});
}
translatedBody = translateRequest(
sourceFormat,
targetFormat,
+18
View File
@@ -31,6 +31,24 @@ const BUILT_IN_ALIASES: Record<string, string> = {
"gpt-4-0125-preview": "gpt-4-turbo",
"gpt-4-1106-preview": "gpt-4-turbo",
"gpt-3.5-turbo-0125": "gpt-3.5-turbo",
// Kimi/Moonshot — Fireworks long-path aliases (#265)
"accounts/fireworks/models/kimi-k2p5": "moonshotai/Kimi-K2.5",
"fireworks/accounts/fireworks/models/kimi-k2p5": "moonshotai/Kimi-K2.5",
"kimi-k2p5": "moonshotai/Kimi-K2.5",
"accounts/fireworks/models/kimi-k2": "moonshotai/Kimi-K2",
"fireworks/accounts/fireworks/models/kimi-k2": "moonshotai/Kimi-K2",
"kimi-k2": "moonshotai/Kimi-K2",
// Mistral short aliases
"mistral-large": "mistral-large-latest",
"mistral-small": "mistral-small-latest",
codestral: "codestral-latest",
// Llama short aliases
"llama-3.3": "llama-3.3-70b-versatile",
"llama-3-70b": "llama-3.3-70b-versatile",
"llama-3-8b": "llama3-8b-8192",
};
// ── Custom Aliases (persisted via Settings API) ─────────────────────────────
+8
View File
@@ -59,6 +59,11 @@ const PERSIST_DEBOUNCE_MS = 60_000; // Debounce persistence to every 60s max
// Track initialization
let initialized = false;
// Max time (ms) a job can wait in queue before failing with a timeout error.
// Prevents infinite queuing when all providers are exhausted after a 429.
// Configurable via RATE_LIMIT_MAX_WAIT_MS env var (default: 2 minutes).
const MAX_WAIT_MS = parseInt(process.env.RATE_LIMIT_MAX_WAIT_MS || "120000", 10);
// Default conservative settings (before we learn from headers)
const DEFAULT_SETTINGS = {
maxConcurrent: 10,
@@ -66,6 +71,7 @@ const DEFAULT_SETTINGS = {
reservoir: null, // No initial reservoir — unlimited until we learn
reservoirRefreshAmount: null,
reservoirRefreshInterval: null,
maxWait: MAX_WAIT_MS, // Fail-fast: don't queue forever on 429 exhaustion
};
/**
@@ -111,6 +117,7 @@ export async function initializeRateLimits() {
reservoir: rpm,
reservoirRefreshAmount: rpm,
reservoirRefreshInterval: 60 * 1000,
maxWait: MAX_WAIT_MS,
id: key,
})
);
@@ -135,6 +142,7 @@ export async function initializeRateLimits() {
reservoir: DEFAULT_API_LIMITS.requestsPerMinute,
reservoirRefreshAmount: DEFAULT_API_LIMITS.requestsPerMinute,
reservoirRefreshInterval: 60 * 1000, // Refresh every minute
maxWait: MAX_WAIT_MS,
id: key,
})
);
+12 -9
View File
@@ -488,13 +488,14 @@ async function getClaudeUsage(accessToken) {
const data = await oauthResponse.json();
const quotas: Record<string, UsageQuota> = {};
// utilization = percentage REMAINING (e.g., 90 means 90% remaining, 10% used)
// utilization = percentage USED (e.g., 90 means 90% used, 10% remaining)
// Confirmed via user report #299: Claude.ai shows 87% used = OmniRoute must show 13% remaining.
const hasUtilization = (window: JsonRecord) =>
window && typeof window === "object" && safePercentage(window.utilization) !== undefined;
const createQuotaObject = (window: JsonRecord) => {
const remaining = safePercentage(window.utilization) as number;
const used = 100 - remaining;
const used = safePercentage(window.utilization) as number; // utilization = % used
const remaining = Math.max(0, 100 - used);
return {
used,
total: 100,
@@ -917,12 +918,12 @@ async function getKimiUsage(accessToken) {
};
};
if (hasUtilization(dataObj.five_hour)) {
quotas["session (5h)"] = createQuotaObject(dataObj.five_hour);
if (hasUtilization(toRecord(dataObj.five_hour))) {
quotas["session (5h)"] = createQuotaObject(toRecord(dataObj.five_hour));
}
if (hasUtilization(dataObj.seven_day)) {
quotas["weekly (7d)"] = createQuotaObject(dataObj.seven_day);
if (hasUtilization(toRecord(dataObj.seven_day))) {
quotas["weekly (7d)"] = createQuotaObject(toRecord(dataObj.seven_day));
}
// Check for model-specific quotas
@@ -935,7 +936,8 @@ async function getKimiUsage(accessToken) {
}
if (Object.keys(quotas).length > 0) {
const membershipLevel = dataObj.user?.membership?.level;
const userRecord = toRecord(dataObj.user);
const membershipLevel = toRecord(userRecord.membership).level;
const planName = getKimiPlanName(membershipLevel);
return {
plan: planName || "Kimi Coding",
@@ -944,7 +946,8 @@ async function getKimiUsage(accessToken) {
}
// No quota data in response
const membershipLevel = dataObj.user?.membership?.level;
const userRecord = toRecord(dataObj.user);
const membershipLevel = toRecord(userRecord.membership).level;
const planName = getKimiPlanName(membershipLevel);
return {
plan: planName || "Kimi Coding",
+12 -2
View File
@@ -127,14 +127,24 @@ export function prepareClaudeRequest(body, provider = null) {
}
// Pass 1.4: Filter out tool_use blocks with empty names (causes Claude 400 error)
// Apply to ALL roles (assistant tool_use + any user messages that may carry tool_use)
// Also filter tool_result blocks with missing tool_use_id
for (const msg of filtered) {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
if (Array.isArray(msg.content)) {
msg.content = msg.content.filter(
(block) => block.type !== "tool_use" || (block.name && block.name.trim())
(block) => block.type !== "tool_use" || (block.name && block.name?.trim())
);
msg.content = msg.content.filter(
(block) => block.type !== "tool_result" || block.tool_use_id
);
}
}
// Also filter top-level tool declarations with empty names
if (body.tools && Array.isArray(body.tools)) {
body.tools = body.tools.filter((tool) => tool.name && tool.name?.trim());
}
// Pass 1.5: Fix tool_use/tool_result ordering
// Each tool_use must have tool_result in the NEXT message (not same message with other content)
filtered = fixToolUseOrdering(filtered);
@@ -126,15 +126,6 @@ export function generateSessionId() {
return `-${Math.floor(Math.random() * 9000000000000000000)}`;
}
// Generate project ID
export function generateProjectId() {
const adjectives = ["useful", "bright", "swift", "calm", "bold"];
const nouns = ["fuze", "wave", "spark", "flow", "core"];
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
return `${adj}-${noun}-${crypto.randomUUID().slice(0, 5)}`;
}
// Helper: Remove unsupported keywords recursively from object/array
function removeUnsupportedKeywords(obj, keywords) {
if (!obj || typeof obj !== "object") return;
@@ -175,6 +175,9 @@ export function openaiToClaudeRequest(model, body, stream) {
};
});
// Filter out tools with empty names (would cause Claude 400 error)
result.tools = result.tools.filter((tool) => tool.name && tool.name?.trim());
// Add cache_control to last tool that doesn't have defer_loading
// Tools with defer_loading=true cannot have cache_control (API rejects it)
for (let i = result.tools.length - 1; i >= 0; i--) {
@@ -227,6 +230,8 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPr
if (part.type === "text" && part.text) {
blocks.push({ type: "text", text: part.text });
} else if (part.type === "tool_result") {
// Skip tool_result with no tool_use_id (would be useless and may cause errors)
if (!part.tool_use_id) continue;
blocks.push({
type: "tool_result",
tool_use_id: part.tool_use_id,
@@ -15,7 +15,6 @@ import {
tryParseJSON,
generateRequestId,
generateSessionId,
generateProjectId,
cleanJSONSchemaForAntigravity,
} from "../helpers/geminiHelper.ts";
@@ -321,13 +320,11 @@ export function openaiToGeminiCLIRequest(model, body, stream) {
// Wrap Gemini CLI format in Cloud Code wrapper
function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigravity = false) {
const hasRealProject = !!credentials?.projectId;
const projectId = credentials?.projectId || generateProjectId();
const projectId = credentials?.projectId;
if (!hasRealProject) {
console.warn(
`[${isAntigravity ? "Antigravity" : "GeminiCLI"}] ⚠️ No projectId in credentials — using generated fallback "${projectId}". ` +
`This may cause 404 errors. Ensure the OAuth token includes a valid GCP project.`
if (!projectId) {
throw new Error(
`${isAntigravity ? "Antigravity" : "GeminiCLI"} account is missing projectId. Reconnect OAuth to load your real Cloud Code project before sending requests.`
);
}
@@ -374,13 +371,11 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra
}
function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = null) {
const hasRealProject = !!credentials?.projectId;
const projectId = credentials?.projectId || generateProjectId();
const projectId = credentials?.projectId;
if (!hasRealProject) {
console.warn(
`[Antigravity/Claude] ⚠️ No projectId in credentials — using generated fallback "${projectId}". ` +
`This may cause 404 errors. Ensure the OAuth token includes a valid GCP project.`
if (!projectId) {
throw new Error(
"Antigravity/Claude account is missing projectId. Reconnect OAuth to load your real Cloud Code project before sending requests."
);
}
@@ -51,7 +51,9 @@ export function claudeToOpenAIResponse(chunk, state) {
} else if (block?.type === "thinking") {
state.inThinkingBlock = true;
state.currentBlockIndex = chunk.index;
results.push(createChunk(state, { content: "<think>" }));
// Emit empty reasoning_content to signal thinking block start
// (clients like Claude Code look for reasoning_content, not <think> tags)
results.push(createChunk(state, { reasoning_content: "" }));
} else if (block?.type === "tool_use") {
const toolCallIndex = state.toolCallIndex++;
// Restore original tool name from mapping (Claude OAuth)
@@ -76,7 +78,9 @@ export function claudeToOpenAIResponse(chunk, state) {
if (delta?.type === "text_delta" && delta.text) {
results.push(createChunk(state, { content: delta.text }));
} else if (delta?.type === "thinking_delta" && delta.thinking) {
results.push(createChunk(state, { content: delta.thinking }));
// Map Claude thinking_delta → OpenAI reasoning_content
// Clients (Claude Code, Cursor, etc.) display reasoning_content as the thinking panel
results.push(createChunk(state, { reasoning_content: delta.thinking }));
} else if (delta?.type === "input_json_delta" && delta.partial_json) {
const toolCall = state.toolCalls.get(chunk.index);
if (toolCall) {
@@ -99,7 +103,8 @@ export function claudeToOpenAIResponse(chunk, state) {
case "content_block_stop": {
if (state.inThinkingBlock && chunk.index === state.currentBlockIndex) {
results.push(createChunk(state, { content: "</think>" }));
// Thinking block closed — no additional content needed;
// reasoning_content chunks have already been streamed
state.inThinkingBlock = false;
}
state.textBlockStarted = false;
+26 -11
View File
@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "2.2.0",
"version": "2.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "2.2.0",
"version": "2.3.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -15,9 +15,11 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"@monaco-editor/react": "^4.7.0",
"@swc/helpers": "0.5.19",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.6.2",
"bottleneck": "^2.19.5",
"dompurify": "^3.3.2",
"express": "^5.2.1",
"fetch-socks": "^1.3.2",
"http-proxy-middleware": "^3.0.5",
@@ -2985,9 +2987,9 @@
"license": "Apache-2.0"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
@@ -5674,10 +5676,13 @@
}
},
"node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@@ -6864,6 +6869,7 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -7207,9 +7213,9 @@
}
},
"node_modules/hono": {
"version": "4.12.4",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.4.tgz",
"integrity": "sha512-ooiZW1Xy8rQ4oELQ++otI2T9DsKpV0M6c6cO6JGx4RTfav9poFFLlet9UMXHZnoM1yG0HWGlQLswBGX3RZmHtg==",
"version": "4.12.7",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -8773,6 +8779,15 @@
"marked": "14.0.0"
}
},
"node_modules/monaco-editor/node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+9 -3
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "2.2.0",
"version": "2.3.1",
"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": {
@@ -76,7 +76,8 @@
"check": "npm run lint && npm run test",
"prepublishOnly": "npm run build:cli",
"postinstall": "node scripts/postinstall.mjs",
"prepare": "husky"
"prepare": "husky",
"system-info": "node scripts/system-info.mjs"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
@@ -84,6 +85,7 @@
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.6.2",
"bottleneck": "^2.19.5",
"dompurify": "^3.3.2",
"express": "^5.2.1",
"fetch-socks": "^1.3.2",
"http-proxy-middleware": "^3.0.5",
@@ -107,7 +109,8 @@
"uuid": "^13.0.0",
"wreq-js": "^2.0.1",
"zod": "^4.3.6",
"zustand": "^5.0.10"
"zustand": "^5.0.10",
"@swc/helpers": "0.5.19"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
@@ -138,5 +141,8 @@
"*.{json,md,yml,yaml,css}": [
"prettier --write"
]
},
"overrides": {
"@swc/helpers": "0.5.19"
}
}
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env node
/**
* OmniRoute — Zero-Config Bootstrap
*
* Auto-generates required secrets (JWT_SECRET, STORAGE_ENCRYPTION_KEY) if
* missing or empty, persists them to {DATA_DIR}/server.env so they survive
* restarts, Docker volume remounts, and upgrades.
*
* Works across all deployment modes:
* - npm / CLI: called from run-standalone.mjs and run-next.mjs
* - Docker: same, secrets persisted in mounted volume
* - Electron: called from main.js startup, persisted in userData
*
* Priority (lowest → highest):
* 1. Auto-generated defaults
* 2. {DATA_DIR}/server.env (persisted on first boot)
* 3. .env in CWD (user overrides)
* 4. process.env (shell / Docker -e flags, highest priority)
*/
import { createHash, randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
// ── OAuth secrets that are optional but warn if missing ─────────────────────
const OPTIONAL_OAUTH_SECRETS = [
{ key: "ANTIGRAVITY_OAUTH_CLIENT_SECRET", label: "Antigravity OAuth" },
{ key: "IFLOW_OAUTH_CLIENT_SECRET", label: "iFlow OAuth" },
{ key: "GEMINI_OAUTH_CLIENT_SECRET", label: "Gemini OAuth" },
];
// ── Resolve DATA_DIR (mirrors dataPaths.ts logic) ───────────────────────────
function resolveDataDir(overridePath) {
if (overridePath) return resolve(overridePath);
const configured = process.env.DATA_DIR?.trim();
if (configured) return resolve(configured);
if (process.platform === "win32") {
const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
return join(appData, "omniroute");
}
const xdg = process.env.XDG_CONFIG_HOME?.trim();
if (xdg) return join(resolve(xdg), "omniroute");
return join(homedir(), ".omniroute");
}
// ── Parse a simple KEY=VALUE env file ───────────────────────────────────────
function parseEnvFile(filePath) {
if (!existsSync(filePath)) return {};
const env = {};
const lines = readFileSync(filePath, "utf8").split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx < 1) continue;
const key = trimmed.slice(0, eqIdx).trim();
const val = trimmed.slice(eqIdx + 1).trim();
env[key] = val;
}
return env;
}
// ── Write a simple KEY=VALUE env file ───────────────────────────────────────
function writeEnvFile(filePath, env) {
const lines = [
"# Auto-generated by OmniRoute bootstrap — do not delete",
`# Created: ${new Date().toISOString()}`,
"",
...Object.entries(env).map(([k, v]) => `${k}=${v}`),
"",
];
writeFileSync(filePath, lines.join("\n"), "utf8");
}
// ── Main bootstrap function ──────────────────────────────────────────────────
/**
* @param {{ dataDirOverride?: string; quiet?: boolean }} options
* @returns {Record<string, string>} merged env to pass to child process
*/
export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) {
const log = quiet ? () => {} : (msg) => process.stderr.write(`[bootstrap] ${msg}\n`);
const dataDir = resolveDataDir(dataDirOverride);
const serverEnvPath = join(dataDir, "server.env");
const dotEnvPath = join(process.cwd(), ".env");
// ── Layer 1: Load persisted server.env ────────────────────────────────────
let persisted = parseEnvFile(serverEnvPath);
// ── Layer 2: Load .env from CWD (user overrides, higher priority) ─────────
const dotEnv = parseEnvFile(dotEnvPath);
// ── Merge: persisted < .env < process.env ─────────────────────────────────
const merged = { ...persisted, ...dotEnv, ...process.env };
// ── Auto-generate required secrets ────────────────────────────────────────
let needsPersist = false;
if (!merged.JWT_SECRET?.trim()) {
persisted.JWT_SECRET = randomBytes(64).toString("hex");
merged.JWT_SECRET = persisted.JWT_SECRET;
needsPersist = true;
log("✨ JWT_SECRET auto-generated (first run)");
}
if (!merged.STORAGE_ENCRYPTION_KEY?.trim()) {
persisted.STORAGE_ENCRYPTION_KEY = randomBytes(32).toString("hex");
merged.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY;
needsPersist = true;
log("✨ STORAGE_ENCRYPTION_KEY auto-generated (first run)");
}
if (!merged.STORAGE_ENCRYPTION_KEY_VERSION?.trim()) {
persisted.STORAGE_ENCRYPTION_KEY_VERSION = "v1";
merged.STORAGE_ENCRYPTION_KEY_VERSION = persisted.STORAGE_ENCRYPTION_KEY_VERSION;
needsPersist = true;
}
if (!merged.API_KEY_SECRET?.trim()) {
persisted.API_KEY_SECRET = randomBytes(32).toString("hex");
merged.API_KEY_SECRET = persisted.API_KEY_SECRET;
needsPersist = true;
log("✨ API_KEY_SECRET auto-generated (first run)");
}
// ── Persist new secrets ────────────────────────────────────────────────────
if (needsPersist) {
try {
mkdirSync(dataDir, { recursive: true });
// Only persist keys that we auto-generated (not .env or process.env vals)
writeEnvFile(serverEnvPath, persisted);
log(`📁 Secrets persisted to: ${serverEnvPath}`);
} catch (e) {
log(`⚠️ Could not persist secrets to ${serverEnvPath}: ${e.message}`);
}
}
// ── Mark as bootstrapped ───────────────────────────────────────────────────
if (needsPersist) {
merged.OMNIROUTE_BOOTSTRAPPED = "true";
}
// ── Warn about missing optional OAuth secrets ──────────────────────────────
const missingOauth = OPTIONAL_OAUTH_SECRETS.filter(({ key }) => !merged[key]?.trim());
if (missingOauth.length > 0) {
log("️ The following OAuth integrations are not configured:");
for (const { key, label } of missingOauth) {
log(`${label} (${key}) — set in .env or ${serverEnvPath}`);
}
log(" These providers will not work until configured.");
}
// ── Warn about default password ────────────────────────────────────────────
if (merged.INITIAL_PASSWORD === "CHANGEME" || !merged.INITIAL_PASSWORD?.trim()) {
log("⚠️ INITIAL_PASSWORD is not set — using default 'CHANGEME'. Change it in Settings!");
}
return merged;
}
// ── CLI usage: node scripts/bootstrap-env.mjs ──────────────────────────────
if (process.argv[1] && process.argv[1].endsWith("bootstrap-env.mjs")) {
const env = bootstrapEnv();
process.stderr.write(`[bootstrap] Done. DATA_DIR resolved to: ${resolveDataDir()}\n`);
process.stderr.write(`[bootstrap] JWT_SECRET length: ${env.JWT_SECRET?.length ?? 0}\n`);
process.stderr.write(
`[bootstrap] STORAGE_ENCRYPTION_KEY length: ${env.STORAGE_ENCRYPTION_KEY?.length ?? 0}\n`
);
}
+2 -2
View File
@@ -2,12 +2,12 @@
/**
* Docker healthcheck script for OmniRoute.
* Checks the /api/settings endpoint on the dashboard port.
* Checks the /api/monitoring/health endpoint on the dashboard port.
* Used by Dockerfile and docker-compose files.
*/
const port = process.env.DASHBOARD_PORT || process.env.PORT || "20128";
fetch(`http://127.0.0.1:${port}/api/settings`)
fetch(`http://127.0.0.1:${port}/api/monitoring/health`)
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
})
+37 -13
View File
@@ -30,14 +30,20 @@ if (!existsSync(appNodeModules)) {
const buildInfoPath = join(appNodeModules, "build", "Release", "better_sqlite3.node");
// Quick check: try to load the native module
try {
// Use a dynamic import-like approach — try to dlopen the .node file
process.dlopen({ exports: {} }, buildInfoPath);
// If it loaded, the binary is compatible — nothing to do
process.exit(0);
} catch {
// Binary is incompatible — rebuild
// The published binary is compiled for linux-x64.
// On any other platform/arch, we must rebuild — dlopen alone is unreliable
// because macOS may load an incompatible binary without throwing.
const BUILD_PLATFORM = "linux";
const BUILD_ARCH = "x64";
const needsRebuild = process.platform !== BUILD_PLATFORM || process.arch !== BUILD_ARCH;
if (!needsRebuild) {
try {
process.dlopen({ exports: {} }, buildInfoPath);
process.exit(0);
} catch {
// Same platform but binary still incompatible (e.g. Node.js ABI mismatch) — rebuild
}
}
console.log(`\n 🔧 Rebuilding better-sqlite3 for ${process.platform}-${process.arch}...`);
@@ -48,10 +54,28 @@ try {
stdio: "inherit",
timeout: 120_000,
});
console.log(" ✅ Native module rebuilt successfully!\n");
} catch (error) {
console.warn(" ⚠️ Failed to rebuild better-sqlite3 automatically.");
console.warn(" You can fix this manually by running:");
console.warn(` cd ${join(ROOT, "app")} && npm rebuild better-sqlite3\n`);
// Don't fail the install — the user can fix manually
console.error(" Failed to rebuild better-sqlite3 automatically.");
console.error(" You can fix this manually by running:");
console.error(` cd ${join(ROOT, "app")} && npm rebuild better-sqlite3`);
if (process.platform === "darwin") {
console.error(" If build tools are missing: xcode-select --install");
}
console.error("");
process.exit(1);
}
// Verify the rebuilt binary actually loads
try {
process.dlopen({ exports: {} }, buildInfoPath);
console.log(" ✅ Native module rebuilt successfully!\n");
} catch {
console.error(" ❌ Rebuild completed but binary is still incompatible.");
console.error(" Try manually:");
console.error(` cd ${join(ROOT, "app")} && npm rebuild better-sqlite3`);
if (process.platform === "darwin") {
console.error(" If build tools are missing: xcode-select --install");
}
console.error("");
process.exit(1);
}
+5 -1
View File
@@ -5,12 +5,16 @@ import {
withRuntimePortEnv,
spawnWithForwardedSignals,
} from "./runtime-env.mjs";
import { bootstrapEnv } from "./bootstrap-env.mjs";
const mode = process.argv[2] === "start" ? "start" : "dev";
const runtimePorts = resolveRuntimePorts();
const { dashboardPort } = runtimePorts;
// Auto-generate secrets on first run, merge .env + process.env
const env = bootstrapEnv();
const args = ["./node_modules/next/dist/bin/next", mode, "--port", String(dashboardPort)];
if (mode === "dev") {
args.splice(2, 0, "--webpack");
@@ -18,5 +22,5 @@ if (mode === "dev") {
spawnWithForwardedSignals(process.execPath, args, {
stdio: "inherit",
env: withRuntimePortEnv(process.env, runtimePorts),
env: withRuntimePortEnv(env, runtimePorts),
});
+5 -1
View File
@@ -5,10 +5,14 @@ import {
withRuntimePortEnv,
spawnWithForwardedSignals,
} from "./runtime-env.mjs";
import { bootstrapEnv } from "./bootstrap-env.mjs";
const runtimePorts = resolveRuntimePorts();
// Auto-generate secrets on first run, merge .env + process.env
const env = bootstrapEnv();
spawnWithForwardedSignals("node", ["server.js"], {
stdio: "inherit",
env: withRuntimePortEnv(process.env, runtimePorts),
env: withRuntimePortEnv(env, runtimePorts),
});
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env node
/**
* system-info.mjs — OmniRoute System Information Reporter (#280)
*
* Collects system/environment info for bug reports.
* Usage: node scripts/system-info.mjs [--output system-info.txt]
*
* Output includes:
* - Node.js version
* - OmniRoute version
* - OS info
* - Relevant system packages (if apt available)
* - Agent CLI tools (iflow, gemini, claude, codex, antigravity, droid, etc.)
* - Docker / PM2 status
*/
import { execSync } from "child_process";
import { readFileSync, writeFileSync, existsSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
import os from "os";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, "..");
// ── Helpers ────────────────────────────────────────────────────────────────
function run(cmd, fallback = "N/A") {
try {
return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
} catch {
return fallback;
}
}
function toolVersion(cmd, args = "--version") {
const version = run(`${cmd} ${args}`, null);
if (version === null) return "not installed";
// Trim to first line, remove prefixes like "v", "Version: "
return version
.split("\n")[0]
.replace(/^(version\s*:?\s*|v)/i, "")
.trim();
}
function section(title) {
const line = "─".repeat(60);
return `\n${line}\n ${title}\n${line}\n`;
}
// ── Collect Info ──────────────────────────────────────────────────────────
const lines = [];
lines.push("OmniRoute System Information Report");
lines.push(`Generated: ${new Date().toISOString()}`);
// ── Node.js & Runtime ────────────────────────────────────────────────────
lines.push(section("Node.js & Runtime"));
lines.push(`Node.js: ${process.version}`);
lines.push(`npm: v${run("npm --version")}`);
lines.push(`Platform: ${process.platform} (${process.arch})`);
lines.push(`OS: ${os.type()} ${os.release()} (${os.arch()})`);
lines.push(`Hostname: ${os.hostname()}`);
lines.push(`CPUs: ${os.cpus().length}x ${os.cpus()[0]?.model || "unknown"}`);
lines.push(`Total RAM: ${Math.round(os.totalmem() / 1024 / 1024)} MB`);
lines.push(`Free RAM: ${Math.round(os.freemem() / 1024 / 1024)} MB`);
// ── OmniRoute Version ────────────────────────────────────────────────────
lines.push(section("OmniRoute"));
try {
const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf-8"));
lines.push(`Version: ${pkg.version}`);
lines.push(`Name: ${pkg.name}`);
} catch {
lines.push("Version: unable to read package.json");
}
const installedGlobal = run("npm list -g omniroute --depth=0 2>/dev/null | grep omniroute");
lines.push(`Global npm: ${installedGlobal || "not installed globally"}`);
const pm2Status = run("pm2 list 2>/dev/null | grep omniroute | awk '{print $4, $10, $12}'");
lines.push(`PM2 status: ${pm2Status || "not running via PM2"}`);
// ── Agent CLI Tools ──────────────────────────────────────────────────────
lines.push(section("Agent CLI Tools"));
const cliTools = [
{ name: "iflow-cli", cmd: "iflow", args: "--version" },
{ name: "gemini-cli", cmd: "gemini", args: "--version" },
{ name: "claude-code", cmd: "claude", args: "--version" },
{ name: "openai-codex", cmd: "codex", args: "--version" },
{ name: "antigravity", cmd: "antigravity", args: "--version" },
{ name: "droid", cmd: "droid", args: "--version" },
{ name: "openclaw", cmd: "openclaw", args: "--version" },
{ name: "kilo", cmd: "kilo", args: "--version" },
{ name: "cursor", cmd: "cursor", args: "--version" },
{ name: "aider", cmd: "aider", args: "--version" },
];
for (const { name, cmd, args } of cliTools) {
const v = toolVersion(cmd, args);
lines.push(`${name.padEnd(20)} ${v}`);
}
// ── Docker ───────────────────────────────────────────────────────────────
lines.push(section("Docker"));
lines.push(`Docker: ${run("docker --version", "not installed")}`);
const dockerContainers = run(
"docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' 2>/dev/null",
"N/A"
);
lines.push(`Containers:\n${dockerContainers}`);
// ── System Packages ──────────────────────────────────────────────────────
lines.push(section("System Packages (relevant)"));
const relevantPkgs = ["build-essential", "libssl-dev", "openssl", "libsqlite3-dev", "python3"];
for (const pkg of relevantPkgs) {
const ver = run(`dpkg -l ${pkg} 2>/dev/null | grep '^ii' | awk '{print $3}'`, "not found");
lines.push(`${pkg.padEnd(24)} ${ver}`);
}
// ── Environment Variables (safe subset) ─────────────────────────────────
lines.push(section("Environment Variables (non-sensitive)"));
const safeEnvKeys = [
"NODE_ENV",
"PORT",
"DATA_DIR",
"DB_BACKUPS_DIR",
"LOG_LEVEL",
"NEXT_PUBLIC_APP_URL",
"ROUTER_API_KEY_HINT",
];
for (const key of safeEnvKeys) {
const val = process.env[key];
if (val !== undefined) {
// Mask if looks like a secret
const masked = val.length > 8 ? val.substring(0, 4) + "****" : "****";
lines.push(`${key.padEnd(28)} ${masked}`);
}
}
// ── Output ───────────────────────────────────────────────────────────────
const report = lines.join("\n") + "\n";
// Write to file
const outArg = process.argv.find((a) => a.startsWith("--output="));
const outFile = outArg
? outArg.replace("--output=", "")
: process.argv[process.argv.indexOf("--output") + 1] || "system-info.txt";
const outPath = join(ROOT, outFile);
writeFileSync(outPath, report);
console.log(report);
console.log(`\n✅ Report saved to: ${outPath}`);
console.log(
`📎 Attach this file when reporting issues at: https://github.com/diegosouzapw/OmniRoute/issues`
);
@@ -0,0 +1,48 @@
"use client";
import { useState } from "react";
/**
* Shown when OmniRoute was started with auto-generated secrets (zero-config mode).
* The banner is dismissable and persists only for the current session.
*/
export default function BootstrapBanner() {
const [dismissed, setDismissed] = useState(false);
if (dismissed) return null;
// Determine default data dir hint based on platform hint from user-agent
const dataDir =
typeof navigator !== "undefined" && navigator.platform?.startsWith("Win")
? "%APPDATA%\\omniroute\\server.env"
: "~/.omniroute/server.env";
return (
<div
role="alert"
className="flex items-start gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-200 mb-4"
>
<span className="text-amber-400 text-base shrink-0 mt-0.5"></span>
<div className="flex-1 min-w-0">
<p className="font-semibold text-amber-300">Running in zero-config mode</p>
<p className="mt-0.5 text-amber-200/80">
OmniRoute auto-generated secure encryption keys on first launch. They are persisted to{" "}
<code className="font-mono bg-amber-500/20 px-1 rounded text-xs">{dataDir}</code>. No
action is required your data is encrypted and safe. To use custom keys, add{" "}
<code className="font-mono bg-amber-500/20 px-1 rounded text-xs">JWT_SECRET</code> and{" "}
<code className="font-mono bg-amber-500/20 px-1 rounded text-xs">
STORAGE_ENCRYPTION_KEY
</code>{" "}
to that file.
</p>
</div>
<button
onClick={() => setDismissed(true)}
className="shrink-0 text-amber-400/60 hover:text-amber-300 transition-colors ml-1"
aria-label="Dismiss"
>
</button>
</div>
);
}
+8 -1
View File
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
import { getMachineId } from "@/shared/utils/machine";
import { getSettings } from "@/lib/localDb";
import HomePageClient from "./HomePageClient";
import BootstrapBanner from "./BootstrapBanner";
// Must be dynamic — depends on DB state (setupComplete) that changes at runtime
export const dynamic = "force-dynamic";
@@ -12,5 +13,11 @@ export default async function DashboardPage() {
redirect("/dashboard/onboarding");
}
const machineId = await getMachineId();
return <HomePageClient machineId={machineId} />;
const isBootstrapped = process.env.OMNIROUTE_BOOTSTRAPPED === "true";
return (
<>
{isBootstrapped && <BootstrapBanner />}
<HomePageClient machineId={machineId} />
</>
);
}
@@ -1341,6 +1341,7 @@ PassthroughModelRow.propTypes = {
function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
const t = useTranslations("providers");
const notify = useNotificationStore();
const [customModels, setCustomModels] = useState([]);
const [newModelId, setNewModelId] = useState("");
const [newModelName, setNewModelName] = useState("");
@@ -1348,6 +1349,10 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
const [newEndpoints, setNewEndpoints] = useState(["chat"]);
const [adding, setAdding] = useState(false);
const [loading, setLoading] = useState(true);
const [editingModelId, setEditingModelId] = useState<string | null>(null);
const [editingApiFormat, setEditingApiFormat] = useState("chat-completions");
const [editingEndpoints, setEditingEndpoints] = useState<string[]>(["chat"]);
const [savingModelId, setSavingModelId] = useState<string | null>(null);
const fetchCustomModels = useCallback(async () => {
try {
@@ -1410,6 +1415,61 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
}
};
const beginEdit = (model) => {
setEditingModelId(model.id);
setEditingApiFormat(model.apiFormat || "chat-completions");
setEditingEndpoints(
Array.isArray(model.supportedEndpoints) && model.supportedEndpoints.length
? model.supportedEndpoints
: ["chat"]
);
};
const cancelEdit = () => {
setEditingModelId(null);
setEditingApiFormat("chat-completions");
setEditingEndpoints(["chat"]);
setSavingModelId(null);
};
const saveEdit = async (modelId) => {
if (!editingModelId || editingModelId !== modelId) return;
if (!editingEndpoints.length) {
notify.error("Select at least one supported endpoint");
return;
}
setSavingModelId(modelId);
try {
const model = customModels.find((m) => m.id === modelId);
const res = await fetch("/api/provider-models", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: providerId,
modelId,
modelName: model?.name || modelId,
source: model?.source || "manual",
apiFormat: editingApiFormat,
supportedEndpoints: editingEndpoints,
}),
});
if (!res.ok) {
throw new Error("Failed to save model endpoint settings");
}
await fetchCustomModels();
notify.success("Saved model endpoint settings");
cancelEdit();
} catch (e) {
console.error("Failed to save custom model:", e);
notify.error("Failed to save model endpoint settings");
} finally {
setSavingModelId(null);
}
};
return (
<div className="mt-6 pt-6 border-t border-border">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
@@ -1554,14 +1614,82 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
</span>
)}
</div>
{editingModelId === model.id && (
<div className="mt-3 p-3 rounded-lg border border-border bg-sidebar/40">
<div className="flex items-end gap-3 flex-wrap">
<div className="w-44">
<label className="text-xs text-text-muted mb-1 block">API Format</label>
<select
value={editingApiFormat}
onChange={(e) => setEditingApiFormat(e.target.value)}
className="w-full px-2.5 py-2 text-xs border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
>
<option value="chat-completions">Chat Completions</option>
<option value="responses">Responses API</option>
</select>
</div>
<div className="flex-1 min-w-[240px]">
<span className="text-xs text-text-muted mb-1 block">Supported Endpoints</span>
<div className="flex items-center gap-3 flex-wrap">
{["chat", "embeddings", "images", "audio"].map((ep) => (
<label key={ep} className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer">
<input
type="checkbox"
checked={editingEndpoints.includes(ep)}
onChange={(e) => {
if (e.target.checked) {
setEditingEndpoints((prev) => (prev.includes(ep) ? prev : [...prev, ep]));
} else {
setEditingEndpoints((prev) => prev.filter((x) => x !== ep));
}
}}
className="rounded border-border"
/>
{ep === "chat"
? "💬 Chat"
: ep === "embeddings"
? "📐 Embeddings"
: ep === "images"
? "🖼️ Images"
: "🔊 Audio"}
</label>
))}
</div>
</div>
</div>
<div className="mt-3 flex items-center gap-2">
<Button
size="sm"
onClick={() => saveEdit(model.id)}
disabled={savingModelId === model.id}
>
{savingModelId === model.id ? t("saving") : t("save")}
</Button>
<Button size="sm" variant="ghost" onClick={cancelEdit}>
{t("cancel")}
</Button>
</div>
</div>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => beginEdit(model)}
className="p-1 hover:bg-sidebar rounded text-text-muted hover:text-primary"
title={t("edit")}
>
<span className="material-symbols-outlined text-sm">edit</span>
</button>
<button
onClick={() => handleRemove(model.id)}
className="p-1 hover:bg-red-50 rounded text-red-500"
title={t("removeCustomModel")}
>
<span className="material-symbols-outlined text-sm">delete</span>
</button>
</div>
<button
onClick={() => handleRemove(model.id)}
className="p-1 hover:bg-red-50 rounded text-red-500"
title={t("removeCustomModel")}
>
<span className="material-symbols-outlined text-sm">delete</span>
</button>
</div>
);
})}
@@ -10,6 +10,19 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
const PROFILES_DIR = path.join(resolveDataDir(), "codex-profiles");
/**
* Resolve a path inside PROFILES_DIR and verify it stays within bounds.
* Throws on path traversal attempts.
*/
function safeProfilePath(...segments: string[]): string {
const resolved = path.resolve(PROFILES_DIR, ...segments);
const base = path.resolve(PROFILES_DIR);
if (resolved !== base && !resolved.startsWith(base + path.sep)) {
throw new Error("Invalid path: directory traversal detected");
}
return resolved;
}
/**
* Ensure profiles directory exists
*/
+54
View File
@@ -3,6 +3,7 @@ import {
getAllCustomModels,
addCustomModel,
removeCustomModel,
updateCustomModel,
} from "@/lib/localDb";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { providerModelMutationSchema } from "@/shared/validation/schemas";
@@ -84,6 +85,59 @@ export async function POST(request) {
}
}
/**
* PUT /api/provider-models
* Body: { provider, modelId, modelName?, apiFormat?, supportedEndpoints? }
*/
export async function PUT(request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return Response.json(
{ error: { message: "Invalid JSON body", type: "validation_error" } },
{ status: 400 }
);
}
try {
if (!(await isAuthenticated(request))) {
return Response.json(
{ error: { message: "Authentication required", type: "invalid_api_key" } },
{ status: 401 }
);
}
const validation = validateBody(providerModelMutationSchema, rawBody);
if (isValidationFailure(validation)) {
return Response.json({ error: validation.error }, { status: 400 });
}
const { provider, modelId, modelName, apiFormat, supportedEndpoints } = validation.data;
const model = await updateCustomModel(provider, modelId, {
modelName,
apiFormat,
supportedEndpoints,
});
if (!model) {
return Response.json(
{ error: { message: "Model not found", type: "not_found" } },
{ status: 404 }
);
}
return Response.json({ model });
} catch (error) {
console.error("Error updating provider model:", error);
return Response.json(
{ error: { message: "Failed to update provider model", type: "server_error" } },
{ status: 500 }
);
}
}
/**
* DELETE /api/provider-models?provider=<id>&model=<modelId>
*/
+15 -1
View File
@@ -247,6 +247,14 @@ const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> = {
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
"ollama-cloud": {
url: "https://api.ollama.com/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.models || data.data || [],
},
};
/**
@@ -389,7 +397,13 @@ export async function GET(request, { params }) {
// Get auth token
const token = accessToken || apiKey;
if (!token) {
return NextResponse.json({ error: "No valid token found" }, { status: 401 });
return NextResponse.json(
{
error:
"No API key configured for this provider. Please add an API key in the provider settings.",
},
{ status: 400 }
);
}
// Build request URL
+24
View File
@@ -46,6 +46,30 @@ export async function register() {
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 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`
);
}
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.warn("[STARTUP] Could not restore model aliases:", msg);
}
// Compliance: Initialize audit_log table + cleanup expired logs
try {
const { initAuditLog, cleanupExpiredLogs } = await import("@/lib/compliance/index");
+17 -2
View File
@@ -159,11 +159,26 @@ export async function listDbBackups() {
export async function restoreDbBackup(backupId: string) {
const backupDir = DB_BACKUPS_DIR || path.join(DATA_DIR, "db_backups");
const backupPath = path.join(backupDir, backupId);
if (!backupId.startsWith("db_") || !backupId.endsWith(".sqlite")) {
// Validate format: must be db_<timestamp>_<reason>.sqlite, no path separators
if (
!backupId.startsWith("db_") ||
!backupId.endsWith(".sqlite") ||
backupId.includes(path.sep) ||
backupId.includes("/")
) {
throw new Error("Invalid backup ID");
}
const backupPath = path.resolve(backupDir, backupId);
// Prevent path traversal: resolved path must stay within backupDir
if (
!backupPath.startsWith(path.resolve(backupDir) + path.sep) &&
backupPath !== path.resolve(backupDir)
) {
throw new Error("Invalid backup ID: path traversal detected");
}
if (!fs.existsSync(backupPath)) {
throw new Error(`Backup not found: ${backupId}`);
}
+35
View File
@@ -177,3 +177,38 @@ export async function removeCustomModel(providerId, modelId) {
backupDbFile("pre-write");
return true;
}
export async function updateCustomModel(providerId, modelId, updates = {}) {
const db = getDbInstance();
const row = db
.prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?")
.get(providerId);
if (!row) return null;
const value = getKeyValue(row).value;
if (!value) return null;
const models = JSON.parse(value);
const index = models.findIndex((m) => m.id === modelId);
if (index === -1) return null;
const current = models[index];
const next = {
...current,
...(updates.modelName !== undefined ? { name: updates.modelName || current.name } : {}),
...(updates.apiFormat !== undefined ? { apiFormat: updates.apiFormat } : {}),
...(updates.supportedEndpoints !== undefined
? { supportedEndpoints: updates.supportedEndpoints }
: {}),
};
models[index] = next;
db.prepare("UPDATE key_value SET value = ? WHERE namespace = 'customModels' AND key = ?").run(
JSON.stringify(models),
providerId
);
backupDbFile("pre-write");
return next;
}
+1
View File
@@ -40,6 +40,7 @@ export {
getAllCustomModels,
addCustomModel,
removeCustomModel,
updateCustomModel,
} from "./db/models";
export {
+11 -5
View File
@@ -35,8 +35,8 @@ const pendingRequests: {
byModel: Record<string, number>;
byAccount: Record<string, Record<string, number>>;
} = {
byModel: {},
byAccount: {},
byModel: Object.create(null) as Record<string, number>,
byAccount: Object.create(null) as Record<string, Record<string, number>>,
};
/**
@@ -50,16 +50,22 @@ export function trackPendingRequest(
) {
const modelKey = provider ? `${model} (${provider})` : model;
if (!pendingRequests.byModel[modelKey]) pendingRequests.byModel[modelKey] = 0;
// Use hasOwnProperty guard to prevent prototype pollution via crafted keys
if (!Object.prototype.hasOwnProperty.call(pendingRequests.byModel, modelKey)) {
pendingRequests.byModel[modelKey] = 0;
}
pendingRequests.byModel[modelKey] = Math.max(
0,
pendingRequests.byModel[modelKey] + (started ? 1 : -1)
);
if (connectionId) {
if (!pendingRequests.byAccount[connectionId]) pendingRequests.byAccount[connectionId] = {};
if (!pendingRequests.byAccount[connectionId][modelKey])
if (!Object.prototype.hasOwnProperty.call(pendingRequests.byAccount, connectionId)) {
pendingRequests.byAccount[connectionId] = Object.create(null) as Record<string, number>;
}
if (!Object.prototype.hasOwnProperty.call(pendingRequests.byAccount[connectionId], modelKey)) {
pendingRequests.byAccount[connectionId][modelKey] = 0;
}
pendingRequests.byAccount[connectionId][modelKey] = Math.max(
0,
pendingRequests.byAccount[connectionId][modelKey] + (started ? 1 : -1)
+17 -3
View File
@@ -45,12 +45,22 @@ const CHAT_URL_PATTERNS = [":generateContent", ":streamGenerateContent"];
const LOG_DIR = path.join(__dirname, "../../logs/mitm");
if (ENABLE_FILE_LOG && !fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true });
// Safe log filename: only alphanumeric + hyphens, anchored inside LOG_DIR
function safeLogPath(name) {
const safe = name.replace(/[^a-zA-Z0-9_\-]/g, "_").substring(0, 80);
const resolved = path.resolve(LOG_DIR, safe);
if (!resolved.startsWith(path.resolve(LOG_DIR) + path.sep)) {
throw new Error("Path traversal attempt detected in log filename");
}
return resolved;
}
function saveRequestLog(url, bodyBuffer) {
if (!ENABLE_FILE_LOG) return;
try {
const ts = new Date().toISOString().replace(/[:.]/g, "-");
const urlSlug = url.replace(/[^a-zA-Z0-9]/g, "_").substring(0, 60);
const filePath = path.join(LOG_DIR, `${ts}_${urlSlug}.json`);
const filePath = safeLogPath(`${ts}_${urlSlug}.json`);
const body = JSON.parse(bodyBuffer.toString());
fs.writeFileSync(filePath, JSON.stringify(body, null, 2));
console.log(`💾 Saved request: ${filePath}`);
@@ -64,7 +74,7 @@ function saveResponseLog(url, data) {
try {
const ts = new Date().toISOString().replace(/[:.]/g, "-");
const urlSlug = url.replace(/[^a-zA-Z0-9]/g, "_").substring(0, 60);
const filePath = path.join(LOG_DIR, `${ts}_${urlSlug}_response.txt`);
const filePath = safeLogPath(`${ts}_${urlSlug}_response.txt`);
fs.writeFileSync(filePath, data);
console.log(`💾 Saved response: ${filePath}`);
} catch {
@@ -156,6 +166,10 @@ function getMappedModel(model) {
async function passthrough(req, res, bodyBuffer) {
const targetIP = await resolveTargetIP();
// TLS validation is enabled by default. Set MITM_DISABLE_TLS_VERIFY=1 only
// in controlled local environments where the target uses a self-signed cert.
const rejectUnauthorized = process.env.MITM_DISABLE_TLS_VERIFY !== "1";
const forwardReq = https.request(
{
hostname: targetIP,
@@ -164,7 +178,7 @@ async function passthrough(req, res, bodyBuffer) {
method: req.method,
headers: { ...req.headers, host: TARGET_HOST },
servername: TARGET_HOST,
rejectUnauthorized: false,
rejectUnauthorized,
},
(forwardRes) => {
res.writeHead(forwardRes.statusCode, forwardRes.headers);
+11 -11
View File
@@ -98,12 +98,12 @@ export default function OAuthModal({
GOOGLE_OAUTH_PROVIDERS.has(provider)
) {
setError(
"redirect_uri_mismatch: As credenciais padrão do Google OAuth só funcionam em localhost. " +
"Para uso remoto, configure suas próprias credenciais OAuth nas variáveis de ambiente: " +
"redirect_uri_mismatch: The default Google OAuth credentials only work on localhost. " +
"For remote use, configure your own OAuth credentials via environment variables: " +
(provider === "antigravity"
? "ANTIGRAVITY_OAUTH_CLIENT_ID e ANTIGRAVITY_OAUTH_CLIENT_SECRET"
: "GEMINI_OAUTH_CLIENT_ID e GEMINI_OAUTH_CLIENT_SECRET") +
". Veja o README, seção 'OAuth em Servidor Remoto'."
? "ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET"
: "GEMINI_OAUTH_CLIENT_ID and GEMINI_OAUTH_CLIENT_SECRET") +
". See the README section 'OAuth on a Remote Server'."
);
} else {
setError(err.message);
@@ -512,17 +512,17 @@ export default function OAuthModal({
<span className="material-symbols-outlined text-sm align-middle mr-1">
warning
</span>
<strong>Acesso remoto + Google OAuth:</strong> As credenciais padrão aceitam
redirect para <code>localhost</code>. Após autorizar, o browser tentará abrir
<code>localhost</code> copie essa URL completa e cole abaixo. Para uso
totalmente remoto sem esse passo manual,{" "}
<strong>Remote access + Google OAuth:</strong> The default credentials only accept
redirects to <code>localhost</code>. After authorizing, your browser will try to
open <code>localhost</code> copy that full URL and paste it below. For fully
remote use without this manual step,{" "}
<a
href="https://github.com/diegosouzapw/OmniRoute#oauth-em-servidor-remoto"
href="https://github.com/diegosouzapw/OmniRoute#oauth-on-a-remote-server"
target="_blank"
rel="noreferrer"
className="underline"
>
configure suas próprias credenciais OAuth
configure your own OAuth credentials
</a>
.
</div>
+18 -4
View File
@@ -5,11 +5,24 @@ import { resolveDataDir } from "@/lib/dataPaths";
const BACKUP_DIR = path.join(resolveDataDir(), "backups");
const MAX_BACKUPS_PER_TOOL = 5;
/**
* Resolve a path within BACKUP_DIR and verify it stays within bounds.
* Throws if the resolved path escapes BACKUP_DIR (path traversal guard).
*/
function safePath(...segments: string[]): string {
const resolved = path.resolve(BACKUP_DIR, ...segments);
const base = path.resolve(BACKUP_DIR);
if (resolved !== base && !resolved.startsWith(base + path.sep)) {
throw new Error("Invalid path: directory traversal detected");
}
return resolved;
}
/**
* Get backup directory for a specific tool
*/
function getToolBackupDir(toolId: string) {
return path.join(BACKUP_DIR, toolId);
return safePath(toolId);
}
/**
@@ -136,7 +149,8 @@ export async function listBackups(toolId: string) {
*/
export async function restoreBackup(toolId: string, backupId: string) {
const dir = getToolBackupDir(toolId);
const backupPath = path.join(dir, backupId);
// Anchor backupId within the tool dir — prevent path traversal via backupId
const backupPath = safePath(toolId, backupId);
const metaPath = backupPath + ".meta.json";
// Read metadata to find original path
@@ -174,8 +188,8 @@ export async function restoreBackup(toolId: string, backupId: string) {
* Delete a specific backup by its id.
*/
export async function deleteBackup(toolId: string, backupId: string) {
const dir = getToolBackupDir(toolId);
const backupPath = path.join(dir, backupId);
// Anchor backupId within the tool dir — prevent path traversal via backupId
const backupPath = safePath(toolId, backupId);
const metaPath = backupPath + ".meta.json";
try {