Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e9a9adbad | |||
| d87c7c3b8c | |||
| eb3c834609 | |||
| e53c76081f | |||
| 134316328c | |||
| 4767561f02 | |||
| 2d6b31b606 | |||
| a22f0a4e7b | |||
| 5a244aa12a | |||
| 69d28bec4d | |||
| c859665c6b | |||
| e7b19758f3 | |||
| 623c63baf6 | |||
| a3ad7c6c2e | |||
| afc9362ca5 | |||
| f6b125e8c2 | |||
| 5df3c22be8 | |||
| 11a0df5443 | |||
| e27a2a0d55 | |||
| dc8abe60ee | |||
| afe2ab37e4 | |||
| f7bd99f965 | |||
| f5238944b4 | |||
| c7ae9c30c2 | |||
| 82f7a12a46 | |||
| f494a8531b | |||
| 36ed0499db | |||
| 46cff2200d | |||
| 5ea6ad4a9e | |||
| 6cad4fae8e | |||
| 8df24c855b | |||
| f25882c0e9 | |||
| be6c769192 | |||
| a4276444b5 | |||
| 0af27b8d8a | |||
| 542eb0e719 | |||
| c658b39270 | |||
| 52ef3dfc7e | |||
| 57da407693 | |||
| d2d6fc5883 | |||
| 6a7a6022d4 | |||
| b53eafa615 | |||
| c949214e99 | |||
| 887cf25b65 | |||
| dd6142196f | |||
| 902c7244d1 | |||
| 4f11762c68 | |||
| 8a7f7c1ba0 | |||
| af46f87eed | |||
| fd749d1e0b | |||
| 5046f90dfa | |||
| cf13e95610 | |||
| 5763609008 | |||
| 6d672ab09a |
@@ -53,10 +53,14 @@ Keep an empty `## [Unreleased]` section above it.
|
||||
## [2.x.y] — YYYY-MM-DD
|
||||
```
|
||||
|
||||
### 4. Update openapi.yaml version
|
||||
### 4. Update openapi.yaml version ⚠️ MANDATORY
|
||||
|
||||
> **CI will fail** if `docs/openapi.yaml` version ≠ `package.json` version (`check:docs-sync` enforces this).
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
sed -i 's/version: OLD/version: NEW/' docs/openapi.yaml
|
||||
VERSION=$(node -p "require('./package.json').version") && sed -i "s/ version: .*/ version: $VERSION/" docs/openapi.yaml && echo "✓ openapi.yaml → $VERSION"
|
||||
```
|
||||
|
||||
### 5. Stage, commit, and tag
|
||||
@@ -95,3 +99,12 @@ ssh root@<VPS_IP> "npm install -g omniroute@2.x.y && pm2 restart omniroute"
|
||||
- The `prepublishOnly` script runs `npm run build:cli` automatically during `npm publish`
|
||||
- After npm publish, verify with `npm info omniroute version`
|
||||
- Lock file sync errors are caused by skipping `npm install` after version bump
|
||||
|
||||
## Known CI Pitfalls
|
||||
|
||||
| CI failure | Cause | Fix |
|
||||
| ------------------------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `[docs-sync] FAIL - OpenAPI version differs from package.json` | Skipped step 4 — `docs/openapi.yaml` version not updated | Run step 4 (`sed -i ...`) and commit |
|
||||
| `[docs-sync] FAIL - CHANGELOG.md first section must be "## [Unreleased]"` | `## [Unreleased]` missing or not at top of CHANGELOG | Add `## [Unreleased]\n\n---\n` before the first versioned `## [x.y.z]` |
|
||||
| Electron Linux `.deb` build fails (`FpmTarget` error) | `fpm` Ruby gem not installed on `ubuntu-latest` runner | Already fixed in `electron-release.yml` (`gem install fpm` step) |
|
||||
| Docker Hub `502 error writing layer blob` | Transient Docker Hub network error during ARM64 push | Re-run the Docker publish workflow; no code change needed |
|
||||
|
||||
@@ -49,6 +49,9 @@ jobs:
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
no-cache: false
|
||||
env:
|
||||
DOCKER_BUILDKIT_INLINE_CACHE: 1
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
|
||||
@@ -107,6 +107,10 @@ jobs:
|
||||
"
|
||||
echo "✓ electron/package.json version set to $VERSION_NO_V"
|
||||
|
||||
- name: Install fpm (Linux .deb packaging tool)
|
||||
if: matrix.platform == 'linux'
|
||||
run: sudo gem install fpm --no-document
|
||||
|
||||
- name: Install Electron dependencies
|
||||
working-directory: electron
|
||||
run: npm install --no-audit --no-fund
|
||||
|
||||
+138
@@ -7,6 +7,144 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [2.3.2] — 2026-03-12
|
||||
|
||||
> ### Claude 1M Context, Postinstall Fix, New Models & OAuth Remote Docs
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Claude 1M extended context window support** — Use `[1m]` suffix on Claude model names (e.g. `claude-sonnet-4-6[1m]`) to activate Anthropic's 1M token context via the `Anthropic-Beta: context-1m-2025-08-07` header. Supported: `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-sonnet-4-5`, `claude-sonnet-4`. (PR #311 — @DavyMassoneto)
|
||||
- **New provider models** — Added `coder-model` (Qwen3.5) to Qwen and `iflow-rome-30ba3b`, `qwen3-max`, `qwen3-vl-plus`, `kimi-k2-0905`, `deepseek-v3.2`, `qwen3-235b` variants to iFlow; `kimi-for-coding` to Kimi. (PR #326 — @nyatoru)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Postinstall native binary regression fix** — PR #313's `process.exit(1)` caused npm to rollback the full package on rebuild failure. New approach copies the already-compiled binary from root `node_modules/` instead of rebuilding inside `app/` (which is a no-op). New `native-binary-compat.mjs` reads ELF/Mach-O/PE headers for reliable platform detection. (PR #327 — @ardaaltinors, fixes #321)
|
||||
- **README: English Remote OAuth guide added** — The OAuth Remote Server guide existed only in Portuguese. English version now appears first; PT moved to a collapsible section. Fixes the 🔗 anchor `#oauth-on-a-remote-server` referenced from `OAuthModal.tsx` since v2.3.1. (PR #329, fixes #318)
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- Added 3 unit tests for `parseModel([1m])` suffix parsing (`model-parse.test.mjs`)
|
||||
|
||||
---
|
||||
|
||||
## [2.3.1] — 2026-03-11
|
||||
|
||||
> ### TypeScript Fixes & UI Polish
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **OAuth Modal displayed Portuguese text regardless of language setting (#314)** — Two hardcoded PT-BR strings in `OAuthModal.tsx` (remote-access info banner and `redirect_uri_mismatch` error message) are now in English for all users (PR #325).
|
||||
- **TypeScript errors in Kimi usage parser (`usage.ts`)** — `dataObj.five_hour`, `dataObj.seven_day`, and `dataObj.user` were typed as `unknown`. Wrapped with `toRecord()` before passing to typed functions — fixes 6 compiler errors on lines 921–948.
|
||||
- **`await` missing on `getSettings()` in `instrumentation.ts` (#316 follow-up)** — `getSettings()` is declared `async`; calling it without `await` made `settings` a `Promise` causing 4 TS errors when accessing `settings.modelAliases`.
|
||||
|
||||
---
|
||||
|
||||
## [2.3.0] — 2026-03-11
|
||||
|
||||
> ### Bug Fixes
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Custom Model Alias (Pattern→Target) ignored during routing (#315)** — `chatCore.ts` now calls `resolveModelAlias()` before the routing format lookup so aliases configured in Settings → Model Aliases → Pattern→Target are applied correctly (PR #317).
|
||||
- **Custom Model Aliases lost after server restart (#316)** — Next.js startup hook (`src/instrumentation.ts`) now restores custom aliases from `settings.modelAliases` in the DB at boot, preventing the in-memory state from resetting to empty on restart (PR #317).
|
||||
- **`better-sqlite3` postinstall rebuild fails silently on macOS ARM (#312)** — Replace unreliable `process.dlopen()` detection with explicit `process.platform`/`process.arch` comparison. Rebuild now fail-fasts with a clear error on non-linux-x64 platforms (PR #313 by @ardaaltinors).
|
||||
|
||||
---
|
||||
|
||||
## [2.2.9] — 2026-03-11
|
||||
|
||||
> ### Features, Bug Fixes & Dependency Updates
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Edit custom model endpoints (#307)** — Provider detail page now shows per-row **Edit / Save / Cancel** controls for custom models. Changes to `apiFormat` and `supportedEndpoints` are now persisted via the new `PUT /api/provider-models` endpoint instead of resetting on navigation (PR #307 by @hijak).
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **`@swc/helpers` MODULE_NOT_FOUND on startup (#306)** — Added `@swc/helpers@0.5.19` as an explicit `dependency` and `override` in `package.json`. Global npm install (`npm install -g omniroute`) now reliably includes this transitive dependency on all platforms including Windows (PR #308).
|
||||
- **Claude quota display inverted (#299)** — Claude Code's OAuth API returns `utilization` as _percent used_, not percent remaining. The quota bar was backwards: 87% used on Claude.ai = 87% "remaining" (green) in OmniRoute. Fixed `open-sse/services/usage.ts`: `remaining = 100 - utilization` (PR #309).
|
||||
|
||||
---
|
||||
|
||||
## [2.2.8] — 2026-03-11
|
||||
|
||||
> ### Bug Fixes
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Docker healthcheck wrong endpoint (#296)** — `scripts/healthcheck.mjs` now queries `/api/monitoring/health` instead of `/api/settings`. Aligns the healthcheck with all other health monitoring components (PR #301).
|
||||
- **429 causes endless queue / requests hang forever (#297)** — Added `maxWait=120000` (2 min) to all Bottleneck instances. When all provider quotas are exhausted, requests now fail-fast with a clean error instead of queueing indefinitely. Configurable via `RATE_LIMIT_MAX_WAIT_MS` env var (PR #302).
|
||||
|
||||
---
|
||||
|
||||
## [2.2.7] — 2026-03-10
|
||||
|
||||
> ### Bug Fixes & Dependency Updates
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Docker startup crash (#292)** — Fixed missing `bootstrap-env.mjs` in the runtime image. The Dockerfile runner stage now copies the file from the builder stage (PR #293).
|
||||
- **Google CLI stale projectId (#394)** — Antigravity and Gemini CLI executors now prefer the OAuth-stored `projectId` over `body.project` to prevent 403/404 errors from stale cached values. Includes type-safe body assignment (PR #294).
|
||||
- **Tool-calling 400 errors (#291)** — Empty `name: ""` fields in `messages[]` and `input[]` are now stripped before forwarding to upstream providers (OpenAI, Codex) that reject them (PR #300).
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Bump `hono` from 4.12.4 to 4.12.7 (security patch) (PR #298)
|
||||
|
||||
---
|
||||
|
||||
## [2.2.6] — 2026-03-10
|
||||
|
||||
> ### 🐛 Fix Claude Thinking Tokens Invisible in Passthrough Mode
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Claude thinking tokens not visible (#289)** — When routing through Antigravity OAuth or any Claude provider, thinking blocks were being emitted as regular `delta.content` with `<think>/<\/think>` XML wrappers. Fixed: now correctly maps `thinking_delta` events to `delta.reasoning_content` so clients like Claude Code, Cursor, and Windsurf display the thinking panel properly.
|
||||
|
||||
---
|
||||
|
||||
## [2.2.5] — 2026-03-10
|
||||
|
||||
> ### 🔧 Zero-Config Bootstrap · 🐛 Electron Black Screen Fix
|
||||
|
||||
### Features
|
||||
|
||||
- **Zero-config bootstrap (#252, #249)** — OmniRoute now auto-generates required secrets on first run across all deployment modes (npm, Docker, Electron Desktop App):
|
||||
- `JWT_SECRET` (64-byte hex) — required for auth/sessions
|
||||
- `STORAGE_ENCRYPTION_KEY` (32-byte hex) — required for SQLite encryption
|
||||
- `API_KEY_SECRET` (32-byte hex) — required for API key signing
|
||||
- Secrets are persisted to `{DATA_DIR}/server.env` and survive restarts, Docker volume remounts, and upgrades
|
||||
- Friendly startup warnings if OAuth secrets (Antigravity, iFlow, Gemini) are not configured
|
||||
- New **`scripts/bootstrap-env.mjs`** module — single source of truth for zero-config initialization
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Electron black screen on macOS/Windows/Linux** — The Next.js server was crashing silently because `JWT_SECRET` and `STORAGE_ENCRYPTION_KEY` are never present in desktop OS environments. Fixed by calling `bootstrapEnv()` before spawning `server.js`, with secrets persisted to Electron's `userData` directory.
|
||||
- **Dashboard bootstrap banner** — Added dismissable amber warning banner on the dashboard home when running in zero-config mode, showing where `server.env` is stored and how to customize secrets.
|
||||
|
||||
### Note for Docker users
|
||||
|
||||
Previously, `--env-file .env` was required to pass secrets to the container. Now OmniRoute will generate and persist them automatically in the mounted volume. Existing `DATA_DIR` secrets are always respected.
|
||||
|
||||
---
|
||||
|
||||
## [2.2.4] — 2026-03-10
|
||||
|
||||
> ### 🔧 CI Fixes
|
||||
|
||||
### CI
|
||||
|
||||
- **docs-sync fix** — Updated `docs/openapi.yaml` version from `2.2.0` to `2.2.3` (was out of sync with `package.json`, causing CI lint failure)
|
||||
- **CHANGELOG format** — Added required `## [Unreleased]` section at top of `CHANGELOG.md` (required by `check:docs-sync` script)
|
||||
- **Electron Linux** — Added `gem install fpm` step to `electron-release.yml` Linux build job; `fpm` is required by `electron-builder` to package `.deb` installers but was not pre-installed on `ubuntu-latest` runners
|
||||
- **Docker publish** — Added `DOCKER_BUILDKIT_INLINE_CACHE` env; previous `502 error writing layer blob` was a transient Docker Hub network error
|
||||
|
||||
---
|
||||
|
||||
## [2.2.3] — 2026-03-10
|
||||
|
||||
> ### 🐛 Bug Fixes · 🔧 Reliability
|
||||
|
||||
@@ -33,6 +33,7 @@ COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/node_modules/@swc/helpers ./node_modules/@swc/helpers
|
||||
COPY --from=builder /app/scripts/run-standalone.mjs ./run-standalone.mjs
|
||||
COPY --from=builder /app/scripts/runtime-env.mjs ./runtime-env.mjs
|
||||
COPY --from=builder /app/scripts/bootstrap-env.mjs ./bootstrap-env.mjs
|
||||
COPY --from=builder /app/scripts/healthcheck.mjs ./healthcheck.mjs
|
||||
|
||||
EXPOSE 20128
|
||||
|
||||
@@ -1508,11 +1508,102 @@ opencode
|
||||
- OmniRoute v1.0.6+ includes fallback validation via chat completions
|
||||
- Ensure base URL includes `/v1` suffix
|
||||
|
||||
### 🔐 OAuth em Servidor Remoto (Remote OAuth Setup)
|
||||
### 🔐 OAuth on a Remote Server
|
||||
|
||||
<a name="oauth-on-a-remote-server"></a>
|
||||
<a name="oauth-em-servidor-remoto"></a>
|
||||
|
||||
> **⚠️ IMPORTANTE para usuários com OmniRoute em VPS/Docker/servidor remoto**
|
||||
> **⚠️ Important for users running OmniRoute on a VPS, Docker, or any remote server**
|
||||
|
||||
#### Why does Antigravity / Gemini CLI OAuth fail on remote servers?
|
||||
|
||||
The **Antigravity** and **Gemini CLI** providers use **Google OAuth 2.0**. Google requires the `redirect_uri` in the OAuth flow to exactly match one of the pre-registered URIs in the app's Google Cloud Console.
|
||||
|
||||
The OAuth credentials bundled in OmniRoute are registered **for `localhost` only**. When you access OmniRoute on a remote server (e.g. `https://omniroute.myserver.com`), Google rejects the authentication with:
|
||||
|
||||
```
|
||||
Error 400: redirect_uri_mismatch
|
||||
```
|
||||
|
||||
#### Solution: Configure your own OAuth credentials
|
||||
|
||||
You need to create an **OAuth 2.0 Client ID** in Google Cloud Console with your server's URI.
|
||||
|
||||
#### Step-by-step
|
||||
|
||||
**1. Open Google Cloud Console**
|
||||
|
||||
Go to: [https://console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials)
|
||||
|
||||
**2. Create a new OAuth 2.0 Client ID**
|
||||
|
||||
- Click **"+ Create Credentials"** → **"OAuth client ID"**
|
||||
- Application type: **"Web application"**
|
||||
- Name: anything you like (e.g. `OmniRoute Remote`)
|
||||
|
||||
**3. Add Authorized Redirect URIs**
|
||||
|
||||
In the **"Authorized redirect URIs"** field, add:
|
||||
|
||||
```
|
||||
https://your-server.com/callback
|
||||
```
|
||||
|
||||
> Replace `your-server.com` with your server's domain or IP (include the port if needed, e.g. `http://45.33.32.156:20128/callback`).
|
||||
|
||||
**4. Save and copy the credentials**
|
||||
|
||||
After creating, Google will show the **Client ID** and **Client Secret**.
|
||||
|
||||
**5. Set environment variables**
|
||||
|
||||
In your `.env` (or Docker environment variables):
|
||||
|
||||
```bash
|
||||
# For Antigravity:
|
||||
ANTIGRAVITY_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
||||
ANTIGRAVITY_OAUTH_CLIENT_SECRET=GOCSPX-your-secret
|
||||
|
||||
# For Gemini CLI:
|
||||
GEMINI_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
||||
GEMINI_OAUTH_CLIENT_SECRET=GOCSPX-your-secret
|
||||
GEMINI_CLI_OAUTH_CLIENT_SECRET=GOCSPX-your-secret
|
||||
```
|
||||
|
||||
**6. Restart OmniRoute**
|
||||
|
||||
```bash
|
||||
# npm:
|
||||
npm run dev
|
||||
|
||||
# Docker:
|
||||
docker restart omniroute
|
||||
```
|
||||
|
||||
**7. Try connecting again**
|
||||
|
||||
Dashboard → Providers → Antigravity (or Gemini CLI) → OAuth
|
||||
|
||||
Google will now redirect correctly to `https://your-server.com/callback`.
|
||||
|
||||
---
|
||||
|
||||
#### Temporary workaround (without custom credentials)
|
||||
|
||||
If you don't want to set up your own credentials right now, you can still use the **manual URL flow**:
|
||||
|
||||
1. OmniRoute opens the Google authorization URL
|
||||
2. After authorizing, Google tries to redirect to `localhost` (which fails on the remote server)
|
||||
3. **Copy the full URL** from your browser's address bar (even if the page doesn't load)
|
||||
4. Paste that URL into the field shown in the OmniRoute connection modal
|
||||
5. Click **"Connect"**
|
||||
|
||||
> This works because the authorization code in the URL is valid regardless of whether the redirect page loaded.
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><b>🇧🇷 Versão em Português</b></summary>
|
||||
|
||||
#### Por que o OAuth do Antigravity / Gemini CLI falha em servidores remotos?
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { existsSync, readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { homedir, platform } from "node:os";
|
||||
import { isNativeBinaryCompatible } from "../scripts/native-binary-compat.mjs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -193,6 +194,29 @@ if (!existsSync(serverJs)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Pre-flight: verify better-sqlite3 native binary ───────
|
||||
// Verify the binary's actual target platform/arch before trusting dlopen.
|
||||
// This avoids the macOS false positive where a bundled linux-x64 addon can
|
||||
// appear to load even though the runtime will fail when better-sqlite3 starts.
|
||||
const sqliteBinary = join(
|
||||
APP_DIR,
|
||||
"node_modules",
|
||||
"better-sqlite3",
|
||||
"build",
|
||||
"Release",
|
||||
"better_sqlite3.node"
|
||||
);
|
||||
if (existsSync(sqliteBinary) && !isNativeBinaryCompatible(sqliteBinary)) {
|
||||
console.error(
|
||||
"\x1b[31m✖ better-sqlite3 native module is incompatible with this platform.\x1b[0m"
|
||||
);
|
||||
console.error(` Run: cd ${APP_DIR} && npm rebuild better-sqlite3`);
|
||||
if (platform() === "darwin") {
|
||||
console.error(" If build tools are missing: xcode-select --install");
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Start server ───────────────────────────────────────────
|
||||
console.log(` \x1b[2m⏳ Starting server...\x1b[0m\n`);
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 2.2.0
|
||||
version: 2.3.1
|
||||
description: |
|
||||
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
|
||||
endpoint that routes requests to multiple AI providers with load balancing,
|
||||
|
||||
+64
-1
@@ -383,6 +383,69 @@ function startNextServer() {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Zero-config bootstrap: auto-generate required secrets ─────────────────
|
||||
// Electron uses CJS — cannot dynamically import ESM bootstrap-env.mjs.
|
||||
// This mirrors bootstrap-env.mjs logic synchronously:
|
||||
// 1. Read persisted secrets from userData/server.env
|
||||
// 2. Generate missing secrets with crypto.randomBytes()
|
||||
// 3. Persist back to userData/server.env for future restarts
|
||||
const crypto = require("crypto");
|
||||
const userDataDir = app.getPath("userData");
|
||||
const serverEnvPath = path.join(userDataDir, "server.env");
|
||||
|
||||
// Parse a simple KEY=VALUE file
|
||||
function parseEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return {};
|
||||
const env = {};
|
||||
for (const line of fs.readFileSync(filePath, "utf8").split(/\r?\n/)) {
|
||||
const t = line.trim();
|
||||
if (!t || t.startsWith("#")) continue;
|
||||
const eq = t.indexOf("=");
|
||||
if (eq < 1) continue;
|
||||
env[t.slice(0, eq).trim()] = t.slice(eq + 1).trim();
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
const persisted = parseEnvFile(serverEnvPath);
|
||||
const serverEnv = { ...process.env, ...persisted };
|
||||
let changed = false;
|
||||
|
||||
if (!serverEnv.JWT_SECRET) {
|
||||
serverEnv.JWT_SECRET = persisted.JWT_SECRET = crypto.randomBytes(64).toString("hex");
|
||||
changed = true;
|
||||
console.log("[Electron] ✨ JWT_SECRET auto-generated");
|
||||
}
|
||||
if (!serverEnv.STORAGE_ENCRYPTION_KEY) {
|
||||
serverEnv.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY = crypto
|
||||
.randomBytes(32)
|
||||
.toString("hex");
|
||||
serverEnv.STORAGE_ENCRYPTION_KEY_VERSION = persisted.STORAGE_ENCRYPTION_KEY_VERSION = "v1";
|
||||
changed = true;
|
||||
console.log("[Electron] ✨ STORAGE_ENCRYPTION_KEY auto-generated");
|
||||
}
|
||||
if (!serverEnv.API_KEY_SECRET) {
|
||||
serverEnv.API_KEY_SECRET = persisted.API_KEY_SECRET = crypto.randomBytes(32).toString("hex");
|
||||
changed = true;
|
||||
console.log("[Electron] ✨ API_KEY_SECRET auto-generated");
|
||||
}
|
||||
if (changed) {
|
||||
serverEnv.OMNIROUTE_BOOTSTRAPPED = "true";
|
||||
try {
|
||||
fs.mkdirSync(userDataDir, { recursive: true });
|
||||
const lines = [
|
||||
"# Auto-generated by OmniRoute bootstrap",
|
||||
"",
|
||||
...Object.entries(persisted).map(([k, v]) => `${k}=${v}`),
|
||||
"",
|
||||
];
|
||||
fs.writeFileSync(serverEnvPath, lines.join("\n"), "utf8");
|
||||
console.log("[Electron] 📁 Secrets persisted to:", serverEnvPath);
|
||||
} catch (e) {
|
||||
console.warn("[Electron] Could not persist secrets:", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[Electron] Starting Next.js server on port", serverPort);
|
||||
sendToRenderer("server-status", { status: "starting", port: serverPort });
|
||||
|
||||
@@ -390,7 +453,7 @@ function startNextServer() {
|
||||
nextServer = spawn("node", [serverScript], {
|
||||
cwd: NEXT_SERVER_PATH,
|
||||
env: {
|
||||
...process.env,
|
||||
...serverEnv,
|
||||
PORT: String(serverPort),
|
||||
NODE_ENV: "production",
|
||||
},
|
||||
|
||||
@@ -225,6 +225,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
|
||||
{ id: "qwen3-coder-flash", name: "Qwen3 Coder Flash" },
|
||||
{ id: "vision-model", name: "Qwen3 Vision Model" },
|
||||
{ id: "coder-model", name: "Qwen3.5 (Coder Model)" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -248,15 +249,20 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
authUrl: "https://iflow.cn/oauth",
|
||||
},
|
||||
models: [
|
||||
{ id: "iflow-rome-30ba3b", name: "iFlow ROME" },
|
||||
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
|
||||
{ id: "qwen3-max", name: "Qwen3 Max" },
|
||||
{ id: "qwen3-vl-plus", name: "Qwen3 Vision Plus" },
|
||||
{ id: "kimi-k2-0905", name: "Kimi K2 0905" },
|
||||
{ id: "qwen3-max-preview", name: "Qwen3 Max Preview" },
|
||||
{ id: "kimi-k2", name: "Kimi K2" },
|
||||
{ id: "kimi-k2-thinking", name: "Kimi K2 Thinking" },
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||
{ id: "deepseek-v3.2", name: "DeepSeek-V3.2-Exp" },
|
||||
{ id: "deepseek-r1", name: "DeepSeek R1" },
|
||||
{ id: "deepseek-v3.2-chat", name: "DeepSeek V3.2 Chat" },
|
||||
{ id: "deepseek-v3.2-reasoner", name: "DeepSeek V3.2 Reasoner" },
|
||||
{ id: "minimax-m2.1", name: "MiniMax M2.1" },
|
||||
{ id: "glm-4.7", name: "GLM 4.7" },
|
||||
{ id: "deepseek-v3", name: "DeepSeek V3" },
|
||||
{ id: "qwen3-32b", name: "Qwen3 32B" },
|
||||
{ id: "qwen3-235b-a22b-thinking-2507", name: "Qwen3 235B A22B Thinking 2507" },
|
||||
{ id: "qwen3-235b-a22b-instruct", name: "Qwen3 235B A22B Instruct" },
|
||||
{ id: "qwen3-235b", name: "Qwen3 235B" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -486,6 +492,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||
{ id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" },
|
||||
{ id: "kimi-latest", name: "Kimi Latest" },
|
||||
{ id: "kimi-for-coding", name: "Kimi For Coding" },
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -38,7 +38,13 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
const bodyProjectId = body?.project;
|
||||
const credentialsProjectId = credentials?.projectId;
|
||||
const projectId = bodyProjectId || credentialsProjectId;
|
||||
const allowBodyProjectOverride = process.env.OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE === "1";
|
||||
|
||||
// Default: prefer OAuth-stored projectId over incoming body.project to avoid
|
||||
// stale/wrong client-side values causing 404/403 from Cloud Code endpoints.
|
||||
// Opt-in escape hatch: set OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE=1.
|
||||
const projectId =
|
||||
allowBodyProjectOverride && bodyProjectId ? bodyProjectId : credentialsProjectId || bodyProjectId;
|
||||
|
||||
if (!projectId) {
|
||||
throw new Error(
|
||||
|
||||
@@ -40,6 +40,7 @@ export type ExecuteInput = {
|
||||
credentials: ProviderCredentials;
|
||||
signal?: AbortSignal | null;
|
||||
log?: ExecutorLog | null;
|
||||
extendedContext?: boolean;
|
||||
};
|
||||
|
||||
function mergeAbortSignals(primary: AbortSignal, secondary: AbortSignal): AbortSignal {
|
||||
@@ -174,7 +175,7 @@ export class BaseExecutor {
|
||||
return { status: response.status, message: bodyText || `HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
async execute({ model, body, stream, credentials, signal, log }: ExecuteInput) {
|
||||
async execute({ model, body, stream, credentials, signal, log, extendedContext }: ExecuteInput) {
|
||||
const fallbackCount = this.getFallbackCount();
|
||||
let lastError: unknown = null;
|
||||
let lastStatus = 0;
|
||||
@@ -182,6 +183,29 @@ export class BaseExecutor {
|
||||
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
|
||||
const url = this.buildUrl(model, stream, urlIndex, credentials);
|
||||
const headers = this.buildHeaders(credentials, stream);
|
||||
|
||||
// Append 1M context beta header when [1m] suffix was used
|
||||
// Only supported for specific Claude models per Anthropic docs
|
||||
if (extendedContext) {
|
||||
const EXTENDED_CONTEXT_MODELS = [
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-sonnet-4-5",
|
||||
"claude-sonnet-4",
|
||||
];
|
||||
const baseModel = model.replace(/-\d{8}$/, "");
|
||||
if (
|
||||
EXTENDED_CONTEXT_MODELS.some((m) => baseModel === m || model === m || model.startsWith(m))
|
||||
) {
|
||||
const existing = headers["Anthropic-Beta"];
|
||||
if (existing) {
|
||||
headers["Anthropic-Beta"] = existing + ",context-1m-2025-08-07";
|
||||
} else {
|
||||
headers["Anthropic-Beta"] = "context-1m-2025-08-07";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
||||
|
||||
try {
|
||||
|
||||
@@ -20,7 +20,16 @@ export class GeminiCLIExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
if (!body.project && credentials?.projectId) {
|
||||
const allowBodyProjectOverride = process.env.OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE === "1";
|
||||
|
||||
// Default: prefer OAuth-stored projectId. Incoming body.project can be stale
|
||||
// when clients cache older Cloud Code project values.
|
||||
// Opt-in escape hatch: set OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE=1.
|
||||
if (allowBodyProjectOverride && body?.project) {
|
||||
return body;
|
||||
}
|
||||
|
||||
if (credentials?.projectId) {
|
||||
body.project = credentials.projectId;
|
||||
}
|
||||
return body;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { addBufferToUsage, filterUsageForFormat, estimateUsage } from "../utils/
|
||||
import { refreshWithRetry } from "../services/tokenRefresh.ts";
|
||||
import { createRequestLogger } from "../utils/requestLogger.ts";
|
||||
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts";
|
||||
import { resolveModelAlias } from "../services/modelDeprecation.ts";
|
||||
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.ts";
|
||||
import { HTTP_STATUS } from "../config/constants.ts";
|
||||
import { handleBypassRequest } from "../utils/bypassHandler.ts";
|
||||
@@ -68,7 +69,7 @@ export async function handleChatCore({
|
||||
userAgent,
|
||||
comboName,
|
||||
}) {
|
||||
const { provider, model } = modelInfo;
|
||||
const { provider, model, extendedContext } = modelInfo;
|
||||
const startTime = Date.now();
|
||||
|
||||
// ── Phase 9.2: Idempotency check ──
|
||||
@@ -105,8 +106,13 @@ export async function handleChatCore({
|
||||
// Detect source format and get target format
|
||||
// Model-specific targetFormat takes priority over provider default
|
||||
|
||||
// Apply custom model aliases (Settings → Model Aliases → Pattern→Target) before routing (#315)
|
||||
// Custom aliases take priority over built-in and must be resolved here so the
|
||||
// downstream getModelTargetFormat() lookup uses the correct, aliased model ID.
|
||||
const resolvedModel = resolveModelAlias(model);
|
||||
|
||||
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
|
||||
const modelTargetFormat = getModelTargetFormat(alias, model);
|
||||
const modelTargetFormat = getModelTargetFormat(alias, resolvedModel);
|
||||
const targetFormat = modelTargetFormat || getTargetFormat(provider);
|
||||
|
||||
// Default to false unless client explicitly sets stream: true (OpenAI spec compliant)
|
||||
@@ -158,6 +164,28 @@ export async function handleChatCore({
|
||||
translatedBody = { ...translatedBody, _disableToolPrefix: true };
|
||||
}
|
||||
|
||||
// ── #291: Strip empty name fields from messages/input items ──
|
||||
// Upstream providers (OpenAI, Codex) reject name:"" with 400 errors.
|
||||
// Clients like PocketPaw may forward empty name fields from assistant turns.
|
||||
if (Array.isArray(body.messages)) {
|
||||
body.messages = body.messages.map((msg: Record<string, unknown>) => {
|
||||
if (msg.name === "") {
|
||||
const { name: _n, ...rest } = msg;
|
||||
return rest;
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
}
|
||||
if (Array.isArray(body.input)) {
|
||||
body.input = body.input.map((item: Record<string, unknown>) => {
|
||||
if (item.name === "") {
|
||||
const { name: _n, ...rest } = item;
|
||||
return rest;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
translatedBody = translateRequest(
|
||||
sourceFormat,
|
||||
targetFormat,
|
||||
@@ -248,6 +276,7 @@ export async function handleChatCore({
|
||||
credentials,
|
||||
signal: streamController.signal,
|
||||
log,
|
||||
extendedContext,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -335,6 +364,7 @@ export async function handleChatCore({
|
||||
credentials,
|
||||
signal: streamController.signal,
|
||||
log,
|
||||
extendedContext,
|
||||
});
|
||||
|
||||
if (retryResult.response.ok) {
|
||||
|
||||
@@ -59,29 +59,50 @@ function resolveProviderModelAlias(providerOrAlias, modelId) {
|
||||
|
||||
/**
|
||||
* Parse model string: "alias/model" or "provider/model" or just alias
|
||||
* Supports [1m] suffix for extended 1M context window (e.g. "claude-sonnet-4-6[1m]")
|
||||
*/
|
||||
export function parseModel(modelStr) {
|
||||
if (!modelStr) {
|
||||
return { provider: null, model: null, isAlias: false, providerAlias: null };
|
||||
return {
|
||||
provider: null,
|
||||
model: null,
|
||||
isAlias: false,
|
||||
providerAlias: null,
|
||||
extendedContext: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Sanitize: reject strings with path traversal or control characters
|
||||
if (/\.\.[\/\\]/.test(modelStr) || /[\x00-\x1f]/.test(modelStr)) {
|
||||
console.log(`[MODEL] Warning: rejected malformed model string: "${modelStr.substring(0, 50)}"`);
|
||||
return { provider: null, model: null, isAlias: false, providerAlias: null };
|
||||
return {
|
||||
provider: null,
|
||||
model: null,
|
||||
isAlias: false,
|
||||
providerAlias: null,
|
||||
extendedContext: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Extract [1m] suffix before parsing provider/model
|
||||
let extendedContext = false;
|
||||
let cleanStr = modelStr;
|
||||
if (cleanStr.endsWith("[1m]")) {
|
||||
extendedContext = true;
|
||||
cleanStr = cleanStr.slice(0, -4);
|
||||
}
|
||||
|
||||
// Check if standard format: provider/model or alias/model
|
||||
if (modelStr.includes("/")) {
|
||||
const firstSlash = modelStr.indexOf("/");
|
||||
const providerOrAlias = modelStr.slice(0, firstSlash);
|
||||
const model = modelStr.slice(firstSlash + 1);
|
||||
if (cleanStr.includes("/")) {
|
||||
const firstSlash = cleanStr.indexOf("/");
|
||||
const providerOrAlias = cleanStr.slice(0, firstSlash);
|
||||
const model = cleanStr.slice(firstSlash + 1);
|
||||
const provider = resolveProviderAlias(providerOrAlias);
|
||||
return { provider, model, isAlias: false, providerAlias: providerOrAlias };
|
||||
return { provider, model, isAlias: false, providerAlias: providerOrAlias, extendedContext };
|
||||
}
|
||||
|
||||
// Alias format (model alias, not provider alias)
|
||||
return { provider: null, model: modelStr, isAlias: true, providerAlias: null };
|
||||
return { provider: null, model: cleanStr, isAlias: true, providerAlias: null, extendedContext };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,12 +144,14 @@ export function resolveModelAliasFromMap(alias, aliases) {
|
||||
*/
|
||||
export async function getModelInfoCore(modelStr, aliasesOrGetter) {
|
||||
const parsed = parseModel(modelStr);
|
||||
const { extendedContext } = parsed;
|
||||
|
||||
if (!parsed.isAlias) {
|
||||
const canonicalModel = resolveProviderModelAlias(parsed.provider, parsed.model);
|
||||
return {
|
||||
provider: parsed.provider,
|
||||
model: canonicalModel,
|
||||
extendedContext,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -142,6 +165,7 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
|
||||
return {
|
||||
provider: resolved.provider,
|
||||
model: canonicalModel,
|
||||
extendedContext,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -153,6 +177,7 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
|
||||
return {
|
||||
provider: "openai",
|
||||
model: modelId,
|
||||
extendedContext,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -160,7 +185,7 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
|
||||
if (nonOpenAIProviders.length === 1) {
|
||||
const provider = nonOpenAIProviders[0];
|
||||
const canonicalModel = resolveProviderModelAlias(provider, modelId);
|
||||
return { provider, model: canonicalModel };
|
||||
return { provider, model: canonicalModel, extendedContext };
|
||||
}
|
||||
|
||||
if (nonOpenAIProviders.length > 1) {
|
||||
@@ -182,5 +207,6 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
|
||||
return {
|
||||
provider: "openai",
|
||||
model: modelId,
|
||||
extendedContext,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,6 +59,11 @@ const PERSIST_DEBOUNCE_MS = 60_000; // Debounce persistence to every 60s max
|
||||
// Track initialization
|
||||
let initialized = false;
|
||||
|
||||
// Max time (ms) a job can wait in queue before failing with a timeout error.
|
||||
// Prevents infinite queuing when all providers are exhausted after a 429.
|
||||
// Configurable via RATE_LIMIT_MAX_WAIT_MS env var (default: 2 minutes).
|
||||
const MAX_WAIT_MS = parseInt(process.env.RATE_LIMIT_MAX_WAIT_MS || "120000", 10);
|
||||
|
||||
// Default conservative settings (before we learn from headers)
|
||||
const DEFAULT_SETTINGS = {
|
||||
maxConcurrent: 10,
|
||||
@@ -66,6 +71,7 @@ const DEFAULT_SETTINGS = {
|
||||
reservoir: null, // No initial reservoir — unlimited until we learn
|
||||
reservoirRefreshAmount: null,
|
||||
reservoirRefreshInterval: null,
|
||||
maxWait: MAX_WAIT_MS, // Fail-fast: don't queue forever on 429 exhaustion
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -111,6 +117,7 @@ export async function initializeRateLimits() {
|
||||
reservoir: rpm,
|
||||
reservoirRefreshAmount: rpm,
|
||||
reservoirRefreshInterval: 60 * 1000,
|
||||
maxWait: MAX_WAIT_MS,
|
||||
id: key,
|
||||
})
|
||||
);
|
||||
@@ -135,6 +142,7 @@ export async function initializeRateLimits() {
|
||||
reservoir: DEFAULT_API_LIMITS.requestsPerMinute,
|
||||
reservoirRefreshAmount: DEFAULT_API_LIMITS.requestsPerMinute,
|
||||
reservoirRefreshInterval: 60 * 1000, // Refresh every minute
|
||||
maxWait: MAX_WAIT_MS,
|
||||
id: key,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -488,13 +488,14 @@ async function getClaudeUsage(accessToken) {
|
||||
const data = await oauthResponse.json();
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
|
||||
// utilization = percentage REMAINING (e.g., 90 means 90% remaining, 10% used)
|
||||
// utilization = percentage USED (e.g., 90 means 90% used, 10% remaining)
|
||||
// Confirmed via user report #299: Claude.ai shows 87% used = OmniRoute must show 13% remaining.
|
||||
const hasUtilization = (window: JsonRecord) =>
|
||||
window && typeof window === "object" && safePercentage(window.utilization) !== undefined;
|
||||
|
||||
const createQuotaObject = (window: JsonRecord) => {
|
||||
const remaining = safePercentage(window.utilization) as number;
|
||||
const used = 100 - remaining;
|
||||
const used = safePercentage(window.utilization) as number; // utilization = % used
|
||||
const remaining = Math.max(0, 100 - used);
|
||||
return {
|
||||
used,
|
||||
total: 100,
|
||||
@@ -917,12 +918,12 @@ async function getKimiUsage(accessToken) {
|
||||
};
|
||||
};
|
||||
|
||||
if (hasUtilization(dataObj.five_hour)) {
|
||||
quotas["session (5h)"] = createQuotaObject(dataObj.five_hour);
|
||||
if (hasUtilization(toRecord(dataObj.five_hour))) {
|
||||
quotas["session (5h)"] = createQuotaObject(toRecord(dataObj.five_hour));
|
||||
}
|
||||
|
||||
if (hasUtilization(dataObj.seven_day)) {
|
||||
quotas["weekly (7d)"] = createQuotaObject(dataObj.seven_day);
|
||||
if (hasUtilization(toRecord(dataObj.seven_day))) {
|
||||
quotas["weekly (7d)"] = createQuotaObject(toRecord(dataObj.seven_day));
|
||||
}
|
||||
|
||||
// Check for model-specific quotas
|
||||
@@ -935,7 +936,8 @@ async function getKimiUsage(accessToken) {
|
||||
}
|
||||
|
||||
if (Object.keys(quotas).length > 0) {
|
||||
const membershipLevel = dataObj.user?.membership?.level;
|
||||
const userRecord = toRecord(dataObj.user);
|
||||
const membershipLevel = toRecord(userRecord.membership).level;
|
||||
const planName = getKimiPlanName(membershipLevel);
|
||||
return {
|
||||
plan: planName || "Kimi Coding",
|
||||
@@ -944,7 +946,8 @@ async function getKimiUsage(accessToken) {
|
||||
}
|
||||
|
||||
// No quota data in response
|
||||
const membershipLevel = dataObj.user?.membership?.level;
|
||||
const userRecord = toRecord(dataObj.user);
|
||||
const membershipLevel = toRecord(userRecord.membership).level;
|
||||
const planName = getKimiPlanName(membershipLevel);
|
||||
return {
|
||||
plan: planName || "Kimi Coding",
|
||||
|
||||
@@ -51,7 +51,9 @@ export function claudeToOpenAIResponse(chunk, state) {
|
||||
} else if (block?.type === "thinking") {
|
||||
state.inThinkingBlock = true;
|
||||
state.currentBlockIndex = chunk.index;
|
||||
results.push(createChunk(state, { content: "<think>" }));
|
||||
// Emit empty reasoning_content to signal thinking block start
|
||||
// (clients like Claude Code look for reasoning_content, not <think> tags)
|
||||
results.push(createChunk(state, { reasoning_content: "" }));
|
||||
} else if (block?.type === "tool_use") {
|
||||
const toolCallIndex = state.toolCallIndex++;
|
||||
// Restore original tool name from mapping (Claude OAuth)
|
||||
@@ -76,7 +78,9 @@ export function claudeToOpenAIResponse(chunk, state) {
|
||||
if (delta?.type === "text_delta" && delta.text) {
|
||||
results.push(createChunk(state, { content: delta.text }));
|
||||
} else if (delta?.type === "thinking_delta" && delta.thinking) {
|
||||
results.push(createChunk(state, { content: delta.thinking }));
|
||||
// Map Claude thinking_delta → OpenAI reasoning_content
|
||||
// Clients (Claude Code, Cursor, etc.) display reasoning_content as the thinking panel
|
||||
results.push(createChunk(state, { reasoning_content: delta.thinking }));
|
||||
} else if (delta?.type === "input_json_delta" && delta.partial_json) {
|
||||
const toolCall = state.toolCalls.get(chunk.index);
|
||||
if (toolCall) {
|
||||
@@ -99,7 +103,8 @@ export function claudeToOpenAIResponse(chunk, state) {
|
||||
|
||||
case "content_block_stop": {
|
||||
if (state.inThinkingBlock && chunk.index === state.currentBlockIndex) {
|
||||
results.push(createChunk(state, { content: "</think>" }));
|
||||
// Thinking block closed — no additional content needed;
|
||||
// reasoning_content chunks have already been streamed
|
||||
state.inThinkingBlock = false;
|
||||
}
|
||||
state.textBlockStarted = false;
|
||||
|
||||
Generated
+6
-16
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.2.3",
|
||||
"version": "2.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "2.2.3",
|
||||
"version": "2.3.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -15,6 +15,7 @@
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@swc/helpers": "0.5.19",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"bottleneck": "^2.19.5",
|
||||
@@ -7212,9 +7213,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.4",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.4.tgz",
|
||||
"integrity": "sha512-ooiZW1Xy8rQ4oELQ++otI2T9DsKpV0M6c6cO6JGx4RTfav9poFFLlet9UMXHZnoM1yG0HWGlQLswBGX3RZmHtg==",
|
||||
"version": "4.12.7",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
|
||||
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -8978,17 +8979,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-intl/node_modules/@swc/helpers": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
|
||||
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.2.3",
|
||||
"version": "2.3.2",
|
||||
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -13,6 +13,7 @@
|
||||
"open-sse/mcp-server/",
|
||||
"src/shared/contracts/",
|
||||
"scripts/postinstall.mjs",
|
||||
"scripts/native-binary-compat.mjs",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
@@ -109,7 +110,8 @@
|
||||
"uuid": "^13.0.0",
|
||||
"wreq-js": "^2.0.1",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.10"
|
||||
"zustand": "^5.0.10",
|
||||
"@swc/helpers": "0.5.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
@@ -142,6 +144,6 @@
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"@swc/helpers": "^0.5.19"
|
||||
"@swc/helpers": "0.5.19"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* OmniRoute — Zero-Config Bootstrap
|
||||
*
|
||||
* Auto-generates required secrets (JWT_SECRET, STORAGE_ENCRYPTION_KEY) if
|
||||
* missing or empty, persists them to {DATA_DIR}/server.env so they survive
|
||||
* restarts, Docker volume remounts, and upgrades.
|
||||
*
|
||||
* Works across all deployment modes:
|
||||
* - npm / CLI: called from run-standalone.mjs and run-next.mjs
|
||||
* - Docker: same, secrets persisted in mounted volume
|
||||
* - Electron: called from main.js startup, persisted in userData
|
||||
*
|
||||
* Priority (lowest → highest):
|
||||
* 1. Auto-generated defaults
|
||||
* 2. {DATA_DIR}/server.env (persisted on first boot)
|
||||
* 3. .env in CWD (user overrides)
|
||||
* 4. process.env (shell / Docker -e flags, highest priority)
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
// ── OAuth secrets that are optional but warn if missing ─────────────────────
|
||||
const OPTIONAL_OAUTH_SECRETS = [
|
||||
{ key: "ANTIGRAVITY_OAUTH_CLIENT_SECRET", label: "Antigravity OAuth" },
|
||||
{ key: "IFLOW_OAUTH_CLIENT_SECRET", label: "iFlow OAuth" },
|
||||
{ key: "GEMINI_OAUTH_CLIENT_SECRET", label: "Gemini OAuth" },
|
||||
];
|
||||
|
||||
// ── Resolve DATA_DIR (mirrors dataPaths.ts logic) ───────────────────────────
|
||||
function resolveDataDir(overridePath) {
|
||||
if (overridePath) return resolve(overridePath);
|
||||
|
||||
const configured = process.env.DATA_DIR?.trim();
|
||||
if (configured) return resolve(configured);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
|
||||
return join(appData, "omniroute");
|
||||
}
|
||||
|
||||
const xdg = process.env.XDG_CONFIG_HOME?.trim();
|
||||
if (xdg) return join(resolve(xdg), "omniroute");
|
||||
|
||||
return join(homedir(), ".omniroute");
|
||||
}
|
||||
|
||||
// ── Parse a simple KEY=VALUE env file ───────────────────────────────────────
|
||||
function parseEnvFile(filePath) {
|
||||
if (!existsSync(filePath)) return {};
|
||||
const env = {};
|
||||
const lines = readFileSync(filePath, "utf8").split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eqIdx = trimmed.indexOf("=");
|
||||
if (eqIdx < 1) continue;
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
const val = trimmed.slice(eqIdx + 1).trim();
|
||||
env[key] = val;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
// ── Write a simple KEY=VALUE env file ───────────────────────────────────────
|
||||
function writeEnvFile(filePath, env) {
|
||||
const lines = [
|
||||
"# Auto-generated by OmniRoute bootstrap — do not delete",
|
||||
`# Created: ${new Date().toISOString()}`,
|
||||
"",
|
||||
...Object.entries(env).map(([k, v]) => `${k}=${v}`),
|
||||
"",
|
||||
];
|
||||
writeFileSync(filePath, lines.join("\n"), "utf8");
|
||||
}
|
||||
|
||||
// ── Main bootstrap function ──────────────────────────────────────────────────
|
||||
/**
|
||||
* @param {{ dataDirOverride?: string; quiet?: boolean }} options
|
||||
* @returns {Record<string, string>} merged env to pass to child process
|
||||
*/
|
||||
export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) {
|
||||
const log = quiet ? () => {} : (msg) => process.stderr.write(`[bootstrap] ${msg}\n`);
|
||||
|
||||
const dataDir = resolveDataDir(dataDirOverride);
|
||||
const serverEnvPath = join(dataDir, "server.env");
|
||||
const dotEnvPath = join(process.cwd(), ".env");
|
||||
|
||||
// ── Layer 1: Load persisted server.env ────────────────────────────────────
|
||||
let persisted = parseEnvFile(serverEnvPath);
|
||||
|
||||
// ── Layer 2: Load .env from CWD (user overrides, higher priority) ─────────
|
||||
const dotEnv = parseEnvFile(dotEnvPath);
|
||||
|
||||
// ── Merge: persisted < .env < process.env ─────────────────────────────────
|
||||
const merged = { ...persisted, ...dotEnv, ...process.env };
|
||||
|
||||
// ── Auto-generate required secrets ────────────────────────────────────────
|
||||
let needsPersist = false;
|
||||
|
||||
if (!merged.JWT_SECRET?.trim()) {
|
||||
persisted.JWT_SECRET = randomBytes(64).toString("hex");
|
||||
merged.JWT_SECRET = persisted.JWT_SECRET;
|
||||
needsPersist = true;
|
||||
log("✨ JWT_SECRET auto-generated (first run)");
|
||||
}
|
||||
|
||||
if (!merged.STORAGE_ENCRYPTION_KEY?.trim()) {
|
||||
persisted.STORAGE_ENCRYPTION_KEY = randomBytes(32).toString("hex");
|
||||
merged.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY;
|
||||
needsPersist = true;
|
||||
log("✨ STORAGE_ENCRYPTION_KEY auto-generated (first run)");
|
||||
}
|
||||
|
||||
if (!merged.STORAGE_ENCRYPTION_KEY_VERSION?.trim()) {
|
||||
persisted.STORAGE_ENCRYPTION_KEY_VERSION = "v1";
|
||||
merged.STORAGE_ENCRYPTION_KEY_VERSION = persisted.STORAGE_ENCRYPTION_KEY_VERSION;
|
||||
needsPersist = true;
|
||||
}
|
||||
|
||||
if (!merged.API_KEY_SECRET?.trim()) {
|
||||
persisted.API_KEY_SECRET = randomBytes(32).toString("hex");
|
||||
merged.API_KEY_SECRET = persisted.API_KEY_SECRET;
|
||||
needsPersist = true;
|
||||
log("✨ API_KEY_SECRET auto-generated (first run)");
|
||||
}
|
||||
|
||||
// ── Persist new secrets ────────────────────────────────────────────────────
|
||||
if (needsPersist) {
|
||||
try {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
// Only persist keys that we auto-generated (not .env or process.env vals)
|
||||
writeEnvFile(serverEnvPath, persisted);
|
||||
log(`📁 Secrets persisted to: ${serverEnvPath}`);
|
||||
} catch (e) {
|
||||
log(`⚠️ Could not persist secrets to ${serverEnvPath}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mark as bootstrapped ───────────────────────────────────────────────────
|
||||
if (needsPersist) {
|
||||
merged.OMNIROUTE_BOOTSTRAPPED = "true";
|
||||
}
|
||||
|
||||
// ── Warn about missing optional OAuth secrets ──────────────────────────────
|
||||
const missingOauth = OPTIONAL_OAUTH_SECRETS.filter(({ key }) => !merged[key]?.trim());
|
||||
if (missingOauth.length > 0) {
|
||||
log("ℹ️ The following OAuth integrations are not configured:");
|
||||
for (const { key, label } of missingOauth) {
|
||||
log(` • ${label} (${key}) — set in .env or ${serverEnvPath}`);
|
||||
}
|
||||
log(" These providers will not work until configured.");
|
||||
}
|
||||
|
||||
// ── Warn about default password ────────────────────────────────────────────
|
||||
if (merged.INITIAL_PASSWORD === "CHANGEME" || !merged.INITIAL_PASSWORD?.trim()) {
|
||||
log("⚠️ INITIAL_PASSWORD is not set — using default 'CHANGEME'. Change it in Settings!");
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ── CLI usage: node scripts/bootstrap-env.mjs ──────────────────────────────
|
||||
if (process.argv[1] && process.argv[1].endsWith("bootstrap-env.mjs")) {
|
||||
const env = bootstrapEnv();
|
||||
process.stderr.write(`[bootstrap] Done. DATA_DIR resolved to: ${resolveDataDir()}\n`);
|
||||
process.stderr.write(`[bootstrap] JWT_SECRET length: ${env.JWT_SECRET?.length ?? 0}\n`);
|
||||
process.stderr.write(
|
||||
`[bootstrap] STORAGE_ENCRYPTION_KEY length: ${env.STORAGE_ENCRYPTION_KEY?.length ?? 0}\n`
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
/**
|
||||
* Docker healthcheck script for OmniRoute.
|
||||
* Checks the /api/settings endpoint on the dashboard port.
|
||||
* Checks the /api/monitoring/health endpoint on the dashboard port.
|
||||
* Used by Dockerfile and docker-compose files.
|
||||
*/
|
||||
const port = process.env.DASHBOARD_PORT || process.env.PORT || "20128";
|
||||
|
||||
fetch(`http://127.0.0.1:${port}/api/settings`)
|
||||
fetch(`http://127.0.0.1:${port}/api/monitoring/health`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
})
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { existsSync, openSync, readSync, closeSync } from "node:fs";
|
||||
|
||||
export const PUBLISHED_BUILD_PLATFORM = "linux";
|
||||
export const PUBLISHED_BUILD_ARCH = "x64";
|
||||
|
||||
const HEADER_SIZE = 4096;
|
||||
const MAX_FAT_ARCH_COUNT = 30;
|
||||
|
||||
function mapElfMachine(machine) {
|
||||
switch (machine) {
|
||||
case 62:
|
||||
return "x64";
|
||||
case 183:
|
||||
return "arm64";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mapMachCpuType(cpuType) {
|
||||
switch (cpuType) {
|
||||
case 0x01000007:
|
||||
return "x64";
|
||||
case 0x0100000c:
|
||||
return "arm64";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mapPeMachine(machine) {
|
||||
switch (machine) {
|
||||
case 0x8664:
|
||||
return "x64";
|
||||
case 0xaa64:
|
||||
return "arm64";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readUInt16(buffer, offset, littleEndian) {
|
||||
return littleEndian ? buffer.readUInt16LE(offset) : buffer.readUInt16BE(offset);
|
||||
}
|
||||
|
||||
function readUInt32(buffer, offset, littleEndian) {
|
||||
return littleEndian ? buffer.readUInt32LE(offset) : buffer.readUInt32BE(offset);
|
||||
}
|
||||
|
||||
const ELF_MAGIC = 0x7f454c46;
|
||||
|
||||
function detectElfTarget(buffer) {
|
||||
if (buffer.length < 20) return null;
|
||||
if (buffer.readUInt32BE(0) !== ELF_MAGIC) return null;
|
||||
|
||||
const littleEndian = buffer[5] !== 2;
|
||||
const arch = mapElfMachine(readUInt16(buffer, 18, littleEndian));
|
||||
if (!arch) return null;
|
||||
|
||||
return { platform: "linux", architectures: [arch] };
|
||||
}
|
||||
|
||||
const THIN_MACH_MAGIC = new Map([
|
||||
[0xfeedface, false],
|
||||
[0xfeedfacf, false],
|
||||
[0xcefaedfe, true],
|
||||
[0xcffaedfe, true],
|
||||
]);
|
||||
const FAT_MACH_MAGIC = new Map([
|
||||
[0xcafebabe, false],
|
||||
[0xcafebabf, false],
|
||||
[0xbebafeca, true],
|
||||
[0xbfbafeca, true],
|
||||
]);
|
||||
|
||||
function detectMachTarget(buffer) {
|
||||
if (buffer.length < 8) return null;
|
||||
|
||||
const magic = buffer.readUInt32BE(0);
|
||||
|
||||
if (THIN_MACH_MAGIC.has(magic)) {
|
||||
const littleEndian = THIN_MACH_MAGIC.get(magic);
|
||||
const arch = mapMachCpuType(readUInt32(buffer, 4, littleEndian));
|
||||
if (!arch) return null;
|
||||
return { platform: "darwin", architectures: [arch] };
|
||||
}
|
||||
|
||||
if (!FAT_MACH_MAGIC.has(magic)) return null;
|
||||
|
||||
const littleEndian = FAT_MACH_MAGIC.get(magic);
|
||||
const isFat64 = magic === 0xcafebabf || magic === 0xbfbafeca;
|
||||
const archCount = readUInt32(buffer, 4, littleEndian);
|
||||
if (archCount > MAX_FAT_ARCH_COUNT) return null;
|
||||
const entrySize = isFat64 ? 32 : 20;
|
||||
const architectures = new Set();
|
||||
|
||||
for (let index = 0; index < archCount; index += 1) {
|
||||
const offset = 8 + index * entrySize;
|
||||
if (offset + 4 > buffer.length) break;
|
||||
const arch = mapMachCpuType(readUInt32(buffer, offset, littleEndian));
|
||||
if (arch) architectures.add(arch);
|
||||
}
|
||||
|
||||
if (architectures.size === 0) return null;
|
||||
return { platform: "darwin", architectures: [...architectures] };
|
||||
}
|
||||
|
||||
function detectPeTarget(buffer) {
|
||||
if (buffer.length < 0x40) return null;
|
||||
if (buffer.readUInt16LE(0) !== 0x5a4d) return null;
|
||||
|
||||
const peHeaderOffset = buffer.readUInt32LE(0x3c);
|
||||
if (peHeaderOffset + 6 > buffer.length) return null;
|
||||
if (buffer.readUInt32LE(peHeaderOffset) !== 0x00004550) return null;
|
||||
|
||||
const arch = mapPeMachine(buffer.readUInt16LE(peHeaderOffset + 4));
|
||||
if (!arch) return null;
|
||||
return { platform: "win32", architectures: [arch] };
|
||||
}
|
||||
|
||||
export function detectNativeBinaryTarget(buffer) {
|
||||
return detectElfTarget(buffer) ?? detectMachTarget(buffer) ?? detectPeTarget(buffer);
|
||||
}
|
||||
|
||||
export function readNativeBinaryTarget(binaryPath) {
|
||||
if (!existsSync(binaryPath)) return null;
|
||||
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(binaryPath, "r");
|
||||
const buffer = Buffer.alloc(HEADER_SIZE);
|
||||
const bytesRead = readSync(fd, buffer, 0, HEADER_SIZE, 0);
|
||||
return detectNativeBinaryTarget(buffer.subarray(0, bytesRead));
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ Could not read native binary at ${binaryPath}: ${err.message}`);
|
||||
return null;
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
export function isNativeBinaryCompatible(
|
||||
binaryPath,
|
||||
{ runtimePlatform = process.platform, runtimeArch = process.arch, dlopen = process.dlopen } = {}
|
||||
) {
|
||||
const target = readNativeBinaryTarget(binaryPath);
|
||||
|
||||
if (target) {
|
||||
if (target.platform !== runtimePlatform || !target.architectures.includes(runtimeArch)) {
|
||||
return false;
|
||||
}
|
||||
} else if (runtimePlatform !== PUBLISHED_BUILD_PLATFORM || runtimeArch !== PUBLISHED_BUILD_ARCH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
dlopen({ exports: {} }, binaryPath);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ Native binary dlopen failed: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+83
-25
@@ -1,57 +1,115 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* OmniRoute — Postinstall Native Module Rebuild
|
||||
* OmniRoute — Postinstall Native Module Fix
|
||||
*
|
||||
* The npm package ships with a Next.js standalone build that includes
|
||||
* better-sqlite3 compiled for the build platform (Linux x64).
|
||||
* This script detects platform mismatches and rebuilds the native
|
||||
* module for the user's actual OS/architecture.
|
||||
* better-sqlite3 compiled for the build platform (Linux x64) inside
|
||||
* app/node_modules/. However, npm also installs better-sqlite3 as a
|
||||
* top-level dependency (in the root node_modules/), correctly compiled
|
||||
* for the user's platform.
|
||||
*
|
||||
* This script copies the correctly-built native binary from the root
|
||||
* into the standalone app directory — no rebuild or build tools needed.
|
||||
*
|
||||
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/129
|
||||
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/321
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, copyFileSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { PUBLISHED_BUILD_PLATFORM, PUBLISHED_BUILD_ARCH } from "./native-binary-compat.mjs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const ROOT = join(__dirname, "..");
|
||||
|
||||
// The standalone build bundles better-sqlite3 inside app/node_modules
|
||||
const appNodeModules = join(ROOT, "app", "node_modules", "better-sqlite3");
|
||||
const appBinary = join(
|
||||
ROOT,
|
||||
"app",
|
||||
"node_modules",
|
||||
"better-sqlite3",
|
||||
"build",
|
||||
"Release",
|
||||
"better_sqlite3.node"
|
||||
);
|
||||
const rootBinary = join(
|
||||
ROOT,
|
||||
"node_modules",
|
||||
"better-sqlite3",
|
||||
"build",
|
||||
"Release",
|
||||
"better_sqlite3.node"
|
||||
);
|
||||
|
||||
if (!existsSync(appNodeModules)) {
|
||||
// No bundled better-sqlite3 — nothing to do (dev install, not npm global)
|
||||
if (!existsSync(join(ROOT, "app", "node_modules", "better-sqlite3"))) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const buildInfoPath = join(appNodeModules, "build", "Release", "better_sqlite3.node");
|
||||
const platformMatch =
|
||||
process.platform === PUBLISHED_BUILD_PLATFORM && process.arch === PUBLISHED_BUILD_ARCH;
|
||||
|
||||
// Quick check: try to load the native module
|
||||
try {
|
||||
// Use a dynamic import-like approach — try to dlopen the .node file
|
||||
process.dlopen({ exports: {} }, buildInfoPath);
|
||||
// If it loaded, the binary is compatible — nothing to do
|
||||
process.exit(0);
|
||||
} catch {
|
||||
// Binary is incompatible — rebuild
|
||||
if (platformMatch) {
|
||||
try {
|
||||
process.dlopen({ exports: {} }, appBinary);
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ Bundled binary incompatible despite platform match: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n 🔧 Rebuilding better-sqlite3 for ${process.platform}-${process.arch}...`);
|
||||
console.log(`\n 🔧 Fixing better-sqlite3 binary for ${process.platform}-${process.arch}...`);
|
||||
|
||||
// Strategy 1: Copy the correctly-built binary from root node_modules
|
||||
if (existsSync(rootBinary)) {
|
||||
try {
|
||||
mkdirSync(dirname(appBinary), { recursive: true });
|
||||
copyFileSync(rootBinary, appBinary);
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ Failed to copy binary: ${err.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
process.dlopen({ exports: {} }, appBinary);
|
||||
console.log(" ✅ Native module fixed successfully!\n");
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ Copied binary failed to load: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Fall back to npm rebuild (may work if build tools are available)
|
||||
console.log(" ⚠️ Root binary not available or incompatible, attempting npm rebuild...");
|
||||
|
||||
try {
|
||||
const { execSync } = await import("node:child_process");
|
||||
execSync("npm rebuild better-sqlite3", {
|
||||
cwd: join(ROOT, "app"),
|
||||
stdio: "inherit",
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
process.dlopen({ exports: {} }, appBinary);
|
||||
console.log(" ✅ Native module rebuilt successfully!\n");
|
||||
} catch (error) {
|
||||
console.warn(" ⚠️ Failed to rebuild better-sqlite3 automatically.");
|
||||
console.warn(" You can fix this manually by running:");
|
||||
console.warn(` cd ${join(ROOT, "app")} && npm rebuild better-sqlite3\n`);
|
||||
// Don't fail the install — the user can fix manually
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
const isTimeout = err.killed || err.signal === "SIGTERM";
|
||||
if (isTimeout) {
|
||||
console.warn(" ⚠️ npm rebuild timed out after 120s.");
|
||||
} else {
|
||||
console.warn(` ⚠️ npm rebuild failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing worked, warn but don't fail the install — let the package stay
|
||||
// installed so users can fix manually or use the pre-flight check in the CLI
|
||||
console.warn(" ⚠️ Could not fix better-sqlite3 native module automatically.");
|
||||
console.warn(" The server may not start correctly.");
|
||||
console.warn(" Try manually:");
|
||||
console.warn(` cd ${join(ROOT, "app")} && npm rebuild better-sqlite3`);
|
||||
if (process.platform === "darwin") {
|
||||
console.warn(" If build tools are missing: xcode-select --install");
|
||||
}
|
||||
console.warn("");
|
||||
|
||||
@@ -5,12 +5,16 @@ import {
|
||||
withRuntimePortEnv,
|
||||
spawnWithForwardedSignals,
|
||||
} from "./runtime-env.mjs";
|
||||
import { bootstrapEnv } from "./bootstrap-env.mjs";
|
||||
|
||||
const mode = process.argv[2] === "start" ? "start" : "dev";
|
||||
|
||||
const runtimePorts = resolveRuntimePorts();
|
||||
const { dashboardPort } = runtimePorts;
|
||||
|
||||
// Auto-generate secrets on first run, merge .env + process.env
|
||||
const env = bootstrapEnv();
|
||||
|
||||
const args = ["./node_modules/next/dist/bin/next", mode, "--port", String(dashboardPort)];
|
||||
if (mode === "dev") {
|
||||
args.splice(2, 0, "--webpack");
|
||||
@@ -18,5 +22,5 @@ if (mode === "dev") {
|
||||
|
||||
spawnWithForwardedSignals(process.execPath, args, {
|
||||
stdio: "inherit",
|
||||
env: withRuntimePortEnv(process.env, runtimePorts),
|
||||
env: withRuntimePortEnv(env, runtimePorts),
|
||||
});
|
||||
|
||||
@@ -5,10 +5,14 @@ import {
|
||||
withRuntimePortEnv,
|
||||
spawnWithForwardedSignals,
|
||||
} from "./runtime-env.mjs";
|
||||
import { bootstrapEnv } from "./bootstrap-env.mjs";
|
||||
|
||||
const runtimePorts = resolveRuntimePorts();
|
||||
|
||||
// Auto-generate secrets on first run, merge .env + process.env
|
||||
const env = bootstrapEnv();
|
||||
|
||||
spawnWithForwardedSignals("node", ["server.js"], {
|
||||
stdio: "inherit",
|
||||
env: withRuntimePortEnv(process.env, runtimePorts),
|
||||
env: withRuntimePortEnv(env, runtimePorts),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* Shown when OmniRoute was started with auto-generated secrets (zero-config mode).
|
||||
* The banner is dismissable and persists only for the current session.
|
||||
*/
|
||||
export default function BootstrapBanner() {
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
// Determine default data dir hint based on platform hint from user-agent
|
||||
const dataDir =
|
||||
typeof navigator !== "undefined" && navigator.platform?.startsWith("Win")
|
||||
? "%APPDATA%\\omniroute\\server.env"
|
||||
: "~/.omniroute/server.env";
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-200 mb-4"
|
||||
>
|
||||
<span className="text-amber-400 text-base shrink-0 mt-0.5">⚠️</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-amber-300">Running in zero-config mode</p>
|
||||
<p className="mt-0.5 text-amber-200/80">
|
||||
OmniRoute auto-generated secure encryption keys on first launch. They are persisted to{" "}
|
||||
<code className="font-mono bg-amber-500/20 px-1 rounded text-xs">{dataDir}</code>. No
|
||||
action is required — your data is encrypted and safe. To use custom keys, add{" "}
|
||||
<code className="font-mono bg-amber-500/20 px-1 rounded text-xs">JWT_SECRET</code> and{" "}
|
||||
<code className="font-mono bg-amber-500/20 px-1 rounded text-xs">
|
||||
STORAGE_ENCRYPTION_KEY
|
||||
</code>{" "}
|
||||
to that file.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="shrink-0 text-amber-400/60 hover:text-amber-300 transition-colors ml-1"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
|
||||
import { getMachineId } from "@/shared/utils/machine";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import HomePageClient from "./HomePageClient";
|
||||
import BootstrapBanner from "./BootstrapBanner";
|
||||
|
||||
// Must be dynamic — depends on DB state (setupComplete) that changes at runtime
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -12,5 +13,11 @@ export default async function DashboardPage() {
|
||||
redirect("/dashboard/onboarding");
|
||||
}
|
||||
const machineId = await getMachineId();
|
||||
return <HomePageClient machineId={machineId} />;
|
||||
const isBootstrapped = process.env.OMNIROUTE_BOOTSTRAPPED === "true";
|
||||
return (
|
||||
<>
|
||||
{isBootstrapped && <BootstrapBanner />}
|
||||
<HomePageClient machineId={machineId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1341,6 +1341,7 @@ PassthroughModelRow.propTypes = {
|
||||
|
||||
function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
|
||||
const t = useTranslations("providers");
|
||||
const notify = useNotificationStore();
|
||||
const [customModels, setCustomModels] = useState([]);
|
||||
const [newModelId, setNewModelId] = useState("");
|
||||
const [newModelName, setNewModelName] = useState("");
|
||||
@@ -1348,6 +1349,10 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
|
||||
const [newEndpoints, setNewEndpoints] = useState(["chat"]);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingModelId, setEditingModelId] = useState<string | null>(null);
|
||||
const [editingApiFormat, setEditingApiFormat] = useState("chat-completions");
|
||||
const [editingEndpoints, setEditingEndpoints] = useState<string[]>(["chat"]);
|
||||
const [savingModelId, setSavingModelId] = useState<string | null>(null);
|
||||
|
||||
const fetchCustomModels = useCallback(async () => {
|
||||
try {
|
||||
@@ -1410,6 +1415,61 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
|
||||
}
|
||||
};
|
||||
|
||||
const beginEdit = (model) => {
|
||||
setEditingModelId(model.id);
|
||||
setEditingApiFormat(model.apiFormat || "chat-completions");
|
||||
setEditingEndpoints(
|
||||
Array.isArray(model.supportedEndpoints) && model.supportedEndpoints.length
|
||||
? model.supportedEndpoints
|
||||
: ["chat"]
|
||||
);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingModelId(null);
|
||||
setEditingApiFormat("chat-completions");
|
||||
setEditingEndpoints(["chat"]);
|
||||
setSavingModelId(null);
|
||||
};
|
||||
|
||||
const saveEdit = async (modelId) => {
|
||||
if (!editingModelId || editingModelId !== modelId) return;
|
||||
if (!editingEndpoints.length) {
|
||||
notify.error("Select at least one supported endpoint");
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingModelId(modelId);
|
||||
try {
|
||||
const model = customModels.find((m) => m.id === modelId);
|
||||
const res = await fetch("/api/provider-models", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: providerId,
|
||||
modelId,
|
||||
modelName: model?.name || modelId,
|
||||
source: model?.source || "manual",
|
||||
apiFormat: editingApiFormat,
|
||||
supportedEndpoints: editingEndpoints,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to save model endpoint settings");
|
||||
}
|
||||
|
||||
await fetchCustomModels();
|
||||
notify.success("Saved model endpoint settings");
|
||||
cancelEdit();
|
||||
} catch (e) {
|
||||
console.error("Failed to save custom model:", e);
|
||||
notify.error("Failed to save model endpoint settings");
|
||||
} finally {
|
||||
setSavingModelId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6 pt-6 border-t border-border">
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
@@ -1554,14 +1614,82 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editingModelId === model.id && (
|
||||
<div className="mt-3 p-3 rounded-lg border border-border bg-sidebar/40">
|
||||
<div className="flex items-end gap-3 flex-wrap">
|
||||
<div className="w-44">
|
||||
<label className="text-xs text-text-muted mb-1 block">API Format</label>
|
||||
<select
|
||||
value={editingApiFormat}
|
||||
onChange={(e) => setEditingApiFormat(e.target.value)}
|
||||
className="w-full px-2.5 py-2 text-xs border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="chat-completions">Chat Completions</option>
|
||||
<option value="responses">Responses API</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-[240px]">
|
||||
<span className="text-xs text-text-muted mb-1 block">Supported Endpoints</span>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{["chat", "embeddings", "images", "audio"].map((ep) => (
|
||||
<label key={ep} className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editingEndpoints.includes(ep)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setEditingEndpoints((prev) => (prev.includes(ep) ? prev : [...prev, ep]));
|
||||
} else {
|
||||
setEditingEndpoints((prev) => prev.filter((x) => x !== ep));
|
||||
}
|
||||
}}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
{ep === "chat"
|
||||
? "💬 Chat"
|
||||
: ep === "embeddings"
|
||||
? "📐 Embeddings"
|
||||
: ep === "images"
|
||||
? "🖼️ Images"
|
||||
: "🔊 Audio"}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveEdit(model.id)}
|
||||
disabled={savingModelId === model.id}
|
||||
>
|
||||
{savingModelId === model.id ? t("saving") : t("save")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={cancelEdit}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => beginEdit(model)}
|
||||
className="p-1 hover:bg-sidebar rounded text-text-muted hover:text-primary"
|
||||
title={t("edit")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">edit</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemove(model.id)}
|
||||
className="p-1 hover:bg-red-50 rounded text-red-500"
|
||||
title={t("removeCustomModel")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemove(model.id)}
|
||||
className="p-1 hover:bg-red-50 rounded text-red-500"
|
||||
title={t("removeCustomModel")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getAllCustomModels,
|
||||
addCustomModel,
|
||||
removeCustomModel,
|
||||
updateCustomModel,
|
||||
} from "@/lib/localDb";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { providerModelMutationSchema } from "@/shared/validation/schemas";
|
||||
@@ -84,6 +85,59 @@ export async function POST(request) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/provider-models
|
||||
* Body: { provider, modelId, modelName?, apiFormat?, supportedEndpoints? }
|
||||
*/
|
||||
export async function PUT(request) {
|
||||
let rawBody;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: { message: "Invalid JSON body", type: "validation_error" } },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return Response.json(
|
||||
{ error: { message: "Authentication required", type: "invalid_api_key" } },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const validation = validateBody(providerModelMutationSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return Response.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const { provider, modelId, modelName, apiFormat, supportedEndpoints } = validation.data;
|
||||
|
||||
const model = await updateCustomModel(provider, modelId, {
|
||||
modelName,
|
||||
apiFormat,
|
||||
supportedEndpoints,
|
||||
});
|
||||
|
||||
if (!model) {
|
||||
return Response.json(
|
||||
{ error: { message: "Model not found", type: "not_found" } },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json({ model });
|
||||
} catch (error) {
|
||||
console.error("Error updating provider model:", error);
|
||||
return Response.json(
|
||||
{ error: { message: "Failed to update provider model", type: "server_error" } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/provider-models?provider=<id>&model=<modelId>
|
||||
*/
|
||||
|
||||
@@ -46,6 +46,30 @@ export async function register() {
|
||||
startBackgroundRefresh();
|
||||
console.log("[STARTUP] Quota cache background refresh started");
|
||||
|
||||
// Model aliases: restore persisted custom aliases into in-memory state (#316)
|
||||
// Custom aliases are saved to settings.modelAliases on PUT /api/settings/model-aliases
|
||||
// but the in-memory _customAliases resets to {} on every restart — load them here.
|
||||
try {
|
||||
const { getSettings } = await import("@/lib/db/settings");
|
||||
const { setCustomAliases } = await import("@omniroute/open-sse/services/modelDeprecation.ts");
|
||||
const settings = await getSettings();
|
||||
if (settings.modelAliases) {
|
||||
const aliases =
|
||||
typeof settings.modelAliases === "string"
|
||||
? JSON.parse(settings.modelAliases)
|
||||
: settings.modelAliases;
|
||||
if (aliases && typeof aliases === "object") {
|
||||
setCustomAliases(aliases);
|
||||
console.log(
|
||||
`[STARTUP] Restored ${Object.keys(aliases).length} custom model alias(es) from settings`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn("[STARTUP] Could not restore model aliases:", msg);
|
||||
}
|
||||
|
||||
// Compliance: Initialize audit_log table + cleanup expired logs
|
||||
try {
|
||||
const { initAuditLog, cleanupExpiredLogs } = await import("@/lib/compliance/index");
|
||||
|
||||
@@ -177,3 +177,38 @@ export async function removeCustomModel(providerId, modelId) {
|
||||
backupDbFile("pre-write");
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function updateCustomModel(providerId, modelId, updates = {}) {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?")
|
||||
.get(providerId);
|
||||
if (!row) return null;
|
||||
|
||||
const value = getKeyValue(row).value;
|
||||
if (!value) return null;
|
||||
|
||||
const models = JSON.parse(value);
|
||||
const index = models.findIndex((m) => m.id === modelId);
|
||||
if (index === -1) return null;
|
||||
|
||||
const current = models[index];
|
||||
const next = {
|
||||
...current,
|
||||
...(updates.modelName !== undefined ? { name: updates.modelName || current.name } : {}),
|
||||
...(updates.apiFormat !== undefined ? { apiFormat: updates.apiFormat } : {}),
|
||||
...(updates.supportedEndpoints !== undefined
|
||||
? { supportedEndpoints: updates.supportedEndpoints }
|
||||
: {}),
|
||||
};
|
||||
|
||||
models[index] = next;
|
||||
|
||||
db.prepare("UPDATE key_value SET value = ? WHERE namespace = 'customModels' AND key = ?").run(
|
||||
JSON.stringify(models),
|
||||
providerId
|
||||
);
|
||||
|
||||
backupDbFile("pre-write");
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export {
|
||||
getAllCustomModels,
|
||||
addCustomModel,
|
||||
removeCustomModel,
|
||||
updateCustomModel,
|
||||
} from "./db/models";
|
||||
|
||||
export {
|
||||
|
||||
@@ -98,12 +98,12 @@ export default function OAuthModal({
|
||||
GOOGLE_OAUTH_PROVIDERS.has(provider)
|
||||
) {
|
||||
setError(
|
||||
"redirect_uri_mismatch: As credenciais padrão do Google OAuth só funcionam em localhost. " +
|
||||
"Para uso remoto, configure suas próprias credenciais OAuth nas variáveis de ambiente: " +
|
||||
"redirect_uri_mismatch: The default Google OAuth credentials only work on localhost. " +
|
||||
"For remote use, configure your own OAuth credentials via environment variables: " +
|
||||
(provider === "antigravity"
|
||||
? "ANTIGRAVITY_OAUTH_CLIENT_ID e ANTIGRAVITY_OAUTH_CLIENT_SECRET"
|
||||
: "GEMINI_OAUTH_CLIENT_ID e GEMINI_OAUTH_CLIENT_SECRET") +
|
||||
". Veja o README, seção 'OAuth em Servidor Remoto'."
|
||||
? "ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET"
|
||||
: "GEMINI_OAUTH_CLIENT_ID and GEMINI_OAUTH_CLIENT_SECRET") +
|
||||
". See the README section 'OAuth on a Remote Server'."
|
||||
);
|
||||
} else {
|
||||
setError(err.message);
|
||||
@@ -512,17 +512,17 @@ export default function OAuthModal({
|
||||
<span className="material-symbols-outlined text-sm align-middle mr-1">
|
||||
warning
|
||||
</span>
|
||||
<strong>Acesso remoto + Google OAuth:</strong> As credenciais padrão só aceitam
|
||||
redirect para <code>localhost</code>. Após autorizar, o browser tentará abrir
|
||||
<code>localhost</code> — copie essa URL completa e cole abaixo. Para uso
|
||||
totalmente remoto sem esse passo manual,{" "}
|
||||
<strong>Remote access + Google OAuth:</strong> The default credentials only accept
|
||||
redirects to <code>localhost</code>. After authorizing, your browser will try to
|
||||
open <code>localhost</code> — copy that full URL and paste it below. For fully
|
||||
remote use without this manual step,{" "}
|
||||
<a
|
||||
href="https://github.com/diegosouzapw/OmniRoute#oauth-em-servidor-remoto"
|
||||
href="https://github.com/diegosouzapw/OmniRoute#oauth-on-a-remote-server"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
configure suas próprias credenciais OAuth
|
||||
configure your own OAuth credentials
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
|
||||
@@ -227,7 +227,7 @@ async function handleSingleModelChat(
|
||||
const resolved = await resolveModelOrError(modelStr, body);
|
||||
if (resolved.error) return resolved.error;
|
||||
|
||||
const { provider, model, sourceFormat, targetFormat } = resolved;
|
||||
const { provider, model, sourceFormat, targetFormat, extendedContext } = resolved;
|
||||
|
||||
// 2. Pipeline gates (availability + circuit breaker)
|
||||
const gate = checkPipelineGates(provider, model);
|
||||
@@ -290,6 +290,7 @@ async function handleSingleModelChat(
|
||||
apiKeyInfo,
|
||||
userAgent,
|
||||
comboName,
|
||||
extendedContext,
|
||||
});
|
||||
if (telemetry) telemetry.endPhase();
|
||||
|
||||
@@ -366,7 +367,7 @@ async function resolveModelOrError(modelStr: string, body: any) {
|
||||
return { error: errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format") };
|
||||
}
|
||||
|
||||
const { provider, model } = modelInfo;
|
||||
const { provider, model, extendedContext } = modelInfo;
|
||||
const sourceFormat = detectFormat(body);
|
||||
const providerAlias = PROVIDER_ID_TO_ALIAS[provider] || provider;
|
||||
|
||||
@@ -378,13 +379,14 @@ async function resolveModelOrError(modelStr: string, body: any) {
|
||||
log.info("ROUTING", `Custom model apiFormat=responses → targetFormat=openai-responses`);
|
||||
}
|
||||
|
||||
const ctxTag = extendedContext && providerAlias === "claude" ? " [1m]" : "";
|
||||
if (modelStr !== `${provider}/${model}`) {
|
||||
log.info("ROUTING", `${modelStr} → ${provider}/${model}`);
|
||||
log.info("ROUTING", `${modelStr} → ${provider}/${model}${ctxTag}`);
|
||||
} else {
|
||||
log.info("ROUTING", `Provider: ${provider}, Model: ${model}`);
|
||||
log.info("ROUTING", `Provider: ${provider}, Model: ${model}${ctxTag}`);
|
||||
}
|
||||
|
||||
return { provider, model, sourceFormat, targetFormat };
|
||||
return { provider, model, sourceFormat, targetFormat, extendedContext };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -437,6 +439,7 @@ async function executeChatWithBreaker({
|
||||
apiKeyInfo,
|
||||
userAgent,
|
||||
comboName,
|
||||
extendedContext,
|
||||
}: any): Promise<{ result: any; tlsFingerprintUsed: boolean }> {
|
||||
let tlsFingerprintUsed = false;
|
||||
|
||||
@@ -445,7 +448,7 @@ async function executeChatWithBreaker({
|
||||
runWithProxyContext(proxyInfo?.proxy || null, () =>
|
||||
(handleChatCore as any)({
|
||||
body: { ...body, model: `${provider}/${model}` },
|
||||
modelInfo: { provider, model },
|
||||
modelInfo: { provider, model, extendedContext },
|
||||
credentials: refreshedCredentials,
|
||||
log: logger,
|
||||
clientRawRequest,
|
||||
|
||||
@@ -39,6 +39,7 @@ async function lookupCustomModelApiFormat(
|
||||
*/
|
||||
export async function getModelInfo(modelStr) {
|
||||
const parsed = parseModel(modelStr);
|
||||
const { extendedContext } = parsed;
|
||||
|
||||
// Check custom provider nodes first (for both alias and non-alias formats)
|
||||
if (parsed.providerAlias || parsed.provider) {
|
||||
@@ -53,7 +54,12 @@ export async function getModelInfo(modelStr) {
|
||||
matchedOpenAI.id as string,
|
||||
parsed.model as string
|
||||
);
|
||||
return { provider: matchedOpenAI.id, model: parsed.model, ...(apiFormat && { apiFormat }) };
|
||||
return {
|
||||
provider: matchedOpenAI.id,
|
||||
model: parsed.model,
|
||||
extendedContext,
|
||||
...(apiFormat && { apiFormat }),
|
||||
};
|
||||
}
|
||||
|
||||
// Check Anthropic Compatible nodes
|
||||
@@ -67,6 +73,7 @@ export async function getModelInfo(modelStr) {
|
||||
return {
|
||||
provider: matchedAnthropic.id,
|
||||
model: parsed.model,
|
||||
extendedContext,
|
||||
...(apiFormat && { apiFormat }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// [1m] extended context suffix — PR #311 (DavyMassoneto)
|
||||
test("[1m] suffix: strips suffix and sets extendedContext=true", () => {
|
||||
const result = parseModel("claude-sonnet-4-6[1m]");
|
||||
assert.strictEqual(result.model, "claude-sonnet-4-6");
|
||||
assert.strictEqual(result.extendedContext, true);
|
||||
});
|
||||
|
||||
test("[1m] suffix: normal model has extendedContext=false", () => {
|
||||
const result = parseModel("claude-sonnet-4-6");
|
||||
assert.strictEqual(result.model, "claude-sonnet-4-6");
|
||||
assert.strictEqual(result.extendedContext, false);
|
||||
});
|
||||
|
||||
test("[1m] suffix: works with provider prefix", () => {
|
||||
const result = parseModel("claude/claude-sonnet-4-6[1m]");
|
||||
assert.strictEqual(result.model, "claude-sonnet-4-6");
|
||||
assert.strictEqual(result.extendedContext, true);
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import {
|
||||
detectNativeBinaryTarget,
|
||||
isNativeBinaryCompatible,
|
||||
} from "../../scripts/native-binary-compat.mjs";
|
||||
|
||||
function makeElfBinary(machine) {
|
||||
const buffer = Buffer.alloc(64);
|
||||
buffer[0] = 0x7f;
|
||||
buffer[1] = 0x45;
|
||||
buffer[2] = 0x4c;
|
||||
buffer[3] = 0x46;
|
||||
buffer[4] = 2;
|
||||
buffer[5] = 1;
|
||||
buffer.writeUInt16LE(machine, 18);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function makeMachBinary(cpuType) {
|
||||
const buffer = Buffer.alloc(32);
|
||||
buffer.writeUInt32BE(0xcffaedfe, 0);
|
||||
buffer.writeUInt32LE(cpuType, 4);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function makePeBinary(machine) {
|
||||
const buffer = Buffer.alloc(160);
|
||||
buffer[0] = 0x4d;
|
||||
buffer[1] = 0x5a;
|
||||
buffer.writeUInt32LE(0x80, 0x3c);
|
||||
buffer.write("PE\0\0", 0x80, "ascii");
|
||||
buffer.writeUInt16LE(machine, 0x84);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
describe("detectNativeBinaryTarget", () => {
|
||||
it("detects linux x64 ELF binaries", () => {
|
||||
assert.deepEqual(detectNativeBinaryTarget(makeElfBinary(62)), {
|
||||
platform: "linux",
|
||||
architectures: ["x64"],
|
||||
});
|
||||
});
|
||||
|
||||
it("detects darwin arm64 Mach-O binaries", () => {
|
||||
assert.deepEqual(detectNativeBinaryTarget(makeMachBinary(0x0100000c)), {
|
||||
platform: "darwin",
|
||||
architectures: ["arm64"],
|
||||
});
|
||||
});
|
||||
|
||||
it("detects win32 x64 PE binaries", () => {
|
||||
assert.deepEqual(detectNativeBinaryTarget(makePeBinary(0x8664)), {
|
||||
platform: "win32",
|
||||
architectures: ["x64"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNativeBinaryCompatible", () => {
|
||||
function withTempBinary(buffer, callback) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "omniroute-native-"));
|
||||
const file = join(dir, "better_sqlite3.node");
|
||||
writeFileSync(file, buffer);
|
||||
|
||||
try {
|
||||
callback(file);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
it("accepts linux-x64 binaries when the target matches and dlopen succeeds", () => {
|
||||
withTempBinary(makeElfBinary(62), (binaryPath) => {
|
||||
assert.equal(
|
||||
isNativeBinaryCompatible(binaryPath, {
|
||||
runtimePlatform: "linux",
|
||||
runtimeArch: "x64",
|
||||
dlopen() {},
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects linux-x64 binaries when dlopen fails on the same platform", () => {
|
||||
withTempBinary(makeElfBinary(62), (binaryPath) => {
|
||||
assert.equal(
|
||||
isNativeBinaryCompatible(binaryPath, {
|
||||
runtimePlatform: "linux",
|
||||
runtimeArch: "x64",
|
||||
dlopen() {
|
||||
throw new Error("abi mismatch");
|
||||
},
|
||||
}),
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects macOS false positives for bundled linux binaries", () => {
|
||||
withTempBinary(makeElfBinary(62), (binaryPath) => {
|
||||
assert.equal(
|
||||
isNativeBinaryCompatible(binaryPath, {
|
||||
runtimePlatform: "darwin",
|
||||
runtimeArch: "arm64",
|
||||
dlopen() {},
|
||||
}),
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects Windows false positives for bundled linux binaries", () => {
|
||||
withTempBinary(makeElfBinary(62), (binaryPath) => {
|
||||
assert.equal(
|
||||
isNativeBinaryCompatible(binaryPath, {
|
||||
runtimePlatform: "win32",
|
||||
runtimeArch: "x64",
|
||||
dlopen() {},
|
||||
}),
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts copied darwin binaries after postinstall replacement", () => {
|
||||
withTempBinary(makeMachBinary(0x0100000c), (binaryPath) => {
|
||||
assert.equal(
|
||||
isNativeBinaryCompatible(binaryPath, {
|
||||
runtimePlatform: "darwin",
|
||||
runtimeArch: "arm64",
|
||||
dlopen() {},
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user