Compare commits

...

54 Commits

Author SHA1 Message Date
diegosouzapw 1e9a9adbad chore(release): v2.3.2
Build Electron Desktop App / Validate version (push) Failing after 38s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
feat(claude): [1m] suffix for 1M extended context (PR #311 @DavyMassoneto)
feat(registry): new models for iFlow, Qwen, Kimi (PR #326 @nyatoru)
fix(cli): postinstall binary copy instead of rebuild (PR #327 @ardaaltinors, fixes #321)
docs: English Remote OAuth guide in README (PR #329, fixes #318)
test: 3 unit tests for parseModel [1m] suffix
2026-03-12 07:00:10 -03:00
Diego Rodrigues de Sa e Souza d87c7c3b8c Merge pull request #311 from DavyMassoneto/fix/merge-duplicates-and-lint-warnings
feat(claude): support [1m] suffix for 1M extended context window
2026-03-12 06:58:57 -03:00
Diego Rodrigues de Sa e Souza eb3c834609 Merge pull request #326 from nyatoru/update/sync-qwen-iflow-model
feat(registry): add new models to the provider registry
2026-03-12 06:58:12 -03:00
Diego Rodrigues de Sa e Souza e53c76081f Merge pull request #327 from ardaaltinors/fix/postinstall-copy-native-binary
fix(cli): fix postinstall native binary rebuild regression (#321)
2026-03-12 06:58:10 -03:00
Diego Rodrigues de Sa e Souza 134316328c Merge pull request #329 from diegosouzapw/fix/issue-318-readme-oauth-en
docs: add English Remote OAuth guide to README (#318)
2026-03-12 06:58:07 -03:00
diegosouzapw 4767561f02 docs: add English translation for Remote OAuth section in README (#318)
The '🔐 OAuth on a Remote Server' guide existed only in Portuguese (#oauth-em-servidor-remoto).
Multiple users (@hijak, @ldsgroups225, @vipinpg) couldn't find it in English.

Changes:
- Full English step-by-step guide added above the existing PT content
- Added 'oauth-on-a-remote-server' anchor (EN) alongside 'oauth-em-servidor-remoto' (PT)
- Portuguese version moved into a collapsible <details> section
- OAuthModal.tsx already updated in v2.3.1 to link to #oauth-on-a-remote-server
2026-03-12 06:56:05 -03:00
Nyaru Toru 2d6b31b606 Update open-sse/config/providerRegistry.ts
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-12 15:08:05 +07:00
ardaaltinors a22f0a4e7b fix(cli): address review feedback on native binary detection and postinstall
- Read only first 4096 bytes of binary header instead of entire file
- Add error logging to all catch blocks with specific failure messages
- Separate copy vs dlopen catch blocks in postinstall Strategy 1
- Add archCount sanity cap (max 30) for fat Mach-O parsing
- Distinguish timeout vs rebuild failure in Strategy 2
2026-03-12 10:34:56 +03:00
ardaaltinors 5a244aa12a fix(cli): include native-binary-compat.mjs in published package files
The module is imported by bin/omniroute.mjs but was missing from the
files array in package.json, causing ERR_MODULE_NOT_FOUND on global
installs.
2026-03-12 10:26:16 +03:00
ardaaltinors 69d28bec4d feat(cli): detect native binary platform from file header instead of dlopen
Add native-binary-compat module that reads ELF/Mach-O/PE headers to
determine the actual target platform/arch of the .node binary. This
eliminates the macOS false-positive where dlopen loads a linux-x64
binary without throwing.

- Parse ELF (linux), Mach-O (darwin), and PE (win32) binary formats
- Use header-based check as primary signal, dlopen as secondary
- Update pre-flight check in CLI to use the new module
- Add unit tests for all binary formats and cross-platform scenarios
2026-03-12 10:20:08 +03:00
ardaaltinors c859665c6b fix(cli): copy native binary from root node_modules instead of rebuilding (#321)
The standalone app/ directory created by Next.js only contains runtime
files for better-sqlite3 (no binding.gyp, no source, no prebuild-install),
so `npm rebuild` inside app/ is a no-op. The previous fix (#312) added
exit(1) on rebuild failure, which caused npm to rollback the entire
package installation — leaving users with nothing to fix manually.

New approach:
1. Check if existing binary is already compatible (dlopen)
2. Copy the correctly-built binary from root node_modules/ (npm already
   compiles it for the correct platform during install)
3. Fall back to npm rebuild if root binary is unavailable
4. Warn but don't fail the install if nothing works — the package stays
   installed and the CLI pre-flight check gives a clear error at startup
2026-03-12 10:07:43 +03:00
nyatoru e7b19758f3 feat(registry): add new models to the provider registry 2026-03-12 11:18:16 +08:00
DavyMassoneto 623c63baf6 feat(claude): support [1m] suffix for 1M context window
Parse [1m] suffix from model name (e.g. claude-sonnet-4-6[1m]) and
propagate extendedContext flag through the request pipeline to append
context-1m-2025-08-07 to the Anthropic-Beta header.
2026-03-11 23:53:09 -03:00
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
38 changed files with 1430 additions and 112 deletions
+15 -2
View File
@@ -53,10 +53,14 @@ Keep an empty `## [Unreleased]` section above it.
## [2.x.y] — YYYY-MM-DD
```
### 4. Update openapi.yaml version
### 4. Update openapi.yaml version ⚠️ MANDATORY
> **CI will fail** if `docs/openapi.yaml` version ≠ `package.json` version (`check:docs-sync` enforces this).
// turbo
```bash
sed -i 's/version: OLD/version: NEW/' docs/openapi.yaml
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
@@ -95,3 +99,12 @@ ssh root@<VPS_IP> "npm install -g omniroute@2.x.y && pm2 restart omniroute"
- 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
@@ -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
+138
View File
@@ -7,6 +7,144 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [Unreleased]
---
## [2.3.2] — 2026-03-12
> ### Claude 1M Context, Postinstall Fix, New Models & OAuth Remote Docs
### ✨ New Features
- **Claude 1M extended context window support** — Use `[1m]` suffix on Claude model names (e.g. `claude-sonnet-4-6[1m]`) to activate Anthropic's 1M token context via the `Anthropic-Beta: context-1m-2025-08-07` header. Supported: `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-sonnet-4-5`, `claude-sonnet-4`. (PR #311@DavyMassoneto)
- **New provider models** — Added `coder-model` (Qwen3.5) to Qwen and `iflow-rome-30ba3b`, `qwen3-max`, `qwen3-vl-plus`, `kimi-k2-0905`, `deepseek-v3.2`, `qwen3-235b` variants to iFlow; `kimi-for-coding` to Kimi. (PR #326@nyatoru)
### 🐛 Bug Fixes
- **Postinstall native binary regression fix** — PR #313's `process.exit(1)` caused npm to rollback the full package on rebuild failure. New approach copies the already-compiled binary from root `node_modules/` instead of rebuilding inside `app/` (which is a no-op). New `native-binary-compat.mjs` reads ELF/Mach-O/PE headers for reliable platform detection. (PR #327@ardaaltinors, fixes #321)
- **README: English Remote OAuth guide added** — The OAuth Remote Server guide existed only in Portuguese. English version now appears first; PT moved to a collapsible section. Fixes the 🔗 anchor `#oauth-on-a-remote-server` referenced from `OAuthModal.tsx` since v2.3.1. (PR #329, fixes #318)
### 🧪 Tests
- Added 3 unit tests for `parseModel([1m])` suffix parsing (`model-parse.test.mjs`)
---
## [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
+1
View File
@@ -33,6 +33,7 @@ COPY --from=builder /app/.next/standalone ./
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
+93 -2
View File
@@ -1508,11 +1508,102 @@ opencode
- OmniRoute v1.0.6+ includes fallback validation via chat completions
- Ensure base URL includes `/v1` suffix
### 🔐 OAuth em Servidor Remoto (Remote OAuth Setup)
### 🔐 OAuth on a Remote Server
<a name="oauth-on-a-remote-server"></a>
<a name="oauth-em-servidor-remoto"></a>
> **⚠️ IMPORTANTE para usuários com OmniRoute em VPS/Docker/servidor remoto**
> **⚠️ Important for users running OmniRoute on a VPS, Docker, or any remote server**
#### Why does Antigravity / Gemini CLI OAuth fail on remote servers?
The **Antigravity** and **Gemini CLI** providers use **Google OAuth 2.0**. Google requires the `redirect_uri` in the OAuth flow to exactly match one of the pre-registered URIs in the app's Google Cloud Console.
The OAuth credentials bundled in OmniRoute are registered **for `localhost` only**. When you access OmniRoute on a remote server (e.g. `https://omniroute.myserver.com`), Google rejects the authentication with:
```
Error 400: redirect_uri_mismatch
```
#### Solution: Configure your own OAuth credentials
You need to create an **OAuth 2.0 Client ID** in Google Cloud Console with your server's URI.
#### Step-by-step
**1. Open Google Cloud Console**
Go to: [https://console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials)
**2. Create a new OAuth 2.0 Client ID**
- Click **"+ Create Credentials"** → **"OAuth client ID"**
- Application type: **"Web application"**
- Name: anything you like (e.g. `OmniRoute Remote`)
**3. Add Authorized Redirect URIs**
In the **"Authorized redirect URIs"** field, add:
```
https://your-server.com/callback
```
> Replace `your-server.com` with your server's domain or IP (include the port if needed, e.g. `http://45.33.32.156:20128/callback`).
**4. Save and copy the credentials**
After creating, Google will show the **Client ID** and **Client Secret**.
**5. Set environment variables**
In your `.env` (or Docker environment variables):
```bash
# For Antigravity:
ANTIGRAVITY_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
ANTIGRAVITY_OAUTH_CLIENT_SECRET=GOCSPX-your-secret
# For Gemini CLI:
GEMINI_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
GEMINI_OAUTH_CLIENT_SECRET=GOCSPX-your-secret
GEMINI_CLI_OAUTH_CLIENT_SECRET=GOCSPX-your-secret
```
**6. Restart OmniRoute**
```bash
# npm:
npm run dev
# Docker:
docker restart omniroute
```
**7. Try connecting again**
Dashboard → Providers → Antigravity (or Gemini CLI) → OAuth
Google will now redirect correctly to `https://your-server.com/callback`.
---
#### Temporary workaround (without custom credentials)
If you don't want to set up your own credentials right now, you can still use the **manual URL flow**:
1. OmniRoute opens the Google authorization URL
2. After authorizing, Google tries to redirect to `localhost` (which fails on the remote server)
3. **Copy the full URL** from your browser's address bar (even if the page doesn't load)
4. Paste that URL into the field shown in the OmniRoute connection modal
5. Click **"Connect"**
> This works because the authorization code in the URL is valid regardless of whether the redirect page loaded.
---
<details>
<summary><b>🇧🇷 Versão em Português</b></summary>
#### Por que o OAuth do Antigravity / Gemini CLI falha em servidores remotos?
+24
View File
@@ -17,6 +17,7 @@ import { existsSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { homedir, platform } from "node:os";
import { isNativeBinaryCompatible } from "../scripts/native-binary-compat.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -193,6 +194,29 @@ if (!existsSync(serverJs)) {
process.exit(1);
}
// ── Pre-flight: verify better-sqlite3 native binary ───────
// Verify the binary's actual target platform/arch before trusting dlopen.
// This avoids the macOS false positive where a bundled linux-x64 addon can
// appear to load even though the runtime will fail when better-sqlite3 starts.
const sqliteBinary = join(
APP_DIR,
"node_modules",
"better-sqlite3",
"build",
"Release",
"better_sqlite3.node"
);
if (existsSync(sqliteBinary) && !isNativeBinaryCompatible(sqliteBinary)) {
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",
},
+13 -6
View File
@@ -225,6 +225,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "qwen3-coder-flash", name: "Qwen3 Coder Flash" },
{ id: "vision-model", name: "Qwen3 Vision Model" },
{ id: "coder-model", name: "Qwen3.5 (Coder Model)" },
],
},
@@ -248,15 +249,20 @@ export const REGISTRY: Record<string, RegistryEntry> = {
authUrl: "https://iflow.cn/oauth",
},
models: [
{ id: "iflow-rome-30ba3b", name: "iFlow ROME" },
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "qwen3-max", name: "Qwen3 Max" },
{ id: "qwen3-vl-plus", name: "Qwen3 Vision Plus" },
{ id: "kimi-k2-0905", name: "Kimi K2 0905" },
{ id: "qwen3-max-preview", name: "Qwen3 Max Preview" },
{ id: "kimi-k2", name: "Kimi K2" },
{ id: "kimi-k2-thinking", name: "Kimi K2 Thinking" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "deepseek-v3.2", name: "DeepSeek-V3.2-Exp" },
{ id: "deepseek-r1", name: "DeepSeek R1" },
{ id: "deepseek-v3.2-chat", name: "DeepSeek V3.2 Chat" },
{ id: "deepseek-v3.2-reasoner", name: "DeepSeek V3.2 Reasoner" },
{ id: "minimax-m2.1", name: "MiniMax M2.1" },
{ id: "glm-4.7", name: "GLM 4.7" },
{ id: "deepseek-v3", name: "DeepSeek V3" },
{ id: "qwen3-32b", name: "Qwen3 32B" },
{ id: "qwen3-235b-a22b-thinking-2507", name: "Qwen3 235B A22B Thinking 2507" },
{ id: "qwen3-235b-a22b-instruct", name: "Qwen3 235B A22B Instruct" },
{ id: "qwen3-235b", name: "Qwen3 235B" },
],
},
@@ -486,6 +492,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" },
{ id: "kimi-latest", name: "Kimi Latest" },
{ id: "kimi-for-coding", name: "Kimi For Coding" },
],
},
+7 -1
View File
@@ -38,7 +38,13 @@ export class AntigravityExecutor extends BaseExecutor {
transformRequest(model, body, stream, credentials) {
const bodyProjectId = body?.project;
const credentialsProjectId = credentials?.projectId;
const projectId = bodyProjectId || credentialsProjectId;
const allowBodyProjectOverride = process.env.OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE === "1";
// 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(
+25 -1
View File
@@ -40,6 +40,7 @@ export type ExecuteInput = {
credentials: ProviderCredentials;
signal?: AbortSignal | null;
log?: ExecutorLog | null;
extendedContext?: boolean;
};
function mergeAbortSignals(primary: AbortSignal, secondary: AbortSignal): AbortSignal {
@@ -174,7 +175,7 @@ export class BaseExecutor {
return { status: response.status, message: bodyText || `HTTP ${response.status}` };
}
async execute({ model, body, stream, credentials, signal, log }: ExecuteInput) {
async execute({ model, body, stream, credentials, signal, log, extendedContext }: ExecuteInput) {
const fallbackCount = this.getFallbackCount();
let lastError: unknown = null;
let lastStatus = 0;
@@ -182,6 +183,29 @@ export class BaseExecutor {
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
const url = this.buildUrl(model, stream, urlIndex, credentials);
const headers = this.buildHeaders(credentials, stream);
// Append 1M context beta header when [1m] suffix was used
// Only supported for specific Claude models per Anthropic docs
if (extendedContext) {
const EXTENDED_CONTEXT_MODELS = [
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-sonnet-4-5",
"claude-sonnet-4",
];
const baseModel = model.replace(/-\d{8}$/, "");
if (
EXTENDED_CONTEXT_MODELS.some((m) => baseModel === m || model === m || model.startsWith(m))
) {
const existing = headers["Anthropic-Beta"];
if (existing) {
headers["Anthropic-Beta"] = existing + ",context-1m-2025-08-07";
} else {
headers["Anthropic-Beta"] = "context-1m-2025-08-07";
}
}
}
const transformedBody = this.transformRequest(model, body, stream, credentials);
try {
+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;
+32 -2
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";
@@ -68,7 +69,7 @@ export async function handleChatCore({
userAgent,
comboName,
}) {
const { provider, model } = modelInfo;
const { provider, model, extendedContext } = modelInfo;
const startTime = Date.now();
// ── Phase 9.2: Idempotency check ──
@@ -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,
@@ -248,6 +276,7 @@ export async function handleChatCore({
credentials,
signal: streamController.signal,
log,
extendedContext,
})
);
@@ -335,6 +364,7 @@ export async function handleChatCore({
credentials,
signal: streamController.signal,
log,
extendedContext,
});
if (retryResult.response.ok) {
+35 -9
View File
@@ -59,29 +59,50 @@ function resolveProviderModelAlias(providerOrAlias, modelId) {
/**
* Parse model string: "alias/model" or "provider/model" or just alias
* Supports [1m] suffix for extended 1M context window (e.g. "claude-sonnet-4-6[1m]")
*/
export function parseModel(modelStr) {
if (!modelStr) {
return { provider: null, model: null, isAlias: false, providerAlias: null };
return {
provider: null,
model: null,
isAlias: false,
providerAlias: null,
extendedContext: false,
};
}
// Sanitize: reject strings with path traversal or control characters
if (/\.\.[\/\\]/.test(modelStr) || /[\x00-\x1f]/.test(modelStr)) {
console.log(`[MODEL] Warning: rejected malformed model string: "${modelStr.substring(0, 50)}"`);
return { provider: null, model: null, isAlias: false, providerAlias: null };
return {
provider: null,
model: null,
isAlias: false,
providerAlias: null,
extendedContext: false,
};
}
// Extract [1m] suffix before parsing provider/model
let extendedContext = false;
let cleanStr = modelStr;
if (cleanStr.endsWith("[1m]")) {
extendedContext = true;
cleanStr = cleanStr.slice(0, -4);
}
// Check if standard format: provider/model or alias/model
if (modelStr.includes("/")) {
const firstSlash = modelStr.indexOf("/");
const providerOrAlias = modelStr.slice(0, firstSlash);
const model = modelStr.slice(firstSlash + 1);
if (cleanStr.includes("/")) {
const firstSlash = cleanStr.indexOf("/");
const providerOrAlias = cleanStr.slice(0, firstSlash);
const model = cleanStr.slice(firstSlash + 1);
const provider = resolveProviderAlias(providerOrAlias);
return { provider, model, isAlias: false, providerAlias: providerOrAlias };
return { provider, model, isAlias: false, providerAlias: providerOrAlias, extendedContext };
}
// Alias format (model alias, not provider alias)
return { provider: null, model: modelStr, isAlias: true, providerAlias: null };
return { provider: null, model: cleanStr, isAlias: true, providerAlias: null, extendedContext };
}
/**
@@ -123,12 +144,14 @@ export function resolveModelAliasFromMap(alias, aliases) {
*/
export async function getModelInfoCore(modelStr, aliasesOrGetter) {
const parsed = parseModel(modelStr);
const { extendedContext } = parsed;
if (!parsed.isAlias) {
const canonicalModel = resolveProviderModelAlias(parsed.provider, parsed.model);
return {
provider: parsed.provider,
model: canonicalModel,
extendedContext,
};
}
@@ -142,6 +165,7 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
return {
provider: resolved.provider,
model: canonicalModel,
extendedContext,
};
}
@@ -153,6 +177,7 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
return {
provider: "openai",
model: modelId,
extendedContext,
};
}
@@ -160,7 +185,7 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
if (nonOpenAIProviders.length === 1) {
const provider = nonOpenAIProviders[0];
const canonicalModel = resolveProviderModelAlias(provider, modelId);
return { provider, model: canonicalModel };
return { provider, model: canonicalModel, extendedContext };
}
if (nonOpenAIProviders.length > 1) {
@@ -182,5 +207,6 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
return {
provider: "openai",
model: modelId,
extendedContext,
};
}
+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",
@@ -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;
+6 -16
View File
@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "2.2.3",
"version": "2.3.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "2.2.3",
"version": "2.3.2",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -15,6 +15,7 @@
"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",
@@ -7212,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"
@@ -8978,17 +8979,6 @@
}
}
},
"node_modules/next-intl/node_modules/@swc/helpers": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+5 -3
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "2.2.3",
"version": "2.3.2",
"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": {
@@ -13,6 +13,7 @@
"open-sse/mcp-server/",
"src/shared/contracts/",
"scripts/postinstall.mjs",
"scripts/native-binary-compat.mjs",
"README.md",
"LICENSE"
],
@@ -109,7 +110,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",
@@ -142,6 +144,6 @@
]
},
"overrides": {
"@swc/helpers": "^0.5.19"
"@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}`);
})
+163
View File
@@ -0,0 +1,163 @@
import { existsSync, openSync, readSync, closeSync } from "node:fs";
export const PUBLISHED_BUILD_PLATFORM = "linux";
export const PUBLISHED_BUILD_ARCH = "x64";
const HEADER_SIZE = 4096;
const MAX_FAT_ARCH_COUNT = 30;
function mapElfMachine(machine) {
switch (machine) {
case 62:
return "x64";
case 183:
return "arm64";
default:
return null;
}
}
function mapMachCpuType(cpuType) {
switch (cpuType) {
case 0x01000007:
return "x64";
case 0x0100000c:
return "arm64";
default:
return null;
}
}
function mapPeMachine(machine) {
switch (machine) {
case 0x8664:
return "x64";
case 0xaa64:
return "arm64";
default:
return null;
}
}
function readUInt16(buffer, offset, littleEndian) {
return littleEndian ? buffer.readUInt16LE(offset) : buffer.readUInt16BE(offset);
}
function readUInt32(buffer, offset, littleEndian) {
return littleEndian ? buffer.readUInt32LE(offset) : buffer.readUInt32BE(offset);
}
const ELF_MAGIC = 0x7f454c46;
function detectElfTarget(buffer) {
if (buffer.length < 20) return null;
if (buffer.readUInt32BE(0) !== ELF_MAGIC) return null;
const littleEndian = buffer[5] !== 2;
const arch = mapElfMachine(readUInt16(buffer, 18, littleEndian));
if (!arch) return null;
return { platform: "linux", architectures: [arch] };
}
const THIN_MACH_MAGIC = new Map([
[0xfeedface, false],
[0xfeedfacf, false],
[0xcefaedfe, true],
[0xcffaedfe, true],
]);
const FAT_MACH_MAGIC = new Map([
[0xcafebabe, false],
[0xcafebabf, false],
[0xbebafeca, true],
[0xbfbafeca, true],
]);
function detectMachTarget(buffer) {
if (buffer.length < 8) return null;
const magic = buffer.readUInt32BE(0);
if (THIN_MACH_MAGIC.has(magic)) {
const littleEndian = THIN_MACH_MAGIC.get(magic);
const arch = mapMachCpuType(readUInt32(buffer, 4, littleEndian));
if (!arch) return null;
return { platform: "darwin", architectures: [arch] };
}
if (!FAT_MACH_MAGIC.has(magic)) return null;
const littleEndian = FAT_MACH_MAGIC.get(magic);
const isFat64 = magic === 0xcafebabf || magic === 0xbfbafeca;
const archCount = readUInt32(buffer, 4, littleEndian);
if (archCount > MAX_FAT_ARCH_COUNT) return null;
const entrySize = isFat64 ? 32 : 20;
const architectures = new Set();
for (let index = 0; index < archCount; index += 1) {
const offset = 8 + index * entrySize;
if (offset + 4 > buffer.length) break;
const arch = mapMachCpuType(readUInt32(buffer, offset, littleEndian));
if (arch) architectures.add(arch);
}
if (architectures.size === 0) return null;
return { platform: "darwin", architectures: [...architectures] };
}
function detectPeTarget(buffer) {
if (buffer.length < 0x40) return null;
if (buffer.readUInt16LE(0) !== 0x5a4d) return null;
const peHeaderOffset = buffer.readUInt32LE(0x3c);
if (peHeaderOffset + 6 > buffer.length) return null;
if (buffer.readUInt32LE(peHeaderOffset) !== 0x00004550) return null;
const arch = mapPeMachine(buffer.readUInt16LE(peHeaderOffset + 4));
if (!arch) return null;
return { platform: "win32", architectures: [arch] };
}
export function detectNativeBinaryTarget(buffer) {
return detectElfTarget(buffer) ?? detectMachTarget(buffer) ?? detectPeTarget(buffer);
}
export function readNativeBinaryTarget(binaryPath) {
if (!existsSync(binaryPath)) return null;
let fd;
try {
fd = openSync(binaryPath, "r");
const buffer = Buffer.alloc(HEADER_SIZE);
const bytesRead = readSync(fd, buffer, 0, HEADER_SIZE, 0);
return detectNativeBinaryTarget(buffer.subarray(0, bytesRead));
} catch (err) {
console.warn(` ⚠️ Could not read native binary at ${binaryPath}: ${err.message}`);
return null;
} finally {
if (fd !== undefined) closeSync(fd);
}
}
export function isNativeBinaryCompatible(
binaryPath,
{ runtimePlatform = process.platform, runtimeArch = process.arch, dlopen = process.dlopen } = {}
) {
const target = readNativeBinaryTarget(binaryPath);
if (target) {
if (target.platform !== runtimePlatform || !target.architectures.includes(runtimeArch)) {
return false;
}
} else if (runtimePlatform !== PUBLISHED_BUILD_PLATFORM || runtimeArch !== PUBLISHED_BUILD_ARCH) {
return false;
}
try {
dlopen({ exports: {} }, binaryPath);
return true;
} catch (err) {
console.warn(` ⚠️ Native binary dlopen failed: ${err.message}`);
return false;
}
}
+83 -25
View File
@@ -1,57 +1,115 @@
#!/usr/bin/env node
/**
* OmniRoute Postinstall Native Module Rebuild
* OmniRoute Postinstall Native Module Fix
*
* The npm package ships with a Next.js standalone build that includes
* better-sqlite3 compiled for the build platform (Linux x64).
* This script detects platform mismatches and rebuilds the native
* module for the user's actual OS/architecture.
* better-sqlite3 compiled for the build platform (Linux x64) inside
* app/node_modules/. However, npm also installs better-sqlite3 as a
* top-level dependency (in the root node_modules/), correctly compiled
* for the user's platform.
*
* This script copies the correctly-built native binary from the root
* into the standalone app directory no rebuild or build tools needed.
*
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/129
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/321
*/
import { execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { existsSync, copyFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { PUBLISHED_BUILD_PLATFORM, PUBLISHED_BUILD_ARCH } from "./native-binary-compat.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ROOT = join(__dirname, "..");
// The standalone build bundles better-sqlite3 inside app/node_modules
const appNodeModules = join(ROOT, "app", "node_modules", "better-sqlite3");
const appBinary = join(
ROOT,
"app",
"node_modules",
"better-sqlite3",
"build",
"Release",
"better_sqlite3.node"
);
const rootBinary = join(
ROOT,
"node_modules",
"better-sqlite3",
"build",
"Release",
"better_sqlite3.node"
);
if (!existsSync(appNodeModules)) {
// No bundled better-sqlite3 — nothing to do (dev install, not npm global)
if (!existsSync(join(ROOT, "app", "node_modules", "better-sqlite3"))) {
process.exit(0);
}
const buildInfoPath = join(appNodeModules, "build", "Release", "better_sqlite3.node");
const platformMatch =
process.platform === PUBLISHED_BUILD_PLATFORM && process.arch === PUBLISHED_BUILD_ARCH;
// 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
if (platformMatch) {
try {
process.dlopen({ exports: {} }, appBinary);
process.exit(0);
} catch (err) {
console.warn(` ⚠️ Bundled binary incompatible despite platform match: ${err.message}`);
}
}
console.log(`\n 🔧 Rebuilding better-sqlite3 for ${process.platform}-${process.arch}...`);
console.log(`\n 🔧 Fixing better-sqlite3 binary for ${process.platform}-${process.arch}...`);
// Strategy 1: Copy the correctly-built binary from root node_modules
if (existsSync(rootBinary)) {
try {
mkdirSync(dirname(appBinary), { recursive: true });
copyFileSync(rootBinary, appBinary);
} catch (err) {
console.warn(` ⚠️ Failed to copy binary: ${err.message}`);
}
try {
process.dlopen({ exports: {} }, appBinary);
console.log(" ✅ Native module fixed successfully!\n");
process.exit(0);
} catch (err) {
console.warn(` ⚠️ Copied binary failed to load: ${err.message}`);
}
}
// Strategy 2: Fall back to npm rebuild (may work if build tools are available)
console.log(" ⚠️ Root binary not available or incompatible, attempting npm rebuild...");
try {
const { execSync } = await import("node:child_process");
execSync("npm rebuild better-sqlite3", {
cwd: join(ROOT, "app"),
stdio: "inherit",
timeout: 120_000,
});
process.dlopen({ exports: {} }, appBinary);
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
process.exit(0);
} catch (err) {
const isTimeout = err.killed || err.signal === "SIGTERM";
if (isTimeout) {
console.warn(" ⚠️ npm rebuild timed out after 120s.");
} else {
console.warn(` ⚠️ npm rebuild failed: ${err.message}`);
}
}
// If nothing worked, warn but don't fail the install — let the package stay
// installed so users can fix manually or use the pre-flight check in the CLI
console.warn(" ⚠️ Could not fix better-sqlite3 native module automatically.");
console.warn(" The server may not start correctly.");
console.warn(" Try manually:");
console.warn(` cd ${join(ROOT, "app")} && npm rebuild better-sqlite3`);
if (process.platform === "darwin") {
console.warn(" If build tools are missing: xcode-select --install");
}
console.warn("");
+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),
});
@@ -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>
);
})}
+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>
*/
+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");
+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 -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>
+9 -6
View File
@@ -227,7 +227,7 @@ async function handleSingleModelChat(
const resolved = await resolveModelOrError(modelStr, body);
if (resolved.error) return resolved.error;
const { provider, model, sourceFormat, targetFormat } = resolved;
const { provider, model, sourceFormat, targetFormat, extendedContext } = resolved;
// 2. Pipeline gates (availability + circuit breaker)
const gate = checkPipelineGates(provider, model);
@@ -290,6 +290,7 @@ async function handleSingleModelChat(
apiKeyInfo,
userAgent,
comboName,
extendedContext,
});
if (telemetry) telemetry.endPhase();
@@ -366,7 +367,7 @@ async function resolveModelOrError(modelStr: string, body: any) {
return { error: errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format") };
}
const { provider, model } = modelInfo;
const { provider, model, extendedContext } = modelInfo;
const sourceFormat = detectFormat(body);
const providerAlias = PROVIDER_ID_TO_ALIAS[provider] || provider;
@@ -378,13 +379,14 @@ async function resolveModelOrError(modelStr: string, body: any) {
log.info("ROUTING", `Custom model apiFormat=responses → targetFormat=openai-responses`);
}
const ctxTag = extendedContext && providerAlias === "claude" ? " [1m]" : "";
if (modelStr !== `${provider}/${model}`) {
log.info("ROUTING", `${modelStr}${provider}/${model}`);
log.info("ROUTING", `${modelStr}${provider}/${model}${ctxTag}`);
} else {
log.info("ROUTING", `Provider: ${provider}, Model: ${model}`);
log.info("ROUTING", `Provider: ${provider}, Model: ${model}${ctxTag}`);
}
return { provider, model, sourceFormat, targetFormat };
return { provider, model, sourceFormat, targetFormat, extendedContext };
}
/**
@@ -437,6 +439,7 @@ async function executeChatWithBreaker({
apiKeyInfo,
userAgent,
comboName,
extendedContext,
}: any): Promise<{ result: any; tlsFingerprintUsed: boolean }> {
let tlsFingerprintUsed = false;
@@ -445,7 +448,7 @@ async function executeChatWithBreaker({
runWithProxyContext(proxyInfo?.proxy || null, () =>
(handleChatCore as any)({
body: { ...body, model: `${provider}/${model}` },
modelInfo: { provider, model },
modelInfo: { provider, model, extendedContext },
credentials: refreshedCredentials,
log: logger,
clientRawRequest,
+8 -1
View File
@@ -39,6 +39,7 @@ async function lookupCustomModelApiFormat(
*/
export async function getModelInfo(modelStr) {
const parsed = parseModel(modelStr);
const { extendedContext } = parsed;
// Check custom provider nodes first (for both alias and non-alias formats)
if (parsed.providerAlias || parsed.provider) {
@@ -53,7 +54,12 @@ export async function getModelInfo(modelStr) {
matchedOpenAI.id as string,
parsed.model as string
);
return { provider: matchedOpenAI.id, model: parsed.model, ...(apiFormat && { apiFormat }) };
return {
provider: matchedOpenAI.id,
model: parsed.model,
extendedContext,
...(apiFormat && { apiFormat }),
};
}
// Check Anthropic Compatible nodes
@@ -67,6 +73,7 @@ export async function getModelInfo(modelStr) {
return {
provider: matchedAnthropic.id,
model: parsed.model,
extendedContext,
...(apiFormat && { apiFormat }),
};
}
+18
View File
@@ -0,0 +1,18 @@
// [1m] extended context suffix — PR #311 (DavyMassoneto)
test("[1m] suffix: strips suffix and sets extendedContext=true", () => {
const result = parseModel("claude-sonnet-4-6[1m]");
assert.strictEqual(result.model, "claude-sonnet-4-6");
assert.strictEqual(result.extendedContext, true);
});
test("[1m] suffix: normal model has extendedContext=false", () => {
const result = parseModel("claude-sonnet-4-6");
assert.strictEqual(result.model, "claude-sonnet-4-6");
assert.strictEqual(result.extendedContext, false);
});
test("[1m] suffix: works with provider prefix", () => {
const result = parseModel("claude/claude-sonnet-4-6[1m]");
assert.strictEqual(result.model, "claude-sonnet-4-6");
assert.strictEqual(result.extendedContext, true);
});
+143
View File
@@ -0,0 +1,143 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
detectNativeBinaryTarget,
isNativeBinaryCompatible,
} from "../../scripts/native-binary-compat.mjs";
function makeElfBinary(machine) {
const buffer = Buffer.alloc(64);
buffer[0] = 0x7f;
buffer[1] = 0x45;
buffer[2] = 0x4c;
buffer[3] = 0x46;
buffer[4] = 2;
buffer[5] = 1;
buffer.writeUInt16LE(machine, 18);
return buffer;
}
function makeMachBinary(cpuType) {
const buffer = Buffer.alloc(32);
buffer.writeUInt32BE(0xcffaedfe, 0);
buffer.writeUInt32LE(cpuType, 4);
return buffer;
}
function makePeBinary(machine) {
const buffer = Buffer.alloc(160);
buffer[0] = 0x4d;
buffer[1] = 0x5a;
buffer.writeUInt32LE(0x80, 0x3c);
buffer.write("PE\0\0", 0x80, "ascii");
buffer.writeUInt16LE(machine, 0x84);
return buffer;
}
describe("detectNativeBinaryTarget", () => {
it("detects linux x64 ELF binaries", () => {
assert.deepEqual(detectNativeBinaryTarget(makeElfBinary(62)), {
platform: "linux",
architectures: ["x64"],
});
});
it("detects darwin arm64 Mach-O binaries", () => {
assert.deepEqual(detectNativeBinaryTarget(makeMachBinary(0x0100000c)), {
platform: "darwin",
architectures: ["arm64"],
});
});
it("detects win32 x64 PE binaries", () => {
assert.deepEqual(detectNativeBinaryTarget(makePeBinary(0x8664)), {
platform: "win32",
architectures: ["x64"],
});
});
});
describe("isNativeBinaryCompatible", () => {
function withTempBinary(buffer, callback) {
const dir = mkdtempSync(join(tmpdir(), "omniroute-native-"));
const file = join(dir, "better_sqlite3.node");
writeFileSync(file, buffer);
try {
callback(file);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
it("accepts linux-x64 binaries when the target matches and dlopen succeeds", () => {
withTempBinary(makeElfBinary(62), (binaryPath) => {
assert.equal(
isNativeBinaryCompatible(binaryPath, {
runtimePlatform: "linux",
runtimeArch: "x64",
dlopen() {},
}),
true
);
});
});
it("rejects linux-x64 binaries when dlopen fails on the same platform", () => {
withTempBinary(makeElfBinary(62), (binaryPath) => {
assert.equal(
isNativeBinaryCompatible(binaryPath, {
runtimePlatform: "linux",
runtimeArch: "x64",
dlopen() {
throw new Error("abi mismatch");
},
}),
false
);
});
});
it("rejects macOS false positives for bundled linux binaries", () => {
withTempBinary(makeElfBinary(62), (binaryPath) => {
assert.equal(
isNativeBinaryCompatible(binaryPath, {
runtimePlatform: "darwin",
runtimeArch: "arm64",
dlopen() {},
}),
false
);
});
});
it("rejects Windows false positives for bundled linux binaries", () => {
withTempBinary(makeElfBinary(62), (binaryPath) => {
assert.equal(
isNativeBinaryCompatible(binaryPath, {
runtimePlatform: "win32",
runtimeArch: "x64",
dlopen() {},
}),
false
);
});
});
it("accepts copied darwin binaries after postinstall replacement", () => {
withTempBinary(makeMachBinary(0x0100000c), (binaryPath) => {
assert.equal(
isNativeBinaryCompatible(binaryPath, {
runtimePlatform: "darwin",
runtimeArch: "arm64",
dlopen() {},
}),
true
);
});
});
});