Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e9a9adbad | |||
| d87c7c3b8c | |||
| eb3c834609 | |||
| e53c76081f | |||
| 134316328c | |||
| 4767561f02 | |||
| 2d6b31b606 | |||
| a22f0a4e7b | |||
| 5a244aa12a | |||
| 69d28bec4d | |||
| c859665c6b | |||
| e7b19758f3 | |||
| 623c63baf6 | |||
| a3ad7c6c2e | |||
| afc9362ca5 | |||
| f6b125e8c2 | |||
| 5df3c22be8 | |||
| 11a0df5443 |
@@ -11,6 +11,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
+12
-21
@@ -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);
|
||||
@@ -194,9 +195,9 @@ if (!existsSync(serverJs)) {
|
||||
}
|
||||
|
||||
// ── Pre-flight: verify better-sqlite3 native binary ───────
|
||||
// The published binary targets linux-x64. Check both platform match AND
|
||||
// dlopen — on macOS, dlopen alone may succeed on an incompatible binary
|
||||
// (false positive), so we check platform first as the primary signal.
|
||||
// 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",
|
||||
@@ -205,25 +206,15 @@ const sqliteBinary = join(
|
||||
"Release",
|
||||
"better_sqlite3.node"
|
||||
);
|
||||
if (existsSync(sqliteBinary)) {
|
||||
let compatible = false;
|
||||
try {
|
||||
process.dlopen({ exports: {} }, sqliteBinary);
|
||||
compatible = true;
|
||||
} catch {
|
||||
// dlopen failed — definitely incompatible
|
||||
}
|
||||
|
||||
if (!compatible) {
|
||||
console.error(
|
||||
"\x1b[31m✖ better-sqlite3 native module is incompatible with this platform.\x1b[0m"
|
||||
);
|
||||
console.error(` Run: cd ${APP_DIR} && npm rebuild better-sqlite3`);
|
||||
if (platform() === "darwin") {
|
||||
console.error(" If build tools are missing: xcode-select --install");
|
||||
}
|
||||
process.exit(1);
|
||||
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 ───────────────────────────────────────────
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 2.3.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,
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -69,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 ──
|
||||
@@ -276,6 +276,7 @@ export async function handleChatCore({
|
||||
credentials,
|
||||
signal: streamController.signal,
|
||||
log,
|
||||
extendedContext,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -363,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -918,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
|
||||
@@ -936,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",
|
||||
@@ -945,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",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.3.0",
|
||||
"version": "2.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "2.3.0",
|
||||
"version": "2.3.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.3.0",
|
||||
"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"
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+78
-44
@@ -1,81 +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;
|
||||
|
||||
// The published binary is compiled for linux-x64.
|
||||
// On any other platform/arch, we must rebuild — dlopen alone is unreliable
|
||||
// because macOS may load an incompatible binary without throwing.
|
||||
const BUILD_PLATFORM = "linux";
|
||||
const BUILD_ARCH = "x64";
|
||||
const needsRebuild = process.platform !== BUILD_PLATFORM || process.arch !== BUILD_ARCH;
|
||||
|
||||
if (!needsRebuild) {
|
||||
if (platformMatch) {
|
||||
try {
|
||||
process.dlopen({ exports: {} }, buildInfoPath);
|
||||
process.dlopen({ exports: {} }, appBinary);
|
||||
process.exit(0);
|
||||
} catch {
|
||||
// Same platform but binary still incompatible (e.g. Node.js ABI mismatch) — rebuild
|
||||
} 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,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(" ❌ Failed to rebuild better-sqlite3 automatically.");
|
||||
console.error(" You can fix this manually by running:");
|
||||
console.error(` cd ${join(ROOT, "app")} && npm rebuild better-sqlite3`);
|
||||
if (process.platform === "darwin") {
|
||||
console.error(" If build tools are missing: xcode-select --install");
|
||||
|
||||
process.dlopen({ exports: {} }, appBinary);
|
||||
console.log(" ✅ Native module rebuilt successfully!\n");
|
||||
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}`);
|
||||
}
|
||||
console.error("");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Verify the rebuilt binary actually loads
|
||||
try {
|
||||
process.dlopen({ exports: {} }, buildInfoPath);
|
||||
console.log(" ✅ Native module rebuilt successfully!\n");
|
||||
} catch {
|
||||
console.error(" ❌ Rebuild completed but binary is still incompatible.");
|
||||
console.error(" Try manually:");
|
||||
console.error(` cd ${join(ROOT, "app")} && npm rebuild better-sqlite3`);
|
||||
if (process.platform === "darwin") {
|
||||
console.error(" If build tools are missing: xcode-select --install");
|
||||
}
|
||||
console.error("");
|
||||
process.exit(1);
|
||||
// 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("");
|
||||
|
||||
@@ -52,7 +52,7 @@ export async function register() {
|
||||
try {
|
||||
const { getSettings } = await import("@/lib/db/settings");
|
||||
const { setCustomAliases } = await import("@omniroute/open-sse/services/modelDeprecation.ts");
|
||||
const settings = getSettings();
|
||||
const settings = await getSettings();
|
||||
if (settings.modelAliases) {
|
||||
const aliases =
|
||||
typeof settings.modelAliases === "string"
|
||||
|
||||
@@ -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