Compare commits

...

49 Commits

Author SHA1 Message Date
diegosouzapw 8e06dc5ace chore(release): v2.5.5 — model list dedup, Electron build hardening, Kiro credit tracking
Build Electron Desktop App / Validate version (push) Failing after 25s
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-15 12:34:58 -03:00
Diego Rodrigues de Sa e Souza bfd3e2c01b Merge pull request #381 from diegosouzapw/feat/issue-337-kiro-credits
feat: add Kiro credit tracking in usage fetcher (#337)
2026-03-15 12:33:14 -03:00
Diego Rodrigues de Sa e Souza a1957f0923 Merge pull request #380 from diegosouzapw/fix/issue-353-model-list-dedup
fix: include provider aliases in active provider filter (#353)
2026-03-15 12:33:13 -03:00
Diego Rodrigues de Sa e Souza 11a02ba361 Merge pull request #379 from kfiramar/fix/electron-standalone-bundle-pr
fix(electron): reject symlinked standalone bundles
2026-03-15 08:52:44 -03:00
diegosouzapw 4643c19abc feat: add Kiro credit tracking in usage fetcher (#337) 2026-03-15 08:47:27 -03:00
diegosouzapw a3369df62f fix: include provider aliases in active provider filter (#353) 2026-03-15 08:44:05 -03:00
Kfir Amar 4297c42597 chore(electron): add contextual staging errors 2026-03-15 12:33:16 +02:00
Kfir Amar e06e7157ac fix(electron): sanitize staged bundle paths cross-platform
Match both slash styles when removing build-machine paths from the
staged standalone bundle so the sanitization step works on Windows
and POSIX builds.

While touching the helper, replace the custom basename logic with
Node's built-in `path.basename` for clarity.
2026-03-15 12:26:23 +02:00
Kfir Amar 22f9e6f4c0 fix(electron): stage standalone bundle for desktop builds
Prepare a dedicated `.next/electron-standalone` bundle before
running electron-builder so desktop packaging operates on a stable,
Electron-specific server payload.

This also adds a preflight that rejects standalone bundles whose
top-level `node_modules` is a symlink, because electron-builder
preserves `extraResources` symlinks and would otherwise ship an app
that depends on the build machine at runtime.
2026-03-15 12:26:23 +02:00
diegosouzapw 4b7a9233e7 chore(release): v2.5.4 — logger fix, login bootstrap, HMR origins, CI hardening
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
2026-03-15 01:12:27 -03:00
Diego Rodrigues de Sa e Souza 204839f702 Merge pull request #374 from kfiramar/fix/dev-allowed-origins
fix(dev): allow loopback HMR origins
2026-03-15 01:10:56 -03:00
Diego Rodrigues de Sa e Souza d15e3109ee Merge pull request #375 from kfiramar/fix/login-bootstrap-metadata
fix(login): use public bootstrap route
2026-03-15 01:10:54 -03:00
Diego Rodrigues de Sa e Souza 8b513ee8f8 Merge pull request #376 from kfiramar/fix/logger-transport
fix(logger): restore transport logger path
2026-03-15 01:10:47 -03:00
diegosouzapw 2c1488e65a fix(ci): fix eslint OOM, failing tests, and strengthen pre-commit hook
- eslint.config.mjs: add missing ignores for vscode-extension/,
  electron/, docs/, app/.next/, clipr/ — ESLint was OOMing because
  it scanned huge VS Code binary blobs and build artifacts
- tests: remove stale ALTER TABLE 'group' statements — column is now
  part of the base schema in core.ts; tests were failing with
  SQLITE_ERROR: duplicate column name
- .husky/pre-commit: add npm run test:unit to block broken tests
  from reaching CI
2026-03-15 00:59:22 -03:00
Kfir Amar 8ebe1cc2d8 test(config): tighten dev origins assertion 2026-03-15 02:06:49 +02:00
Kfir Amar b0d6c15e63 fix(auth): harden login bootstrap checks
Stabilize the bootstrap metadata test by clearing
INITIAL_PASSWORD before each run and add focused coverage
for env-backed and stored-password states.

Log settings lookup failures before returning the
bootstrap-safe fallback payload so operational errors are
still visible on the server side.
2026-03-15 02:06:49 +02:00
Kfir Amar 3a3c7a7968 fix(logs): map numeric pino levels
Normalize numeric pino levels correctly in the console log API so the logger transport fix does not misclassify info, warn, and error entries in file-backed logs.

Add a targeted regression test for numeric log entries.
2026-03-15 01:51:59 +02:00
Kfir Amar 783d7ae605 test(dev): cover loopback dev origins
Add a focused config regression test that locks in localhost, 127.0.0.1, and the existing LAN dev origin allowlist.
2026-03-15 01:51:59 +02:00
Kfir Amar bbf7a6b2f8 test(login): cover bootstrap metadata route
Add a focused unit test for the public login bootstrap route so the branch is backed by the exact response contract the login page now relies on.
2026-03-15 01:51:59 +02:00
Kfir Amar 0fe6e24554 fix(logger): support transport targets
Keep the existing level formatter for direct logger paths, but drop
that formatter from transport-backed configs because pino rejects it
when transport.targets is used.

This restores the intended stdout+file transport path and avoids the
startup fallback warning on every boot.
2026-03-15 01:17:04 +02:00
Kfir Amar 4bbaf55586 fix(dev): allow localhost HMR origins
Add localhost and 127.0.0.1 to allowedDevOrigins so local dev
sessions opened on loopback addresses do not have their Next.js HMR
websocket blocked as cross-origin.
2026-03-15 01:17:04 +02:00
Kfir Amar cda765a02d fix(login): use public bootstrap settings
Point the login page at the existing public bootstrap endpoint
instead of the protected /api/settings route.

Also extend the public bootstrap response with hasPassword and
setupComplete so unauthenticated users get the correct first-run
or password-setup flow without triggering a 401.
2026-03-15 01:17:04 +02:00
diegosouzapw 36856b18db chore: release v2.5.3
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
bug fixes (PRs #373, #371, #372, #369 by @kfiramar):
- fix(db): provider_connections.group column migration for existing DBs
- fix(i18n): replace missing deleteConnection key with delete in tooltip
- fix(auth): clear stale error metadata on genuine provider recovery
- fix(startup): unify env loading across npm/electron startup paths

code quality improvements (per kilo-code-bot review):
- docs: document result.success vs response.ok patterns in auth.ts
- refactor: normalize overridePath?.trim() in electron/main.js
- docs: explain preferredEnv merge order intent
2026-03-14 19:53:59 -03:00
Diego Rodrigues de Sa e Souza 66f0a8f994 Merge pull request #369 from kfiramar/fix-startup-env-key-loading
Thanks @kfiramar! 🎉 Critical security fix — different startup paths were generating different `STORAGE_ENCRYPTION_KEY` values over the same SQLite database, causing `Unsupported state or unable to authenticate data` for all stored tokens.

Improvements added on top:
- Normalized `overridePath?.trim()` in `electron/main.js` to match `bootstrap-env.mjs` (addresses kilo-code-bot warning #1)
- Added explanatory comment documenting the `preferredEnv` merge order intent in Electron startup (addresses kilo-code-bot warning #3)

4 commits + 113-line test file. The fail-closed behaviour (refusing to mint a new key when encrypted rows exist) is an excellent safeguard. Merged!
2026-03-14 19:52:09 -03:00
Diego Rodrigues de Sa e Souza 455231170f Merge pull request #372 from kfiramar/fix/clear-provider-error-state
Thanks @kfiramar! 🎉 Critical fix — stale error metadata on recovered provider accounts was preventing valid accounts from being selected properly after recovery. 

Improvement added on top: documented the two valid success-check patterns (`result.success` for open-sse handlers vs `response?.ok` for fetch-based handlers) to address the kilo-code-bot review warning — both patterns are correct by design, now explicitly documented.

5 commits total, 2 test files (+168 lines of coverage). Merged!
2026-03-14 19:49:51 -03:00
Diego Rodrigues de Sa e Souza 5faeb58ab0 Merge pull request #371 from kfiramar/fix/provider-delete-tooltip-i18n
Thanks @kfiramar! Perfect minimal fix — `t("deleteConnection")` was requesting a non-existent key across all 30 locales, causing `MISSING_MESSAGE: providers.deleteConnection` runtime errors on every provider detail page load. Reusing the existing `providers.delete` key is the correct fix. Merged!
2026-03-14 19:48:01 -03:00
Diego Rodrigues de Sa e Souza 056e4a88ff Merge pull request #373 from kfiramar/fix/provider-connections-group-migration
Thanks @kfiramar! 🎉 Critical schema fix — the `group` column was used in all provider_connections queries but missing from the base schema and backfill migration. Databases upgraded from older versions were silently failing on group-related queries. Clean fix with regression test. Merged!
2026-03-14 19:47:58 -03:00
Kfir Amar 8fd944ccf7 fix(auth): type recovered state helpers
Tighten the helper signatures added for recovered provider cleanup.

This removes the new any-typed recovery parameters called out in
review without broadening the PR into unrelated auth typing work.
2026-03-14 23:11:59 +02:00
Kfir Amar 86105a547c fix(auth): clear stale state on non-chat success
Clear recovered provider error metadata after successful
credentialed requests in non-chat API routes as well.

Add route-level regression tests covering a Response-based
success path and a result-object success path.
2026-03-14 22:39:30 +02:00
Kfir Amar 9806648c07 test(auth): cover stale active error metadata path
Refine the recovered-account regression test to match the real
observed state: an account can remain active while still carrying
stale refresh-failure metadata.

This verifies that getProviderCredentials surfaces those fields
and that clearAccountError clears them through the real runtime
path.
2026-03-14 22:31:03 +02:00
Kfir Amar 6186babdb3 fix(auth): include error fields in recovery path
Pass errorCode, lastErrorType, and lastErrorSource through the
runtime credentials object so clearAccountError can clear stale
provider error metadata after a real successful request.

Also update the regression test to use getProviderCredentials,
matching the production call path.
2026-03-14 22:24:08 +02:00
Kfir Amar f2ecefb54a fix(i18n): use existing provider delete label
Replace a missing deleteConnection message lookup with the
existing delete label to avoid the provider-page runtime i18n
overlay.
2026-03-14 22:18:41 +02:00
Kfir Amar 43bd529b78 fix(db): add provider connection group migration
Add the missing provider_connections.group column to both the
base schema and the runtime column backfill path.

Also add a regression test covering upgrade from an older
database that does not yet have the column.
2026-03-14 22:18:41 +02:00
Kfir Amar 9c82b3d4ca fix(auth): clear stale provider error metadata
Clear errorCode, lastErrorType, and lastErrorSource when an
account recovers so provider state returns to a fully clean
active status.

Add a focused regression test for recovered-account cleanup.
2026-03-14 22:18:41 +02:00
Kfir Amar b19e6a8e87 fix(startup): pass env through env-file lookup
Keep getPreferredEnvFilePath consistent with its env parameter by
passing that env through resolveDataDir in both bootstrap and Electron.

This avoids silently falling back to process.env when a custom env map
is supplied.
2026-03-14 21:33:34 +02:00
Kfir Amar e3a2bd75f3 fix(startup): ignore blank data dir override
Treat empty or whitespace-only dataDirOverride values as unset so
bootstrapEnv keeps using the normal DATA_DIR and .env lookup path.

Adds a focused regression test for the whitespace override case.
2026-03-14 21:29:34 +02:00
Kfir Amar da39e1485f fix(startup): fail closed on key inspection errors
Propagate database inspection failures instead of treating them as
missing encrypted credentials.

This keeps startup from generating a fresh encryption key when an
existing database cannot be inspected and adds a regression test for
that path.
2026-03-14 21:23:07 +02:00
Kfir Amar 88cc53a4b0 fix(startup): honor documented env loading
Align the app bootstrap paths with the documented CLI env lookup.

The CLI wrapper already loads DATA_DIR/.env, ~/.omniroute/.env, or ./.env,
but run-next, run-standalone, and Electron were bypassing that behavior.
On machines with encrypted credentials, that could generate a fresh
STORAGE_ENCRYPTION_KEY in server.env and make existing tokens unreadable.

This change:
- uses the same preferred .env lookup in bootstrapEnv and Electron
- keeps Electron secrets rooted in DATA_DIR and passes DATA_DIR to the child
- refuses to mint a new encryption key over an existing encrypted database
- adds a focused regression test for env precedence and key safety
2026-03-14 21:14:19 +02:00
diegosouzapw 245243c7e7 chore: release v2.5.2 (version bump, npm conflict with 2.5.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
2026-03-14 16:01:14 -03:00
diegosouzapw 759ac0df3d chore: release v2.5.1
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
- PR #368: gpt-5.4 in Codex model registry (cx/gpt-5.4, codex/gpt-5.4)
- PR #367: Codex fast tier toggle (default-off, full stack, 48 tests)
- PR #366: Codex quota policy 5h/weekly with auto-rotation
- fix #356: analytics charts show provider display names not raw IDs
2026-03-14 15:55:06 -03:00
Diego Rodrigues de Sa e Souza db8d97b6de Merge pull request #366 from rexname/feature/codex-account-limit-rotation
Thanks @rexname (Maulana Hasanudin)! 🎉 

Codex account quota policy (5h/weekly) with auto-rotation is now merged. Highlights:
- Per-account policy toggles (5h + weekly ON/OFF) in the Provider dashboard
- Accounts automatically skipped when enabled quota window reaches 90% threshold
- Auto re-eligibility when resetAt timestamp passes (no manual intervention needed)
- Side-effect free `getQuotaWindowStatus` getter design
- Safe partial merge of `codexLimitPolicy` on provider updates

Merged on top of main (v2.5.0) with no conflicts. Analytics label fix (#356) included. Thanks for the excellent quality and the 2-commit cleanup round! 🙏
2026-03-14 15:54:07 -03:00
Diego Rodrigues de Sa e Souza 27d66e4b3e Merge pull request #367 from kfiramar/feat-codex-fast-toggle
Thanks @kfiramar! Codex fast-tier toggle merged 🎉 — default-off, full stack (UI tab + API + executor injection + translator passthrough + startup restore). 48 tests passing. Users can now enable flex tier in Dashboard → Settings → Codex Service Tier.
2026-03-14 15:49:56 -03:00
Diego Rodrigues de Sa e Souza ca7854210d Merge pull request #368 from kfiramar/fix-codex-gpt54-models
Thanks @kfiramar! gpt-5.4 is now exposed in the model catalog as `cx/gpt-5.4` and `codex/gpt-5.4`. Minimal, tested fix — merged directly. 🙏
2026-03-14 15:49:54 -03:00
Kfir Amar c009c993c3 fix(codex): persist fast-tier toggle before applying runtime state 2026-03-14 20:48:19 +02:00
Kfir Amar 00188f75ae feat(codex): add fast tier settings toggle
Add a default-off dashboard setting that injects Codex fast service tier only when the request did not already specify one.

Also preserve service_tier through OpenAI-to-Responses translation and restore the setting at startup.
2026-03-14 20:41:49 +02:00
diegosouzapw 4d086542aa fix: getProviderCredentials missing allowedConnections param (#363 TS error)
PR #363 added allowedConnections as 3rd arg in chat.ts calls to
getProviderCredentials(), but the function signature in auth.ts
only declared 2 params. Adding the optional 3rd param and applying
the connection filter when provided.
2026-03-14 15:38:12 -03:00
rexname 1555883633 fix(codex): address PR review feedback for quota policy flow
- add user-facing success/error notifications for Codex limit toggle API calls
- deduplicate Codex policy default normalization in providers page
- make getQuotaWindowStatus side-effect free (no cache mutation in getter)
- avoid stale threshold blocking after resetAt has passed
- extract named Codex quota threshold constant
- extract helper for earliest future reset date selection
2026-03-15 01:35:19 +07:00
Kfir Amar 8f2c0acc7e fix(codex): advertise gpt-5.4 models
Add gpt-5.4 to the Codex model registry so OmniRoute exposes cx/gpt-5.4 and codex/gpt-5.4 in its model catalog.

Includes a focused regression test for model resolution.
2026-03-14 20:33:47 +02:00
rexname 0e30d15c01 feat(codex): add account-level 5h/weekly quota policy and auto-rotation
- add quota window status helper for Codex session (5h) and weekly windows
- enforce policy-based account filtering when enabled windows reach threshold
- return all-rate-limited metadata when no Codex account is eligible
- add per-account dashboard toggles for 5h and weekly policy controls
- merge codexLimitPolicy safely on provider updates to preserve partial settings
- document purpose and usage scenarios in README (EN + ID + i18n note)
2026-03-15 01:33:44 +07:00
51 changed files with 1713 additions and 86 deletions
+1
View File
@@ -1,2 +1,3 @@
npx lint-staged
node scripts/check-docs-sync.mjs
npm run test:unit
+59 -1
View File
@@ -2,7 +2,65 @@
## [Unreleased]
## [2.5.0] - 2026-03-14
---
## [2.5.5] - 2026-03-15
> Model list dedup fix, Electron standalone build hardening, and Kiro credit tracking.
### 🐛 Bug Fixes
- **fix(models) #380**: `GET /api/models` now includes provider aliases when building the active-provider filter — models for `claude` (alias `cc`) and `github` (alias `gh`) were always shown regardless of whether a connection was configured, because `PROVIDER_MODELS` keys are aliases but DB connections are stored under provider IDs. Fixed by expanding each active provider ID to also include its alias via `PROVIDER_ID_TO_ALIAS`. Closes #353.
- **fix(electron) #379**: New `scripts/prepare-electron-standalone.mjs` stages a dedicated `/.next/electron-standalone` bundle before Electron packaging. Aborts with a clear error if `node_modules` is a symlink (electron-builder would ship a runtime dependency on the build machine). Cross-platform path sanitization via `path.basename`. By @kfiramar.
### ✨ New Features
- **feat(kiro) #381**: Kiro credit balance tracking — usage endpoint now returns credit data for Kiro accounts by calling `codewhisperer.us-east-1.amazonaws.com/getUserCredits` (same endpoint Kiro IDE uses internally). Returns remaining credits, total allowance, renewal date, and subscription tier. Closes #337.
## [2.5.4] - 2026-03-15
> Logger startup fix, login bootstrap security fix, and dev HMR reliability improvement. CI infrastructure hardened.
### 🐛 Bug Fixes (PRs #374, #375, #376 by @kfiramar)
- **fix(logger) #376**: Restore pino transport logger path — `formatters.level` combined with `transport.targets` is rejected by pino. Transport-backed configs now strip the level formatter via `getTransportCompatibleConfig()`. Also corrects numeric level mapping in `/api/logs/console`: `30→info, 40→warn, 50→error` (was shifted by one).
- **fix(login) #375**: Login page now bootstraps from the public `/api/settings/require-login` endpoint instead of the protected `/api/settings`. In password-protected setups, the pre-auth page was receiving a 401 and falling back to safe defaults unnecessarily. The public route now returns all bootstrap metadata (`requireLogin`, `hasPassword`, `setupComplete`) with a conservative 200 fallback on error.
- **fix(dev) #374**: Add `localhost` and `127.0.0.1` to `allowedDevOrigins` in `next.config.mjs` — HMR websocket was blocked when accessing the app via loopback address, producing repeated cross-origin warnings.
### 🔧 CI & Infrastructure
- **ESLint OOM fix**: `eslint.config.mjs` now ignores `vscode-extension/**`, `electron/**`, `docs/**`, `app/.next/**`, and `clipr/**` — ESLint was crashing with a JS heap OOM by scanning VS Code binary blobs and compiled chunks.
- **Unit test fix**: Removed stale `ALTER TABLE provider_connections ADD COLUMN "group"` from 2 test files — column is now part of the base schema (added in #373), causing `SQLITE_ERROR: duplicate column name` on every CI run.
- **Pre-commit hook**: Added `npm run test:unit` to `.husky/pre-commit` — unit tests now block broken commits before they reach CI.
## [2.5.3] - 2026-03-14
> Critical bugfixes: DB schema migration, startup env loading, provider error state clearing, and i18n tooltip fix. Code quality improvements on top of each PR.
### 🐛 Bug Fixes (PRs #369, #371, #372, #373 by @kfiramar)
- **fix(db) #373**: Add `provider_connections.group` column to base schema + backfill migration for existing databases — column was used in all queries but missing from schema definition
- **fix(i18n) #371**: Replace non-existent `t("deleteConnection")` key with existing `providers.delete` key — fixes `MISSING_MESSAGE: providers.deleteConnection` runtime error on provider detail page
- **fix(auth) #372**: Clear stale error metadata (`errorCode`, `lastErrorType`, `lastErrorSource`) from provider accounts after genuine recovery — previously, recovered accounts kept appearing as failed
- **fix(startup) #369**: Unify env loading across `npm run start`, `run-standalone.mjs`, and Electron to respect `DATA_DIR/.env → ~/.omniroute/.env → ./.env` priority — prevents generating a new `STORAGE_ENCRYPTION_KEY` over an existing encrypted database
### 🔧 Code Quality
- Documented `result.success` vs `response?.ok` patterns in `auth.ts` (both intentional, now explained)
- Normalized `overridePath?.trim()` in `electron/main.js` to match `bootstrap-env.mjs`
- Added `preferredEnv` merge order comment in Electron startup
> Codex account quota policy with auto-rotation, fast tier toggle, gpt-5.4 model, and analytics label fix.
### ✨ New Features (PRs #366, #367, #368)
- **Codex Quota Policy (PR #366)**: Per-account 5h/weekly quota window toggles in Provider dashboard. Accounts are automatically skipped when enabled windows reach 90% threshold and re-admitted after `resetAt`. Includes `quotaCache.ts` with side-effect free status getter.
- **Codex Fast Tier Toggle (PR #367)**: Dashboard → Settings → Codex Service Tier. Default-off toggle injects `service_tier: "flex"` only for Codex requests, reducing cost ~80%. Full stack: UI tab + API endpoint + executor + translator + startup restore.
- **gpt-5.4 Model (PR #368)**: Adds `cx/gpt-5.4` and `codex/gpt-5.4` to the Codex model registry. Regression test included.
### 🐛 Bug Fixes
- **fix #356**: Analytics charts (Top Provider, By Account, Provider Breakdown) now display human-readable provider names/labels instead of raw internal IDs for OpenAI-compatible providers.
> Major release: strict-random routing strategy, API key access controls, connection groups, external pricing sync, and critical bug fixes for thinking models, combo testing, and tool name validation.
+17
View File
@@ -1292,6 +1292,23 @@ Models:
cx/gpt-5.1-codex-max
```
#### Codex Account Limit Management (5h + Weekly)
Each Codex account now has policy toggles in `Dashboard -> Providers`:
- `5h` (ON/OFF): enforce the 5-hour window threshold policy.
- `Weekly` (ON/OFF): enforce the weekly window threshold policy.
- Threshold behavior: when an enabled window reaches >=90% usage, that account is skipped.
- Rotation behavior: OmniRoute routes to the next eligible Codex account automatically.
- Reset behavior: when the provider `resetAt` time passes, the account becomes eligible again automatically.
Scenarios:
- `5h ON` + `Weekly ON`: account is skipped when either window reaches threshold.
- `5h OFF` + `Weekly ON`: only weekly usage can block the account.
- `5h ON` + `Weekly OFF`: only 5-hour usage can block the account.
- `resetAt` passed: account re-enters rotation automatically (no manual re-enable).
### Gemini CLI (FREE 180K/month!)
```bash
+9
View File
@@ -9,4 +9,13 @@ This directory contains machine-assisted translations based on the English docs.
- **TROUBLESHOOTING.md**: 🇺🇸 [English](../TROUBLESHOOTING.md) | 🇧🇷 [Português (Brasil)](./pt-BR/TROUBLESHOOTING.md) | 🇪🇸 [Español](./es/TROUBLESHOOTING.md) | 🇫🇷 [Français](./fr/TROUBLESHOOTING.md) | 🇮🇹 [Italiano](./it/TROUBLESHOOTING.md) | 🇷🇺 [Русский](./ru/TROUBLESHOOTING.md) | 🇨🇳 [中文 (简体)](./zh-CN/TROUBLESHOOTING.md) | 🇩🇪 [Deutsch](./de/TROUBLESHOOTING.md) | 🇮🇳 [हिन्दी](./in/TROUBLESHOOTING.md) | 🇹🇭 [ไทย](./th/TROUBLESHOOTING.md) | 🇺🇦 [Українська](./uk-UA/TROUBLESHOOTING.md) | 🇸🇦 [العربية](./ar/TROUBLESHOOTING.md) | 🇯🇵 [日本語](./ja/TROUBLESHOOTING.md) | 🇻🇳 [Tiếng Việt](./vi/TROUBLESHOOTING.md) | 🇧🇬 [Български](./bg/TROUBLESHOOTING.md) | 🇩🇰 [Dansk](./da/TROUBLESHOOTING.md) | 🇫🇮 [Suomi](./fi/TROUBLESHOOTING.md) | 🇮🇱 [עברית](./he/TROUBLESHOOTING.md) | 🇭🇺 [Magyar](./hu/TROUBLESHOOTING.md) | 🇮🇩 [Bahasa Indonesia](./id/TROUBLESHOOTING.md) | 🇰🇷 [한국어](./ko/TROUBLESHOOTING.md) | 🇲🇾 [Bahasa Melayu](./ms/TROUBLESHOOTING.md) | 🇳🇱 [Nederlands](./nl/TROUBLESHOOTING.md) | 🇳🇴 [Norsk](./no/TROUBLESHOOTING.md) | 🇵🇹 [Português (Portugal)](./pt/TROUBLESHOOTING.md) | 🇷🇴 [Română](./ro/TROUBLESHOOTING.md) | 🇵🇱 [Polski](./pl/TROUBLESHOOTING.md) | 🇸🇰 [Slovenčina](./sk/TROUBLESHOOTING.md) | 🇸🇪 [Svenska](./sv/TROUBLESHOOTING.md) | 🇵🇭 [Filipino](./phi/TROUBLESHOOTING.md)
- **USER_GUIDE.md**: 🇺🇸 [English](../USER_GUIDE.md) | 🇧🇷 [Português (Brasil)](./pt-BR/USER_GUIDE.md) | 🇪🇸 [Español](./es/USER_GUIDE.md) | 🇫🇷 [Français](./fr/USER_GUIDE.md) | 🇮🇹 [Italiano](./it/USER_GUIDE.md) | 🇷🇺 [Русский](./ru/USER_GUIDE.md) | 🇨🇳 [中文 (简体)](./zh-CN/USER_GUIDE.md) | 🇩🇪 [Deutsch](./de/USER_GUIDE.md) | 🇮🇳 [हिन्दी](./in/USER_GUIDE.md) | 🇹🇭 [ไทย](./th/USER_GUIDE.md) | 🇺🇦 [Українська](./uk-UA/USER_GUIDE.md) | 🇸🇦 [العربية](./ar/USER_GUIDE.md) | 🇯🇵 [日本語](./ja/USER_GUIDE.md) | 🇻🇳 [Tiếng Việt](./vi/USER_GUIDE.md) | 🇧🇬 [Български](./bg/USER_GUIDE.md) | 🇩🇰 [Dansk](./da/USER_GUIDE.md) | 🇫🇮 [Suomi](./fi/USER_GUIDE.md) | 🇮🇱 [עברית](./he/USER_GUIDE.md) | 🇭🇺 [Magyar](./hu/USER_GUIDE.md) | 🇮🇩 [Bahasa Indonesia](./id/USER_GUIDE.md) | 🇰🇷 [한국어](./ko/USER_GUIDE.md) | 🇲🇾 [Bahasa Melayu](./ms/USER_GUIDE.md) | 🇳🇱 [Nederlands](./nl/USER_GUIDE.md) | 🇳🇴 [Norsk](./no/USER_GUIDE.md) | 🇵🇹 [Português (Portugal)](./pt/USER_GUIDE.md) | 🇷🇴 [Română](./ro/USER_GUIDE.md) | 🇵🇱 [Polski](./pl/USER_GUIDE.md) | 🇸🇰 [Slovenčina](./sk/USER_GUIDE.md) | 🇸🇪 [Svenska](./sv/USER_GUIDE.md) | 🇵🇭 [Filipino](./phi/USER_GUIDE.md)
## Recent note: Codex account limit policy
Documentation now includes Codex account-level quota policy behavior:
- Per-account toggles: `5h` and `Weekly` (ON/OFF).
- Threshold policy: enabled window reaching >=90% marks account as ineligible for selection.
- Auto-rotation: traffic moves to the next eligible Codex account.
- Auto-reuse: account becomes eligible again after provider `resetAt` passes.
Generated on 2026-02-26.
+17
View File
@@ -1059,6 +1059,23 @@ Models:
cx/gpt-5.1-codex-max
```
#### Manajemen Limit Akun Codex (5h + Mingguan)
Setiap akun Codex sekarang punya toggle kebijakan di `Dashboard -> Providers`:
- `5h` (ON/OFF): menerapkan kebijakan ambang untuk jendela 5 jam.
- `Weekly` (ON/OFF): menerapkan kebijakan ambang untuk jendela mingguan.
- Perilaku ambang: saat jendela yang aktif mencapai >=90% penggunaan, akun tersebut di-skip.
- Perilaku rotasi: OmniRoute otomatis merutekan ke akun Codex berikutnya yang masih eligible.
- Perilaku reset: saat waktu `resetAt` provider sudah lewat, akun otomatis bisa dipakai lagi.
Skenario:
- `5h ON` + `Weekly ON`: akun di-skip jika salah satu jendela mencapai ambang.
- `5h OFF` + `Weekly ON`: hanya penggunaan mingguan yang bisa memblokir akun.
- `5h ON` + `Weekly OFF`: hanya penggunaan 5 jam yang bisa memblokir akun.
- `resetAt` sudah lewat: akun otomatis masuk rotasi lagi (tanpa enable manual).
### Gemini CLI (GRATIS 180K/bulan!)
```bash
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 2.5.0
version: 2.5.5
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,
+77 -6
View File
@@ -64,6 +64,64 @@ let serverPort = 20128;
const getServerUrl = () => `http://localhost:${serverPort}`;
function resolveDataDir(overridePath, env = process.env) {
if (overridePath && overridePath.trim()) return path.resolve(overridePath);
const configured = env.DATA_DIR?.trim();
if (configured) return path.resolve(configured);
if (process.platform === "win32") {
const appData = env.APPDATA || path.join(require("os").homedir(), "AppData", "Roaming");
return path.join(appData, "omniroute");
}
const xdg = env.XDG_CONFIG_HOME?.trim();
if (xdg) return path.join(path.resolve(xdg), "omniroute");
return path.join(require("os").homedir(), ".omniroute");
}
function getPreferredEnvFilePath(env = process.env) {
const candidates = [];
if (env.DATA_DIR?.trim()) {
candidates.push(path.join(path.resolve(env.DATA_DIR.trim()), ".env"));
}
candidates.push(path.join(resolveDataDir(null, env), ".env"));
candidates.push(path.join(process.cwd(), ".env"));
return candidates.find((filePath) => fs.existsSync(filePath)) || null;
}
function hasEncryptedCredentials(dbPath) {
if (!fs.existsSync(dbPath)) return false;
try {
const Database = require("better-sqlite3");
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
try {
const row = db
.prepare(
`SELECT 1
FROM provider_connections
WHERE access_token LIKE 'enc:v1:%'
OR refresh_token LIKE 'enc:v1:%'
OR api_key LIKE 'enc:v1:%'
OR id_token LIKE 'enc:v1:%'
LIMIT 1`
)
.get();
return !!row;
} finally {
db.close();
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Unable to inspect existing database at ${dbPath}: ${message}`);
}
}
// ── Auto-Updater Configuration ──────────────────────────────
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
@@ -386,12 +444,10 @@ function startNextServer() {
// ── 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
// 1. Read persisted secrets from the resolved DATA_DIR/server.env
// 2. Generate missing secrets with crypto.randomBytes()
// 3. Persist back to userData/server.env for future restarts
// 3. Persist back to DATA_DIR/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) {
@@ -407,8 +463,12 @@ function startNextServer() {
return env;
}
const preferredEnvPath = getPreferredEnvFilePath(process.env);
const preferredEnv = preferredEnvPath ? parseEnvFile(preferredEnvPath) : {};
const dataDir = resolveDataDir(null, { ...preferredEnv, ...process.env });
const serverEnvPath = path.join(dataDir, "server.env");
const persisted = parseEnvFile(serverEnvPath);
const serverEnv = { ...process.env, ...persisted };
const serverEnv = { ...persisted, ...preferredEnv, ...process.env };
let changed = false;
if (!serverEnv.JWT_SECRET) {
@@ -417,6 +477,16 @@ function startNextServer() {
console.log("[Electron] ✨ JWT_SECRET auto-generated");
}
if (!serverEnv.STORAGE_ENCRYPTION_KEY) {
if (hasEncryptedCredentials(path.join(dataDir, "storage.sqlite"))) {
console.error(
`[Electron] Refusing to auto-generate STORAGE_ENCRYPTION_KEY: encrypted credentials already exist in ${path.join(
dataDir,
"storage.sqlite"
)}. Restore the key via ${preferredEnvPath || "an appropriate .env file"}, ${serverEnvPath}, or process.env.`
);
sendToRenderer("server-status", { status: "error", port: serverPort });
return;
}
serverEnv.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY = crypto
.randomBytes(32)
.toString("hex");
@@ -432,7 +502,7 @@ function startNextServer() {
if (changed) {
serverEnv.OMNIROUTE_BOOTSTRAPPED = "true";
try {
fs.mkdirSync(userDataDir, { recursive: true });
fs.mkdirSync(dataDir, { recursive: true });
const lines = [
"# Auto-generated by OmniRoute bootstrap",
"",
@@ -454,6 +524,7 @@ function startNextServer() {
cwd: NEXT_SERVER_PATH,
env: {
...serverEnv,
DATA_DIR: dataDir,
PORT: String(serverPort),
NODE_ENV: "production",
},
+13 -18
View File
@@ -12,13 +12,14 @@
"scripts": {
"start": "electron .",
"dev": "electron . --no-sandbox",
"build": "electron-builder",
"build:win": "electron-builder --win",
"build:mac": "electron-builder --mac",
"build:mac-x64": "electron-builder --mac --x64",
"build:mac-arm64": "electron-builder --mac --arm64",
"build:linux": "electron-builder --linux",
"pack": "electron-builder --dir"
"prepare:bundle": "node ../scripts/prepare-electron-standalone.mjs",
"build": "npm run prepare:bundle && electron-builder",
"build:win": "npm run prepare:bundle && electron-builder --win",
"build:mac": "npm run prepare:bundle && electron-builder --mac",
"build:mac-x64": "npm run prepare:bundle && electron-builder --mac --x64",
"build:mac-arm64": "npm run prepare:bundle && electron-builder --mac --arm64",
"build:linux": "npm run prepare:bundle && electron-builder --linux",
"pack": "npm run prepare:bundle && electron-builder --dir"
},
"dependencies": {
"electron-updater": "^6.8.3"
@@ -47,24 +48,18 @@
],
"extraResources": [
{
"from": "../.next/standalone",
"from": "../.next/electron-standalone",
"to": "app",
"filter": [
"**/*"
]
},
{
"from": "../.next/static",
"to": "app/.next/static",
"from": "assets",
"to": "assets",
"filter": [
"**/*"
]
},
{
"from": "../public",
"to": "app/public",
"filter": [
"**/*"
"icon.png",
"tray-icon.png"
]
}
],
+19 -1
View File
@@ -24,16 +24,34 @@ const eslintConfig = [
"react-hooks/rules-of-hooks": "off",
},
},
// Global ignores (open-sse and tests REMOVED — now linted)
// Global ignores — keep ESLint scoped to source files only
{
ignores: [
// Next.js build output
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
// Scripts and binaries
"scripts/**",
"bin/**",
// Dependencies
"node_modules/**",
// VS Code extension and its large test fixtures
"vscode-extension/**",
// Electron app
"electron/**",
// Docs
"docs/**",
// Open-SSE compiled/bundled output
"open-sse/mcp-server/dist/**",
// Playwright test output
"playwright-report/**",
"test-results/**",
// Subdirectory .next build output (app/ subdir)
"app/.next/**",
// CLI package copy directory
"clipr/**",
],
},
];
+1 -1
View File
@@ -8,7 +8,7 @@ const nextConfig = {
output: "standalone",
serverExternalPackages: ["better-sqlite3", "zod"],
transpilePackages: ["@omniroute/open-sse"],
allowedDevOrigins: ["192.168.*"],
allowedDevOrigins: ["localhost", "127.0.0.1", "192.168.*"],
typescript: {
// TODO: Re-enable after fixing all sub-component useTranslations scope issues
ignoreBuildErrors: true,
+1
View File
@@ -186,6 +186,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
tokenUrl: "https://auth.openai.com/oauth/token",
},
models: [
{ id: "gpt-5.4", name: "GPT 5.4" },
{ id: "gpt-5.3-codex", name: "GPT 5.3 Codex" },
{ id: "gpt-5.3-codex-xhigh", name: "GPT 5.3 Codex (xHigh)" },
{ id: "gpt-5.3-codex-high", name: "GPT 5.3 Codex (High)" },
+21
View File
@@ -6,6 +6,20 @@ import { refreshCodexToken } from "../services/tokenRefresh.ts";
// Ordered list of effort levels from lowest to highest
const EFFORT_ORDER = ["none", "low", "medium", "high", "xhigh"] as const;
type EffortLevel = (typeof EFFORT_ORDER)[number];
const CODEX_FAST_WIRE_VALUE = "priority";
let defaultFastServiceTierEnabled = false;
function normalizeServiceTierValue(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.trim().toLowerCase();
if (!normalized) return undefined;
if (normalized === "fast") return CODEX_FAST_WIRE_VALUE;
return normalized;
}
export function setDefaultFastServiceTierEnabled(enabled: boolean): void {
defaultFastServiceTierEnabled = enabled;
}
/**
* Maximum reasoning effort allowed per Codex model.
@@ -103,6 +117,13 @@ export class CodexExecutor extends BaseExecutor {
// Ensure store is false (Codex requirement)
body.store = false;
const requestServiceTier = normalizeServiceTierValue(body.service_tier);
if (requestServiceTier) {
body.service_tier = requestServiceTier;
} else if (defaultFastServiceTierEnabled) {
body.service_tier = CODEX_FAST_WIRE_VALUE;
}
// Extract thinking level from model name suffix
// e.g., gpt-5.3-codex-high → high, gpt-5.3-codex → medium (default)
const effortLevels = ["none", "low", "medium", "high", "xhigh"];
@@ -363,6 +363,7 @@ export function openaiToOpenAIResponsesRequest(
}
// Pass through relevant fields
if (root.service_tier !== undefined) result.service_tier = root.service_tier;
if (root.temperature !== undefined) result.temperature = root.temperature;
if (root.max_tokens !== undefined) result.max_tokens = root.max_tokens;
if (root.top_p !== undefined) result.top_p = root.top_p;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "2.4.4",
"version": "2.5.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "2.4.4",
"version": "2.5.5",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "2.5.0",
"version": "2.5.5",
"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": {
+68 -16
View File
@@ -7,22 +7,25 @@
* restarts, Docker volume remounts, and upgrades.
*
* Works across all deployment modes:
* - npm / CLI: called from run-standalone.mjs and run-next.mjs
* - npm / app runners: 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
* - Electron: called from main.js startup, persisted in DATA_DIR
*
* Priority (lowest highest):
* 1. Auto-generated defaults
* 2. {DATA_DIR}/server.env (persisted on first boot)
* 3. .env in CWD (user overrides)
* 3. Preferred config .env (DATA_DIR/.env -> ~/.omniroute/.env -> ./.env)
* 4. process.env (shell / Docker -e flags, highest priority)
*/
import { createHash, randomBytes } from "node:crypto";
import { randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
const require = createRequire(import.meta.url);
// ── OAuth secrets that are optional but warn if missing ─────────────────────
const OPTIONAL_OAUTH_SECRETS = [
{ key: "ANTIGRAVITY_OAUTH_CLIENT_SECRET", label: "Antigravity OAuth" },
@@ -31,23 +34,65 @@ const OPTIONAL_OAUTH_SECRETS = [
];
// ── Resolve DATA_DIR (mirrors dataPaths.ts logic) ───────────────────────────
function resolveDataDir(overridePath) {
if (overridePath) return resolve(overridePath);
function resolveDataDir(overridePath, env = process.env) {
if (overridePath?.trim()) return resolve(overridePath);
const configured = process.env.DATA_DIR?.trim();
const configured = env.DATA_DIR?.trim();
if (configured) return resolve(configured);
if (process.platform === "win32") {
const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
const appData = env.APPDATA || join(homedir(), "AppData", "Roaming");
return join(appData, "omniroute");
}
const xdg = process.env.XDG_CONFIG_HOME?.trim();
const xdg = env.XDG_CONFIG_HOME?.trim();
if (xdg) return join(resolve(xdg), "omniroute");
return join(homedir(), ".omniroute");
}
function getPreferredEnvFilePath(env = process.env) {
const candidates = [];
if (env.DATA_DIR?.trim()) {
candidates.push(join(resolve(env.DATA_DIR.trim()), ".env"));
}
candidates.push(join(resolveDataDir(null, env), ".env"));
candidates.push(join(process.cwd(), ".env"));
return candidates.find((filePath) => existsSync(filePath)) ?? null;
}
function hasEncryptedCredentials(dataDir) {
const dbPath = join(dataDir, "storage.sqlite");
if (!existsSync(dbPath)) return false;
try {
const Database = require("better-sqlite3");
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
try {
const row = db
.prepare(
`SELECT 1
FROM provider_connections
WHERE access_token LIKE 'enc:v1:%'
OR refresh_token LIKE 'enc:v1:%'
OR api_key LIKE 'enc:v1:%'
OR id_token LIKE 'enc:v1:%'
LIMIT 1`
)
.get();
return !!row;
} finally {
db.close();
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Unable to inspect existing database at ${dbPath}: ${message}`);
}
}
// ── Parse a simple KEY=VALUE env file ───────────────────────────────────────
function parseEnvFile(filePath) {
if (!existsSync(filePath)) return {};
@@ -85,18 +130,17 @@ function writeEnvFile(filePath, env) {
export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) {
const log = quiet ? () => {} : (msg) => process.stderr.write(`[bootstrap] ${msg}\n`);
const dataDir = resolveDataDir(dataDirOverride);
const preferredEnvPath = getPreferredEnvFilePath(process.env);
const preferredEnv = preferredEnvPath ? parseEnvFile(preferredEnvPath) : {};
const dataDir = resolveDataDir(dataDirOverride, { ...preferredEnv, ...process.env });
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 };
// ── Layer 2: Load the same preferred .env that the CLI wrapper uses ───────
// This keeps run-next / run-standalone consistent with `bin/omniroute.mjs`.
const merged = { ...persisted, ...preferredEnv, ...process.env };
// ── Auto-generate required secrets ────────────────────────────────────────
let needsPersist = false;
@@ -109,6 +153,14 @@ export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) {
}
if (!merged.STORAGE_ENCRYPTION_KEY?.trim()) {
if (hasEncryptedCredentials(dataDir)) {
throw new Error(
`Refusing to auto-generate STORAGE_ENCRYPTION_KEY: encrypted credentials already exist in ${join(
dataDir,
"storage.sqlite"
)}. Restore the key via ${preferredEnvPath ?? "an appropriate .env file"}, ${serverEnvPath}, or process.env.`
);
}
persisted.STORAGE_ENCRYPTION_KEY = randomBytes(32).toString("hex");
merged.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY;
needsPersist = true;
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env node
import {
cpSync,
existsSync,
lstatSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { basename, dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ROOT = join(__dirname, "..");
const STANDALONE_DIR = join(ROOT, ".next", "standalone");
const ELECTRON_STANDALONE_DIR = join(ROOT, ".next", "electron-standalone");
const STATIC_SRC = join(ROOT, ".next", "static");
const STATIC_DEST = join(ELECTRON_STANDALONE_DIR, ".next", "static");
const PUBLIC_SRC = join(ROOT, "public");
const PUBLIC_DEST = join(ELECTRON_STANDALONE_DIR, "public");
function resolveStandaloneBundleDir() {
const directServer = join(STANDALONE_DIR, "server.js");
if (existsSync(directServer)) {
return STANDALONE_DIR;
}
const nestedCandidates = [
join(STANDALONE_DIR, "projects", "OmniRoute"),
join(STANDALONE_DIR, basename(ROOT)),
];
for (const candidate of nestedCandidates) {
if (existsSync(join(candidate, "server.js"))) {
return candidate;
}
}
throw new Error(
`Standalone server bundle not found in ${STANDALONE_DIR}. Run \`npm run build\` first.`
);
}
function createPathPattern(filePath) {
return filePath
.replace(/\\/g, "/")
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
.replace(/\//g, "[\\\\/]");
}
function sanitizeBuildPaths(bundleDir) {
const buildRoot = ROOT.replace(/\\/g, "/");
const bundleRoot = bundleDir.replace(/\\/g, "/");
const replacements = [buildRoot, bundleRoot];
const targets = [
join(ELECTRON_STANDALONE_DIR, "server.js"),
join(ELECTRON_STANDALONE_DIR, ".next", "required-server-files.json"),
];
for (const filePath of targets) {
if (!existsSync(filePath)) continue;
let content = readFileSync(filePath, "utf8");
let updated = content;
for (const original of replacements) {
updated = updated.replace(new RegExp(createPathPattern(original), "g"), ".");
}
if (updated !== content) {
writeFileSync(filePath, updated, "utf8");
}
}
}
function ensurePackage(pkgPath, sourcePath) {
if (existsSync(pkgPath) || !existsSync(sourcePath)) return;
mkdirSync(dirname(pkgPath), { recursive: true });
cpSync(sourcePath, pkgPath, { recursive: true, dereference: true });
}
function assertBundleIsPackagable(bundleDir) {
const nodeModulesPath = join(bundleDir, "node_modules");
if (!existsSync(nodeModulesPath)) return;
if (lstatSync(nodeModulesPath).isSymbolicLink()) {
throw new Error(
[
"Next standalone emitted app/node_modules as a symlink.",
"electron-builder preserves extraResources symlinks, which would make the packaged app",
"depend on the original build machine path at runtime.",
"",
`Offending path: ${nodeModulesPath}`,
"Use a real node_modules directory in the build worktree before packaging Electron.",
].join("\n")
);
}
}
function logContextualError(error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[electron] failed to prepare standalone bundle: ${message}`);
process.exitCode = 1;
}
process.on("uncaughtException", logContextualError);
const bundleDir = resolveStandaloneBundleDir();
assertBundleIsPackagable(bundleDir);
rmSync(ELECTRON_STANDALONE_DIR, { recursive: true, force: true });
mkdirSync(ELECTRON_STANDALONE_DIR, { recursive: true });
cpSync(bundleDir, ELECTRON_STANDALONE_DIR, {
recursive: true,
dereference: true,
});
sanitizeBuildPaths(bundleDir);
if (existsSync(STATIC_SRC)) {
mkdirSync(dirname(STATIC_DEST), { recursive: true });
cpSync(STATIC_SRC, STATIC_DEST, { recursive: true, dereference: true });
}
if (existsSync(PUBLIC_SRC)) {
cpSync(PUBLIC_SRC, PUBLIC_DEST, { recursive: true, dereference: true });
}
ensurePackage(
join(ELECTRON_STANDALONE_DIR, "node_modules", "@swc", "helpers"),
join(ROOT, "node_modules", "@swc", "helpers")
);
ensurePackage(
join(ELECTRON_STANDALONE_DIR, "node_modules", "better-sqlite3"),
join(ROOT, "node_modules", "better-sqlite3")
);
console.log(
`[electron] prepared standalone bundle: ${relative(ROOT, ELECTRON_STANDALONE_DIR) || "."}`
);
@@ -32,6 +32,17 @@ import {
import { getModelsByProviderId } from "@/shared/constants/models";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
function normalizeCodexLimitPolicy(policy: unknown): { use5h: boolean; useWeekly: boolean } {
const record =
policy && typeof policy === "object" && !Array.isArray(policy)
? (policy as Record<string, unknown>)
: {};
return {
use5h: typeof record.use5h === "boolean" ? record.use5h : true,
useWeekly: typeof record.useWeekly === "boolean" ? record.useWeekly : true,
};
}
export default function ProviderDetailPage() {
const params = useParams();
const router = useRouter();
@@ -49,6 +60,7 @@ export default function ProviderDetailPage() {
const [headerImgError, setHeaderImgError] = useState(false);
const { copied, copy } = useCopyToClipboard();
const t = useTranslations("providers");
const notify = useNotificationStore();
const hasAutoOpened = useRef(false);
const userDismissed = useRef(false);
const [proxyTarget, setProxyTarget] = useState(null);
@@ -311,6 +323,63 @@ export default function ProviderDetailPage() {
}
};
const handleToggleCodexLimit = async (connectionId, field, enabled) => {
try {
const target = connections.find((connection) => connection.id === connectionId);
if (!target) return;
const providerSpecificData =
target.providerSpecificData && typeof target.providerSpecificData === "object"
? target.providerSpecificData
: {};
const existingPolicy =
providerSpecificData.codexLimitPolicy &&
typeof providerSpecificData.codexLimitPolicy === "object"
? providerSpecificData.codexLimitPolicy
: {};
const nextPolicy = {
...normalizeCodexLimitPolicy(existingPolicy),
[field]: enabled,
};
const res = await fetch(`/api/providers/${connectionId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
providerSpecificData: {
...providerSpecificData,
codexLimitPolicy: nextPolicy,
},
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
notify.error(data.error || "Failed to update Codex limit policy");
return;
}
setConnections((prev) =>
prev.map((connection) =>
connection.id === connectionId
? {
...connection,
providerSpecificData: {
...(connection.providerSpecificData || {}),
codexLimitPolicy: nextPolicy,
},
}
: connection
)
);
notify.success("Codex limit policy updated");
} catch (error) {
console.error("Error toggling Codex quota policy:", error);
notify.error("Failed to update Codex limit policy");
}
};
const handleRetestConnection = async (connectionId) => {
if (!connectionId || retestingId) return;
setRetestingId(connectionId);
@@ -331,7 +400,6 @@ export default function ProviderDetailPage() {
// T12: Manual token refresh
const [refreshingId, setRefreshingId] = useState<string | null>(null);
const notify = useNotificationStore();
const handleRefreshToken = async (connectionId: string) => {
if (refreshingId) return;
setRefreshingId(connectionId);
@@ -941,6 +1009,11 @@ export default function ProviderDetailPage() {
onMoveDown={() => handleSwapPriority(conn, connections[index + 1])}
onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)}
isCodex={providerId === "codex"}
onToggleCodex5h={(enabled) => handleToggleCodexLimit(conn.id, "use5h", enabled)}
onToggleCodexWeekly={(enabled) =>
handleToggleCodexLimit(conn.id, "useWeekly", enabled)
}
onRetest={() => handleRetestConnection(conn.id)}
isRetesting={retestingId === conn.id}
onEdit={() => {
@@ -2175,12 +2248,15 @@ function getStatusPresentation(connection, effectiveStatus, isCooldown, t) {
function ConnectionRow({
connection,
isOAuth,
isCodex,
isFirst,
isLast,
onMoveUp,
onMoveDown,
onToggleActive,
onToggleRateLimit,
onToggleCodex5h,
onToggleCodexWeekly,
onRetest,
isRetesting,
onEdit,
@@ -2242,6 +2318,16 @@ function ConnectionRow({
const statusPresentation = getStatusPresentation(connection, effectiveStatus, isCooldown, t);
const rateLimitEnabled = !!connection.rateLimitProtection;
const codexPolicy =
connection.providerSpecificData &&
typeof connection.providerSpecificData === "object" &&
connection.providerSpecificData.codexLimitPolicy &&
typeof connection.providerSpecificData.codexLimitPolicy === "object"
? connection.providerSpecificData.codexLimitPolicy
: {};
const normalizedCodexPolicy = normalizeCodexLimitPolicy(codexPolicy);
const codex5hEnabled = normalizedCodexPolicy.use5h;
const codexWeeklyEnabled = normalizedCodexPolicy.useWeekly;
return (
<div
@@ -2331,6 +2417,35 @@ function ConnectionRow({
<span className="material-symbols-outlined text-[13px]">shield</span>
{rateLimitEnabled ? t("rateLimitProtected") : t("rateLimitUnprotected")}
</button>
{isCodex && (
<>
<span className="text-text-muted/30 select-none">|</span>
<button
onClick={() => onToggleCodex5h?.(!codex5hEnabled)}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
codex5hEnabled
? "bg-blue-500/15 text-blue-500 hover:bg-blue-500/25"
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
}`}
title="Toggle Codex 5h limit policy"
>
<span className="material-symbols-outlined text-[13px]">timer</span>
5h {codex5hEnabled ? "ON" : "OFF"}
</button>
<button
onClick={() => onToggleCodexWeekly?.(!codexWeeklyEnabled)}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
codexWeeklyEnabled
? "bg-violet-500/15 text-violet-500 hover:bg-violet-500/25"
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
}`}
title="Toggle Codex weekly limit policy"
>
<span className="material-symbols-outlined text-[13px]">date_range</span>
Weekly {codexWeeklyEnabled ? "ON" : "OFF"}
</button>
</>
)}
{hasProxy &&
(() => {
const colorClass =
@@ -2425,7 +2540,7 @@ function ConnectionRow({
<button
onClick={onDelete}
className="p-2 hover:bg-red-500/10 rounded text-red-500"
title={t("deleteConnection")}
title={t("delete")}
>
<span className="material-symbols-outlined text-[18px]">delete</span>
</button>
@@ -2451,14 +2566,18 @@ ConnectionRow.propTypes = {
lastErrorSource: PropTypes.string,
errorCode: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
globalPriority: PropTypes.number,
providerSpecificData: PropTypes.object,
}).isRequired,
isOAuth: PropTypes.bool.isRequired,
isCodex: PropTypes.bool,
isFirst: PropTypes.bool.isRequired,
isLast: PropTypes.bool.isRequired,
onMoveUp: PropTypes.func.isRequired,
onMoveDown: PropTypes.func.isRequired,
onToggleActive: PropTypes.func.isRequired,
onToggleRateLimit: PropTypes.func.isRequired,
onToggleCodex5h: PropTypes.func,
onToggleCodexWeekly: PropTypes.func,
onRetest: PropTypes.func.isRequired,
isRetesting: PropTypes.bool,
onEdit: PropTypes.func.isRequired,
@@ -0,0 +1,100 @@
"use client";
import { useEffect, useState } from "react";
import { Card } from "@/shared/components";
export default function CodexServiceTierTab() {
const [enabled, setEnabled] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<"" | "saved" | "error">("");
useEffect(() => {
fetch("/api/settings/codex-service-tier")
.then((res) => res.json())
.then((data) => {
setEnabled(Boolean(data.enabled));
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const save = async (nextEnabled: boolean) => {
setEnabled(nextEnabled);
setSaving(true);
setStatus("");
try {
const res = await fetch("/api/settings/codex-service-tier", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: nextEnabled }),
});
if (res.ok) {
setStatus("saved");
setTimeout(() => setStatus(""), 2000);
} else {
setStatus("error");
setEnabled(!nextEnabled);
}
} catch {
setStatus("error");
setEnabled(!nextEnabled);
} finally {
setSaving(false);
}
};
return (
<Card>
<div className="flex items-center gap-3 mb-5">
<div className="p-2 rounded-lg bg-sky-500/10 text-sky-500">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
bolt
</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">Codex Fast Service Tier</h3>
<p className="text-sm text-text-muted">
Inject `service_tier=fast` into Codex requests when the client leaves it unset.
</p>
</div>
{status === "saved" && (
<span className="text-xs font-medium text-emerald-500 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">check_circle</span>
Saved
</span>
)}
{status === "error" && (
<span className="text-xs font-medium text-rose-500 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">error</span>
Failed to save
</span>
)}
</div>
<div className="flex items-center justify-between p-4 rounded-lg bg-surface/30 border border-border/30">
<div>
<p className="text-sm font-medium">Force fast tier for Codex</p>
<p className="text-xs text-text-muted mt-0.5">
Off by default. Applies only to Codex requests and does not override an explicit tier.
</p>
</div>
<button
onClick={() => save(!enabled)}
disabled={loading || saving}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enabled ? "bg-sky-500" : "bg-white/10"
}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
enabled ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
</Card>
);
}
@@ -12,6 +12,7 @@ import ComboDefaultsTab from "./components/ComboDefaultsTab";
import ProxyTab from "./components/ProxyTab";
import AppearanceTab from "./components/AppearanceTab";
import ThinkingBudgetTab from "./components/ThinkingBudgetTab";
import CodexServiceTierTab from "./components/CodexServiceTierTab";
import SystemPromptTab from "./components/SystemPromptTab";
import ModelAliasesTab from "./components/ModelAliasesTab";
import BackgroundDegradationTab from "./components/BackgroundDegradationTab";
@@ -85,6 +86,7 @@ export default function SettingsPage() {
{activeTab === "ai" && (
<div className="flex flex-col gap-6">
<ThinkingBudgetTab />
<CodexServiceTierTab />
<SystemPromptTab />
<CacheStatsCard />
</div>
+4 -4
View File
@@ -26,10 +26,10 @@ const LEVEL_ORDER: Record<string, number> = {
// Map pino numeric levels to string levels
const NUMERIC_LEVEL_MAP: Record<number, string> = {
10: "trace",
20: "info",
30: "warn",
40: "error",
50: "fatal",
20: "debug",
30: "info",
40: "warn",
50: "error",
60: "fatal",
};
+12 -2
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { getModelAliases, setModelAlias, getProviderConnections } from "@/models";
import { AI_MODELS } from "@/shared/constants/config";
import { AI_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
import { updateModelAliasSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
@@ -18,7 +18,17 @@ export async function GET(request: Request) {
try {
const connections = await getProviderConnections();
const active = connections.filter((c: any) => c.isActive !== false);
activeProviders = new Set(active.map((c: any) => c.provider));
// Include both provider IDs and their aliases in the active set.
// PROVIDER_MODELS keys are aliases (e.g. 'cc' for 'claude', 'gh' for 'github').
// DB connections are stored under provider IDs ('claude', 'github').
// Without this, models for aliased providers always appear unconfigured.
activeProviders = new Set<string>();
for (const c of active) {
const pId = String((c as any).provider);
activeProviders.add(pId);
const alias = PROVIDER_ID_TO_ALIAS[pId];
if (alias) activeProviders.add(alias);
}
} catch {
// If DB unavailable, show all models
}
+38 -1
View File
@@ -10,6 +10,30 @@ import { syncToCloud } from "@/lib/cloudSync";
import { updateProviderConnectionSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
function normalizeCodexLimitPolicy(
incoming: unknown,
existing: unknown
): { use5h: boolean; useWeekly: boolean } {
const incomingRecord =
incoming && typeof incoming === "object" && !Array.isArray(incoming)
? (incoming as Record<string, unknown>)
: {};
const existingRecord =
existing && typeof existing === "object" && !Array.isArray(existing)
? (existing as Record<string, unknown>)
: {};
const existingUse5h = typeof existingRecord.use5h === "boolean" ? existingRecord.use5h : true;
const existingUseWeekly =
typeof existingRecord.useWeekly === "boolean" ? existingRecord.useWeekly : true;
return {
use5h: typeof incomingRecord.use5h === "boolean" ? incomingRecord.use5h : existingUse5h,
useWeekly:
typeof incomingRecord.useWeekly === "boolean" ? incomingRecord.useWeekly : existingUseWeekly,
};
}
// GET /api/providers/[id] - Get single connection
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
@@ -105,7 +129,20 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
existing.providerSpecificData && typeof existing.providerSpecificData === "object"
? existing.providerSpecificData
: {};
updateData.providerSpecificData = { ...existingPsd, ...incomingPsd };
const mergedPsd = { ...existingPsd, ...incomingPsd };
// Deep-merge and normalize Codex limit policy defaults.
if (existing.provider === "codex") {
const incomingRecord = incomingPsd as Record<string, unknown>;
if ("codexLimitPolicy" in incomingRecord || "codexLimitPolicy" in existingPsd) {
mergedPsd.codexLimitPolicy = normalizeCodexLimitPolicy(
incomingRecord.codexLimitPolicy,
(existingPsd as Record<string, unknown>).codexLimitPolicy
);
}
}
updateData.providerSpecificData = mergedPsd;
}
const updated = await updateProviderConnection(id, updateData);
@@ -0,0 +1,55 @@
import { NextResponse, type Request } from "next/server";
import { getSettings, updateSettings } from "@/lib/localDb";
import { setDefaultFastServiceTierEnabled } from "@omniroute/open-sse/executors/codex.ts";
import { updateCodexServiceTierSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
export async function GET() {
try {
const settings = await getSettings();
const persisted =
typeof settings.codexServiceTier === "string"
? JSON.parse(settings.codexServiceTier)
: settings.codexServiceTier;
return NextResponse.json({
enabled: typeof persisted?.enabled === "boolean" ? persisted.enabled : false,
});
} catch (error) {
console.error("[API ERROR] /api/settings/codex-service-tier GET:", error);
return NextResponse.json({ error: "Failed to get config" }, { status: 500 });
}
}
export async function PUT(request: Request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(updateCodexServiceTierSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const config = validation.data;
await updateSettings({ codexServiceTier: config });
setDefaultFastServiceTierEnabled(config.enabled);
return NextResponse.json(config);
} catch (error) {
console.error("[API ERROR] /api/settings/codex-service-tier PUT:", error);
return NextResponse.json({ error: "Failed to update config" }, { status: 500 });
}
}
+8 -2
View File
@@ -8,9 +8,15 @@ export async function GET() {
try {
const settings = await getSettings();
const requireLogin = settings.requireLogin !== false;
return NextResponse.json({ requireLogin });
const hasPassword = !!settings.password || !!process.env.INITIAL_PASSWORD;
const setupComplete = !!settings.setupComplete;
return NextResponse.json({ requireLogin, hasPassword, setupComplete });
} catch (error) {
return NextResponse.json({ requireLogin: true }, { status: 200 });
console.error("[API] Error fetching require-login settings:", error);
return NextResponse.json(
{ requireLogin: true, hasPassword: true, setupComplete: true },
{ status: 200 }
);
}
}
+11 -2
View File
@@ -1,6 +1,11 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleAudioSpeech } from "@omniroute/open-sse/handlers/audioSpeech.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import {
getProviderCredentials,
clearRecoveredProviderState,
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import { parseSpeechModel, getSpeechProvider } from "@omniroute/open-sse/config/audioRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
@@ -70,5 +75,9 @@ export async function POST(request) {
}
}
return handleAudioSpeech({ body, credentials });
const response = await handleAudioSpeech({ body, credentials });
if (response?.ok) {
await clearRecoveredProviderState(credentials);
}
return response;
}
+11 -2
View File
@@ -1,6 +1,11 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleAudioTranscription } from "@omniroute/open-sse/handlers/audioTranscription.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import {
getProviderCredentials,
clearRecoveredProviderState,
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import { parseTranscriptionModel, getTranscriptionProvider } from "@omniroute/open-sse/config/audioRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
@@ -68,5 +73,9 @@ export async function POST(request) {
}
}
return handleAudioTranscription({ formData, credentials });
const response = await handleAudioTranscription({ formData, credentials });
if (response?.ok) {
await clearRecoveredProviderState(credentials);
}
return response;
}
+7 -1
View File
@@ -1,6 +1,11 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleEmbedding } from "@omniroute/open-sse/handlers/embeddings.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import {
getProviderCredentials,
clearRecoveredProviderState,
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import {
parseEmbeddingModel,
getAllEmbeddingModels,
@@ -126,6 +131,7 @@ export async function POST(request) {
const result = await handleEmbedding({ body, credentials, log });
if (result.success) {
await clearRecoveredProviderState(credentials);
return new Response(JSON.stringify(result.data), {
status: 200,
headers: { "Content-Type": "application/json" },
+7 -1
View File
@@ -1,6 +1,11 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleImageGeneration } from "@omniroute/open-sse/handlers/imageGeneration.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import {
getProviderCredentials,
clearRecoveredProviderState,
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import {
parseImageModel,
getAllImageModels,
@@ -170,6 +175,7 @@ export async function POST(request) {
});
if (result.success) {
await clearRecoveredProviderState(credentials);
return new Response(JSON.stringify((result as any).data), {
status: 200,
headers: { "Content-Type": "application/json" },
+11 -2
View File
@@ -1,6 +1,11 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleModeration } from "@omniroute/open-sse/handlers/moderations.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import {
getProviderCredentials,
clearRecoveredProviderState,
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import { parseModerationModel } from "@omniroute/open-sse/config/moderationRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
@@ -64,5 +69,9 @@ export async function POST(request) {
);
}
return handleModeration({ body: { ...body, model }, credentials });
const response = await handleModeration({ body: { ...body, model }, credentials });
if (response?.ok) {
await clearRecoveredProviderState(credentials);
}
return response;
}
+7 -1
View File
@@ -1,6 +1,11 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleMusicGeneration } from "@omniroute/open-sse/handlers/musicGeneration.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import {
getProviderCredentials,
clearRecoveredProviderState,
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import {
parseMusicModel,
getAllMusicModels,
@@ -110,6 +115,7 @@ export async function POST(request) {
const result = await handleMusicGeneration({ body, credentials, log });
if (result.success) {
await clearRecoveredProviderState(credentials);
return new Response(JSON.stringify((result as any).data), {
status: 200,
headers: { "Content-Type": "application/json" },
@@ -2,7 +2,12 @@ import { CORS_ORIGIN } from "@/shared/utils/cors";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import {
getProviderCredentials,
clearRecoveredProviderState,
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import { handleEmbedding } from "@omniroute/open-sse/handlers/embeddings.ts";
import * as log from "@/sse/utils/logger";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
@@ -84,6 +89,7 @@ export async function POST(request, { params }) {
const result = await handleEmbedding({ body, credentials, log });
if (result.success) {
await clearRecoveredProviderState(credentials);
return new Response(JSON.stringify(result.data), {
status: 200,
headers: { "Content-Type": "application/json" },
@@ -2,7 +2,12 @@ import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleImageGeneration } from "@omniroute/open-sse/handlers/imageGeneration.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import {
getProviderCredentials,
clearRecoveredProviderState,
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import { getImageProvider } from "@omniroute/open-sse/config/imageRegistry.ts";
import * as log from "@/sse/utils/logger";
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
@@ -84,6 +89,7 @@ export async function POST(request, { params }) {
const result = await handleImageGeneration({ body, credentials, log });
if (result.success) {
await clearRecoveredProviderState(credentials);
return new Response(JSON.stringify((result as any).data), {
status: 200,
headers: { "Content-Type": "application/json" },
+11 -2
View File
@@ -1,6 +1,11 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleRerank } from "@omniroute/open-sse/handlers/rerank.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import {
getProviderCredentials,
clearRecoveredProviderState,
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import { parseRerankModel } from "@omniroute/open-sse/config/rerankRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
@@ -66,7 +71,7 @@ export async function POST(request) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
}
return handleRerank({
const response = await handleRerank({
model: body.model,
query: body.query,
documents: body.documents,
@@ -74,4 +79,8 @@ export async function POST(request) {
return_documents: body.return_documents,
credentials,
});
if (response?.ok) {
await clearRecoveredProviderState(credentials);
}
return response;
}
+7 -1
View File
@@ -1,6 +1,11 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleVideoGeneration } from "@omniroute/open-sse/handlers/videoGeneration.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import {
getProviderCredentials,
clearRecoveredProviderState,
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import {
parseVideoModel,
getAllVideoModels,
@@ -110,6 +115,7 @@ export async function POST(request) {
const result = await handleVideoGeneration({ body, credentials, log });
if (result.success) {
await clearRecoveredProviderState(credentials);
return new Response(JSON.stringify((result as any).data), {
status: 200,
headers: { "Content-Type": "application/json" },
+1 -1
View File
@@ -24,7 +24,7 @@ export default function LoginPage() {
const baseUrl = typeof window !== "undefined" ? window.location.origin : "";
try {
const res = await fetch(`${baseUrl}/api/settings`, {
const res = await fetch(`${baseUrl}/api/settings/require-login`, {
signal: controller.signal,
});
clearTimeout(timeoutId);
+51
View File
@@ -35,6 +35,13 @@ interface QuotaCacheEntry {
windowDurationMs?: number | null; // T08: optional rolling window duration
}
interface QuotaWindowStatus {
remainingPercentage: number;
usedPercentage: number;
resetAt: string | null;
reachedThreshold: boolean;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const ACTIVE_TTL_MS = 5 * 60 * 1000; // 5 minutes for active accounts
@@ -89,6 +96,11 @@ function parseDate(value: string): number | null {
return Number.isNaN(ms) ? null : ms;
}
function clampPercent(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, value));
}
function earliestResetAt(quotas: Record<string, QuotaInfo>): string | null {
let earliest: string | null = null;
let earliestMs = Infinity;
@@ -175,6 +187,45 @@ export function isAccountQuotaExhausted(connectionId: string): boolean {
return true;
}
/**
* Return quota window status for a connection (e.g., session/weekly).
* Returns null when no cache or no window data is available.
*/
export function getQuotaWindowStatus(
connectionId: string,
windowName: string,
thresholdPercent = 90
): QuotaWindowStatus | null {
const entry = cache.get(connectionId);
if (!entry) return null;
const now = Date.now();
const window = entry.quotas[windowName];
if (!window) return null;
const remainingPercentage = clampPercent(window.remainingPercentage);
const usedPercentage = clampPercent(100 - remainingPercentage);
let resetAt = window.resetAt || null;
let windowExpired = false;
if (resetAt) {
const resetMs = parseDate(resetAt);
if (resetMs !== null && resetMs <= now) {
resetAt = null;
windowExpired = true;
}
}
return {
remainingPercentage,
usedPercentage,
resetAt,
// If reset time has already passed, avoid stale cached percentages blocking selection.
reachedThreshold: windowExpired ? false : usedPercentage >= thresholdPercent,
};
}
/**
* Mark an account as quota-exhausted from a 429 response (no quota data available).
* Uses 5-minute fixed TTL since we don't know the actual resetAt.
+13 -1
View File
@@ -52,7 +52,9 @@ export async function register() {
try {
const { getSettings } = await import("@/lib/db/settings");
const { setCustomAliases } = await import("@omniroute/open-sse/services/modelDeprecation.ts");
const { setDefaultFastServiceTierEnabled } = await import("@omniroute/open-sse/executors/codex.ts");
const settings = await getSettings();
if (settings.modelAliases) {
const aliases =
typeof settings.modelAliases === "string"
@@ -65,9 +67,19 @@ export async function register() {
);
}
}
const persisted =
typeof settings.codexServiceTier === "string"
? JSON.parse(settings.codexServiceTier)
: settings.codexServiceTier;
if (typeof persisted?.enabled === "boolean") {
setDefaultFastServiceTierEnabled(persisted.enabled);
console.log(`[STARTUP] Restored Codex fast service tier: ${persisted.enabled ? "on" : "off"}`);
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.warn("[STARTUP] Could not restore model aliases:", msg);
console.warn("[STARTUP] Could not restore runtime settings:", msg);
}
// Compliance: Initialize audit_log table + cleanup expired logs
+5
View File
@@ -80,6 +80,7 @@ const SCHEMA_SQL = `
consecutive_use_count INTEGER DEFAULT 0,
rate_limit_protection INTEGER DEFAULT 0,
last_used_at TEXT,
"group" TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -316,6 +317,10 @@ function ensureProviderConnectionsColumns(db: SqliteDatabase) {
db.exec("ALTER TABLE provider_connections ADD COLUMN last_used_at TEXT");
console.log("[DB] Added provider_connections.last_used_at column");
}
if (!columnNames.has("group")) {
db.exec('ALTER TABLE provider_connections ADD COLUMN "group" TEXT');
console.log('[DB] Added provider_connections."group" column');
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
console.warn("[DB] Failed to verify provider_connections schema:", message);
+55
View File
@@ -27,6 +27,8 @@ export async function getUsageForProvider(connection) {
return await getQwenUsage(accessToken, providerSpecificData);
case "iflow":
return await getIflowUsage(accessToken);
case "kiro":
return await getKiroUsage(accessToken);
default:
return { message: `Usage API not implemented for ${provider}` };
}
@@ -215,3 +217,56 @@ async function getIflowUsage(accessToken) {
return { message: "Unable to fetch iFlow usage." };
}
}
/**
* Kiro Credits
* Fetches credit balance from Kiro's AWS CodeWhisperer backend.
* The endpoint mirrors what Kiro IDE uses internally for the credit badge.
*/
async function getKiroUsage(accessToken: string) {
try {
const response = await fetch("https://codewhisperer.us-east-1.amazonaws.com/getUserCredits", {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": "AWS-SDK-JS/3.0.0 kiro-ide/1.0.0",
"X-Amz-User-Agent": "aws-sdk-js/3.0.0 kiro-ide/1.0.0",
},
});
if (!response.ok) {
const errText = await response.text();
// 401/403 = expired token, show user-friendly message
if (response.status === 401 || response.status === 403) {
return { message: "Kiro token expired. Please reconnect in Dashboard → Providers → Kiro." };
}
throw new Error(`Kiro credits API error (${response.status}): ${errText}`);
}
const data = await response.json();
// Response shape: { remainingCredits, totalCredits, resetDate, subscriptionType }
const remaining = data.remainingCredits ?? data.remaining_credits ?? null;
const total = data.totalCredits ?? data.total_credits ?? null;
const resetDate = data.resetDate ?? data.reset_date ?? null;
const plan = data.subscriptionType ?? data.subscription_type ?? "unknown";
if (remaining === null) {
return { message: "Kiro connected. Credit data unavailable — check Kiro IDE for balance." };
}
return {
plan,
credits: {
remaining,
total: total ?? remaining,
used: total != null ? total - remaining : 0,
unlimited: total === null || total === 0,
resetDate,
},
};
} catch (error: any) {
return { message: `Unable to fetch Kiro credits: ${error.message}` };
}
}
+11 -2
View File
@@ -30,6 +30,14 @@ const baseConfig: pino.LoggerOptions = {
},
};
function getTransportCompatibleConfig(): pino.LoggerOptions {
const { formatters, ...rest } = baseConfig;
if (!formatters) return rest;
const { level: _levelFormatter, ...safeFormatters } = formatters;
return Object.keys(safeFormatters).length > 0 ? { ...rest, formatters: safeFormatters } : rest;
}
/**
* Build the logger with optional file transport.
* Uses pino transport targets for all destinations.
@@ -37,6 +45,7 @@ const baseConfig: pino.LoggerOptions = {
function buildLogger(): pino.Logger {
const logConfig = getLogConfig();
const logLevel = (baseConfig.level as string) || "info";
const transportConfig = getTransportCompatibleConfig();
// If file logging is enabled, set up dual transport (stdout + file)
if (logConfig.logToFile) {
@@ -50,7 +59,7 @@ function buildLogger(): pino.Logger {
if (isDev) {
// Dev: pino-pretty → stdout, JSON → file
return pino({
...baseConfig,
...transportConfig,
transport: {
targets: [
{
@@ -76,7 +85,7 @@ function buildLogger(): pino.Logger {
// Production: JSON → stdout + JSON → file
return pino({
...baseConfig,
...transportConfig,
transport: {
targets: [
{
+6
View File
@@ -449,6 +449,12 @@ export const updateThinkingBudgetSchema = z
}
});
export const updateCodexServiceTierSchema = z
.object({
enabled: z.boolean(),
})
.strict();
const ipFilterModeSchema = z.enum(["blacklist", "whitelist"]);
const tempBanSchema = z.object({
ip: z.string().trim().min(1),
+156 -9
View File
@@ -4,7 +4,7 @@ import {
updateProviderConnection,
getSettings,
} from "@/lib/localDb";
import { isAccountQuotaExhausted } from "@/domain/quotaCache";
import { getQuotaWindowStatus, isAccountQuotaExhausted } from "@/domain/quotaCache";
import {
isAccountUnavailable,
getUnavailableUntil,
@@ -35,10 +35,24 @@ interface ProviderConnectionView {
consecutiveUseCount: number;
priority: number;
lastError: string | null;
lastErrorType: string | null;
lastErrorSource: string | null;
errorCode: string | number | null;
backoffLevel: number;
}
interface RecoverableConnectionState {
connectionId: string;
testStatus?: string | null;
lastError?: string | null;
rateLimitedUntil?: string | null;
errorCode?: string | number | null;
lastErrorType?: string | null;
lastErrorSource?: string | null;
}
const CODEX_QUOTA_THRESHOLD_PERCENT = 90;
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
@@ -74,12 +88,48 @@ function toProviderConnection(value: unknown): ProviderConnectionView {
consecutiveUseCount: toNumber(row.consecutiveUseCount, 0),
priority: toNumber(row.priority, 999),
lastError: toStringOrNull(row.lastError),
lastErrorType: toStringOrNull(row.lastErrorType),
lastErrorSource: toStringOrNull(row.lastErrorSource),
errorCode:
typeof row.errorCode === "string" || typeof row.errorCode === "number" ? row.errorCode : null,
backoffLevel: toNumber(row.backoffLevel, 0),
};
}
function toBooleanOrDefault(value: unknown, fallback: boolean): boolean {
return typeof value === "boolean" ? value : fallback;
}
function getCodexLimitPolicy(providerSpecificData: JsonRecord): {
use5h: boolean;
useWeekly: boolean;
} {
const policy = asRecord(providerSpecificData.codexLimitPolicy);
return {
use5h: toBooleanOrDefault(policy.use5h, true),
useWeekly: toBooleanOrDefault(policy.useWeekly, true),
};
}
function parseFutureDateMs(value: string | null): number | null {
if (!value) return null;
const ms = new Date(value).getTime();
if (!Number.isFinite(ms) || ms <= Date.now()) return null;
return ms;
}
function getEarliestFutureDate(candidates: Array<string | null>): string | null {
return (
candidates
.map((candidate) => ({
raw: candidate,
ms: parseFutureDateMs(candidate),
}))
.filter((entry) => entry.ms !== null)
.sort((a, b) => (a.ms as number) - (b.ms as number))[0]?.raw || null
);
}
// Mutex to prevent race conditions during account selection
let selectionMutex = Promise.resolve();
@@ -101,7 +151,8 @@ export { fisherYatesShuffle, getNextFromDeckSync as getNextFromDeck };
*/
export async function getProviderCredentials(
provider: string,
excludeConnectionId: string | null = null
excludeConnectionId: string | null = null,
allowedConnections: string[] | null = null
) {
// Acquire mutex to prevent race conditions
const currentMutex = selectionMutex;
@@ -114,9 +165,13 @@ export async function getProviderCredentials(
await currentMutex;
const connectionsRaw = await getProviderConnections({ provider, isActive: true });
const connections = (Array.isArray(connectionsRaw) ? connectionsRaw : [])
let connections = (Array.isArray(connectionsRaw) ? connectionsRaw : [])
.map(toProviderConnection)
.filter((conn) => conn.id.length > 0);
// allowedConnections: restrict to specific connection IDs (from API key policy, #363)
if (allowedConnections && allowedConnections.length > 0) {
connections = connections.filter((conn) => allowedConnections.includes(conn.id));
}
log.debug(
"AUTH",
`${provider} | total connections: ${connections.length}, excludeId: ${excludeConnectionId || "none"}`
@@ -204,11 +259,84 @@ export async function getProviderCredentials(
return null;
}
let policyEligibleConnections = availableConnections;
if (provider === "codex") {
const blockedByPolicy: Array<{
id: string;
reasons: string[];
resetAt: string | null;
}> = [];
policyEligibleConnections = availableConnections.filter((connection) => {
const policy = getCodexLimitPolicy(connection.providerSpecificData);
const sessionStatus = policy.use5h
? getQuotaWindowStatus(connection.id, "session", CODEX_QUOTA_THRESHOLD_PERCENT)
: null;
const weeklyStatus = policy.useWeekly
? getQuotaWindowStatus(connection.id, "weekly", CODEX_QUOTA_THRESHOLD_PERCENT)
: null;
const reasons: string[] = [];
const resetCandidates: Array<string | null> = [];
if (policy.use5h && sessionStatus?.reachedThreshold) {
reasons.push(`5h usage ${Math.round(sessionStatus.usedPercentage)}%`);
resetCandidates.push(sessionStatus.resetAt);
}
if (policy.useWeekly && weeklyStatus?.reachedThreshold) {
reasons.push(`weekly usage ${Math.round(weeklyStatus.usedPercentage)}%`);
resetCandidates.push(weeklyStatus.resetAt);
}
if (reasons.length > 0) {
const nextResetAt = getEarliestFutureDate(resetCandidates);
blockedByPolicy.push({
id: connection.id,
reasons,
resetAt: nextResetAt,
});
return false;
}
return true;
});
if (blockedByPolicy.length > 0) {
log.info(
"AUTH",
`${provider} | quota policy filtered ${blockedByPolicy.length} account(s): ${blockedByPolicy
.map((entry) => `${entry.id.slice(0, 8)}(${entry.reasons.join(", ")})`)
.join("; ")}`
);
}
if (policyEligibleConnections.length === 0 && availableConnections.length > 0) {
const earliestResetAt = getEarliestFutureDate(
blockedByPolicy.map((entry) => entry.resetAt)
);
const earliestResetMs = parseFutureDateMs(earliestResetAt);
const retryAfter = earliestResetMs
? new Date(earliestResetMs).toISOString()
: new Date(Date.now() + 5 * 60 * 1000).toISOString();
return {
allRateLimited: true,
retryAfter,
retryAfterHuman: formatRetryAfter(retryAfter),
lastError: "All Codex accounts reached configured quota threshold",
lastErrorCode: 429,
};
}
}
// Quota-aware: prioritize accounts with available quota
const withQuota = availableConnections.filter((c) => !isAccountQuotaExhausted(c.id));
const exhaustedQuota = availableConnections.filter((c) => isAccountQuotaExhausted(c.id));
const withQuota = policyEligibleConnections.filter((c) => !isAccountQuotaExhausted(c.id));
const exhaustedQuota = policyEligibleConnections.filter((c) => isAccountQuotaExhausted(c.id));
const orderedConnections =
withQuota.length > 0 ? [...withQuota, ...exhaustedQuota] : availableConnections;
withQuota.length > 0 ? [...withQuota, ...exhaustedQuota] : policyEligibleConnections;
if (exhaustedQuota.length > 0) {
log.debug(
@@ -355,6 +483,9 @@ export async function getProviderCredentials(
// Include current status for optimization check
testStatus: connection.testStatus,
lastError: connection.lastError,
lastErrorType: connection.lastErrorType,
lastErrorSource: connection.lastErrorSource,
errorCode: connection.errorCode,
rateLimitedUntil: connection.rateLimitedUntil,
};
} finally {
@@ -455,12 +586,18 @@ export async function markAccountUnavailable(
* Clear account error status (only if currently has error)
* Optimized to avoid unnecessary DB updates
*/
export async function clearAccountError(connectionId: string, currentConnection: any) {
export async function clearAccountError(
connectionId: string,
currentConnection: Partial<RecoverableConnectionState>
) {
// Only update if currently has error status
const hasError =
currentConnection.testStatus === "unavailable" ||
(currentConnection.testStatus && currentConnection.testStatus !== "active") ||
currentConnection.lastError ||
currentConnection.rateLimitedUntil;
currentConnection.rateLimitedUntil ||
currentConnection.errorCode ||
currentConnection.lastErrorType ||
currentConnection.lastErrorSource;
if (!hasError) return; // Skip if already clean
@@ -468,12 +605,22 @@ export async function clearAccountError(connectionId: string, currentConnection:
testStatus: "active",
lastError: null,
lastErrorAt: null,
lastErrorType: null,
lastErrorSource: null,
errorCode: null,
rateLimitedUntil: null,
backoffLevel: 0,
});
log.info("AUTH", `Account ${connectionId.slice(0, 8)} error cleared`);
}
export async function clearRecoveredProviderState(
credentials: Partial<RecoverableConnectionState> | null
) {
if (!credentials?.connectionId) return;
await clearAccountError(credentials.connectionId, credentials);
}
/**
* Extract API key from request headers
*/
@@ -0,0 +1,58 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-auth-clear-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const auth = await import("../../src/sse/services/auth.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("clearAccountError clears stale provider error metadata after recovery", async () => {
await resetStorage();
const created = await providersDb.createProviderConnection({
provider: "codex",
authType: "oauth",
email: "recover@example.com",
accessToken: "access",
refreshToken: "refresh",
testStatus: "active",
lastError: null,
lastErrorType: "token_refresh_failed",
lastErrorSource: "oauth",
errorCode: "refresh_failed",
rateLimitedUntil: null,
backoffLevel: 2,
});
const credentials = await auth.getProviderCredentials("codex");
assert.equal(credentials.connectionId, created.id);
assert.equal(credentials.errorCode, "refresh_failed");
assert.equal(credentials.lastErrorType, "token_refresh_failed");
assert.equal(credentials.lastErrorSource, "oauth");
await auth.clearAccountError(created.id, credentials);
const updated = await providersDb.getProviderConnectionById(created.id);
assert.equal(updated.testStatus, "active");
assert.equal(updated.lastError, undefined);
assert.equal(updated.lastErrorType, undefined);
assert.equal(updated.lastErrorSource, undefined);
assert.equal(updated.errorCode, undefined);
assert.equal(updated.rateLimitedUntil, undefined);
assert.equal(updated.backoffLevel, 0);
});
@@ -0,0 +1,108 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-auth-routes-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const moderationRoute = await import("../../src/app/api/v1/moderations/route.ts");
const embeddingsRoute = await import("../../src/app/api/v1/embeddings/route.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
async function seedOpenAIConnection(email) {
return await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
email,
name: email,
apiKey: "sk-test",
testStatus: "active",
lastError: null,
lastErrorType: "token_refresh_failed",
lastErrorSource: "oauth",
errorCode: "refresh_failed",
rateLimitedUntil: null,
backoffLevel: 2,
});
}
async function readConnection(id) {
return await providersDb.getProviderConnectionById(id);
}
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("moderations route clears stale provider error metadata on success", async () => {
await resetStorage();
const created = await seedOpenAIConnection("moderation@example.com");
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
Response.json({
id: "modr-1",
model: "omni-moderation-latest",
results: [{ flagged: false }],
});
try {
const request = new Request("http://localhost/v1/moderations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: "hello" }),
});
const response = await moderationRoute.POST(request);
assert.equal(response.status, 200);
const updated = await readConnection(created.id);
assert.equal(updated.testStatus, "active");
assert.equal(updated.errorCode, undefined);
assert.equal(updated.lastErrorType, undefined);
assert.equal(updated.lastErrorSource, undefined);
} finally {
globalThis.fetch = originalFetch;
}
});
test("embeddings route clears stale provider error metadata on success", async () => {
await resetStorage();
const created = await seedOpenAIConnection("embeddings@example.com");
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
Response.json({
data: [{ object: "embedding", index: 0, embedding: [0.1, 0.2] }],
usage: { prompt_tokens: 3, total_tokens: 3 },
});
try {
const request = new Request("http://localhost/v1/embeddings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: "openai/text-embedding-3-small", input: "hello" }),
});
const response = await embeddingsRoute.POST(request);
assert.equal(response.status, 200);
const updated = await readConnection(created.id);
assert.equal(updated.testStatus, "active");
assert.equal(updated.errorCode, undefined);
assert.equal(updated.lastErrorType, undefined);
assert.equal(updated.lastErrorSource, undefined);
} finally {
globalThis.fetch = originalFetch;
}
});
+113
View File
@@ -0,0 +1,113 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import Database from "better-sqlite3";
import { bootstrapEnv } from "../../scripts/bootstrap-env.mjs";
function withTempEnv(fn) {
const originalCwd = process.cwd();
const originalEnv = { ...process.env };
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-bootstrap-test-"));
const tempCwd = path.join(tempRoot, "cwd");
const tempHome = path.join(tempRoot, "home");
fs.mkdirSync(tempCwd, { recursive: true });
fs.mkdirSync(tempHome, { recursive: true });
delete process.env.DATA_DIR;
delete process.env.XDG_CONFIG_HOME;
delete process.env.APPDATA;
delete process.env.JWT_SECRET;
delete process.env.STORAGE_ENCRYPTION_KEY;
delete process.env.STORAGE_ENCRYPTION_KEY_VERSION;
delete process.env.API_KEY_SECRET;
delete process.env.INITIAL_PASSWORD;
process.env.HOME = tempHome;
process.chdir(tempCwd);
try {
fn({ tempRoot, tempCwd, tempHome, dataDir: path.join(tempHome, ".omniroute") });
} finally {
process.chdir(originalCwd);
for (const key of Object.keys(process.env)) {
if (!(key in originalEnv)) delete process.env[key];
}
for (const [key, value] of Object.entries(originalEnv)) {
process.env[key] = value;
}
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}
test("bootstrapEnv prefers ~/.omniroute/.env over server.env", () => {
withTempEnv(({ dataDir }) => {
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(
path.join(dataDir, ".env"),
"STORAGE_ENCRYPTION_KEY=from-dot-env\nJWT_SECRET=jwt-from-dot-env\n",
"utf8"
);
fs.writeFileSync(
path.join(dataDir, "server.env"),
"STORAGE_ENCRYPTION_KEY=from-server-env\nJWT_SECRET=jwt-from-server-env\n",
"utf8"
);
const env = bootstrapEnv({ quiet: true });
assert.equal(env.STORAGE_ENCRYPTION_KEY, "from-dot-env");
assert.equal(env.JWT_SECRET, "jwt-from-dot-env");
});
});
test("bootstrapEnv refuses to generate a new key over encrypted data", () => {
withTempEnv(({ dataDir }) => {
fs.mkdirSync(dataDir, { recursive: true });
const db = new Database(path.join(dataDir, "storage.sqlite"));
try {
db.exec(`
CREATE TABLE provider_connections (
id TEXT PRIMARY KEY,
access_token TEXT,
refresh_token TEXT,
api_key TEXT,
id_token TEXT
);
`);
db.prepare("INSERT INTO provider_connections (id, access_token) VALUES (?, ?)")
.run("conn-1", "enc:v1:deadbeef:feedface:cafebabe");
} finally {
db.close();
}
assert.throws(
() => bootstrapEnv({ quiet: true }),
/Refusing to auto-generate STORAGE_ENCRYPTION_KEY/
);
});
});
test("bootstrapEnv fails closed when existing database cannot be inspected", () => {
withTempEnv(({ dataDir }) => {
fs.mkdirSync(path.join(dataDir, "storage.sqlite"), { recursive: true });
assert.throws(
() => bootstrapEnv({ quiet: true }),
/Unable to inspect existing database/
);
});
});
test("bootstrapEnv ignores blank dataDirOverride values", () => {
withTempEnv(({ dataDir }) => {
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(path.join(dataDir, ".env"), "JWT_SECRET=jwt-from-dot-env\n", "utf8");
const env = bootstrapEnv({ dataDirOverride: " ", quiet: true });
assert.equal(env.JWT_SECRET, "jwt-from-dot-env");
});
});
+54
View File
@@ -0,0 +1,54 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_LOG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-console-log-levels-"));
const TEST_LOG_PATH = path.join(TEST_LOG_DIR, "app.log");
const originalLogFilePath = process.env.LOG_FILE_PATH;
process.env.LOG_FILE_PATH = TEST_LOG_PATH;
const route = await import("../../src/app/api/logs/console/route.ts");
test.after(() => {
if (originalLogFilePath === undefined) {
delete process.env.LOG_FILE_PATH;
} else {
process.env.LOG_FILE_PATH = originalLogFilePath;
}
fs.rmSync(TEST_LOG_DIR, { recursive: true, force: true });
});
test("console log API normalizes numeric pino levels correctly", async () => {
fs.writeFileSync(
TEST_LOG_PATH,
[
JSON.stringify({
timestamp: new Date().toISOString(),
level: 30,
module: "probe",
msg: "info entry",
}),
JSON.stringify({
timestamp: new Date().toISOString(),
level: 40,
module: "probe",
msg: "warn entry",
}),
].join("\n") + "\n",
"utf8"
);
const response = await route.GET(
new Request("http://localhost/api/logs/console?level=info&limit=10")
);
const body = await response.json();
assert.equal(response.status, 200);
assert.deepEqual(
body.map((entry) => entry.level),
["info", "warn"]
);
});
+8
View File
@@ -0,0 +1,8 @@
import test from "node:test";
import assert from "node:assert/strict";
test("next config allows loopback dev origins alongside LAN access", async () => {
const { default: nextConfig } = await import("../../next.config.mjs");
assert.deepEqual(nextConfig.allowedDevOrigins, ["localhost", "127.0.0.1", "192.168.*"]);
});
+57
View File
@@ -141,6 +141,63 @@ test("provider connection persists rateLimitProtection across reopen", async ()
assert.equal(secondRead.rateLimitProtection, true);
});
test('provider connection migration adds "group" column for existing databases', async () => {
await resetStorage();
const sqlitePath = core.SQLITE_FILE;
core.resetDbInstance();
const Database = (await import("better-sqlite3")).default;
const db = new Database(sqlitePath);
db.exec(`
CREATE TABLE provider_connections (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
auth_type TEXT,
name TEXT,
email TEXT,
priority INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
access_token TEXT,
refresh_token TEXT,
expires_at TEXT,
token_expires_at TEXT,
scope TEXT,
project_id TEXT,
test_status TEXT,
error_code TEXT,
last_error TEXT,
last_error_at TEXT,
last_error_type TEXT,
last_error_source TEXT,
backoff_level INTEGER DEFAULT 0,
rate_limited_until TEXT,
health_check_interval INTEGER,
last_health_check_at TEXT,
last_tested TEXT,
api_key TEXT,
id_token TEXT,
provider_specific_data TEXT,
expires_in INTEGER,
display_name TEXT,
global_priority INTEGER,
default_model TEXT,
token_type TEXT,
consecutive_use_count INTEGER DEFAULT 0,
rate_limit_protection INTEGER DEFAULT 0,
last_used_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
`);
db.close();
const reopened = core.getDbInstance();
const columns = reopened.prepare("PRAGMA table_info(provider_connections)").all();
const names = new Set(columns.map((column) => column.name));
assert.equal(names.has("group"), true);
});
test("resolveProxyForConnection applies combo proxy for object/string model entries", async () => {
await resetStorage();
+83
View File
@@ -0,0 +1,83 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-login-bootstrap-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
const route = await import("../../src/app/api/settings/require-login/route.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(async () => {
delete process.env.INITIAL_PASSWORD;
await resetStorage();
});
test.after(() => {
delete process.env.INITIAL_PASSWORD;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("public login bootstrap route exposes the metadata the login page consumes", async () => {
await settingsDb.updateSettings({
requireLogin: true,
setupComplete: true,
});
const response = await route.GET();
const body = await response.json();
assert.equal(response.status, 200);
assert.deepEqual(body, {
requireLogin: true,
hasPassword: false,
setupComplete: true,
});
});
test("public login bootstrap route reports env-provided bootstrap password metadata", async () => {
process.env.INITIAL_PASSWORD = "bootstrap-secret";
await settingsDb.updateSettings({
requireLogin: true,
setupComplete: true,
});
const response = await route.GET();
const body = await response.json();
assert.equal(response.status, 200);
assert.deepEqual(body, {
requireLogin: true,
hasPassword: true,
setupComplete: true,
});
});
test("public login bootstrap route reports stored password metadata and disabled auth state", async () => {
await settingsDb.updateSettings({
requireLogin: false,
password: "hashed-password",
setupComplete: true,
});
const response = await route.GET();
const body = await response.json();
assert.equal(response.status, 200);
assert.deepEqual(body, {
requireLogin: false,
hasPassword: true,
setupComplete: true,
});
});
+54 -1
View File
@@ -6,7 +6,10 @@ import { getModelInfoCore } from "../../open-sse/services/model.ts";
import { detectFormat } from "../../open-sse/services/provider.ts";
import { translateRequest } from "../../open-sse/translator/index.ts";
import { GithubExecutor } from "../../open-sse/executors/github.ts";
import { CodexExecutor } from "../../open-sse/executors/codex.ts";
import {
CodexExecutor,
setDefaultFastServiceTierEnabled,
} from "../../open-sse/executors/codex.ts";
import { translateNonStreamingResponse } from "../../open-sse/handlers/responseTranslator.ts";
import { extractUsageFromResponse } from "../../open-sse/handlers/usageExtractor.ts";
import { parseSSEToResponsesOutput } from "../../open-sse/handlers/sseParser.ts";
@@ -23,6 +26,12 @@ test("getModelInfoCore keeps openai fallback for gpt-4o", async () => {
assert.equal(info.model, "gpt-4o");
});
test("getModelInfoCore resolves gpt-5.4 to codex", async () => {
const info = await getModelInfoCore("gpt-5.4", {});
assert.equal(info.provider, "codex");
assert.equal(info.model, "gpt-5.4");
});
test("getModelInfoCore returns explicit ambiguity metadata for ambiguous unprefixed model", async () => {
const info = await getModelInfoCore("claude-haiku-4.5", {});
assert.equal(info.provider, null);
@@ -60,6 +69,32 @@ test("CodexExecutor forces stream=true for upstream compatibility", () => {
assert.equal(transformed.stream, true);
});
test("CodexExecutor maps fast service tier to priority", () => {
const executor = new CodexExecutor();
const transformed = executor.transformRequest(
"gpt-5.1-codex",
{ model: "gpt-5.1-codex", input: [], service_tier: "fast" },
true
);
assert.equal(transformed.service_tier, "priority");
});
test("CodexExecutor can force fast service tier from settings", () => {
setDefaultFastServiceTierEnabled(true);
try {
const executor = new CodexExecutor();
const transformed = executor.transformRequest(
"gpt-5.1-codex",
{ model: "gpt-5.1-codex", input: [] },
true
);
assert.equal(transformed.service_tier, "priority");
} finally {
setDefaultFastServiceTierEnabled(false);
}
});
test("CodexExecutor always requests SSE accept header", () => {
const executor = new CodexExecutor();
const headers = executor.buildHeaders({ accessToken: "test-token" }, false);
@@ -166,6 +201,24 @@ test("translateRequest normalizes openai-responses input string into list payloa
assert.equal(translated.input[0].content[0].text, "hello from responses");
});
test("translateRequest preserves service_tier when converting openai to openai-responses", () => {
const translated = translateRequest(
FORMATS.OPENAI,
FORMATS.OPENAI_RESPONSES,
"gpt-5.1-codex",
{
model: "gpt-5.1-codex",
messages: [{ role: "user", content: "hello from chat completions" }],
service_tier: "fast",
stream: false,
},
false
);
assert.equal(translated.service_tier, "fast");
assert.ok(Array.isArray(translated.input));
});
test("parseSSEToResponsesOutput parses completed response from SSE payload", () => {
const rawSSE = [
"event: response.created",