Compare commits

...

20 Commits

Author SHA1 Message Date
diegosouzapw 8956ffef73 chore: release v2.3.4
Build Electron Desktop App / Validate version (push) Failing after 32s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
2026-03-12 10:27:45 -03:00
diegosouzapw 4383e7d807 feat(ui): endpoint page music section, fixed action buttons, provider logos
Endpoints page:
- Add Music Generation section (/v1/music/generations) in Media & Multi-Modal category
- Include music models (type=music) in endpointData and total model count
- Transcription section already shows Deepgram/AssemblyAI via allModels filter

Provider action buttons:
- Remove hover-only behavior from connection action buttons (edit/delete/reauth/proxy)
- Remove hover-only behavior from combo action buttons (test/duplicate/proxy/edit/delete)
- Buttons now always visible for better UX

Provider logos (SVG fallback):
- ProviderCard now tries .svg before showing text initials when .png not found
- Add SVG logos: ElevenLabs, Hyperbolic, AssemblyAI, PlayHT, Inworld, NanoBanana
- Add ollama-cloud.png (official Ollama icon)
2026-03-12 10:21:05 -03:00
diegosouzapw 863055768e fix(docker): copy native-binary-compat.mjs into build image
postinstall.mjs imports native-binary-compat.mjs but the Dockerfile
only copied postinstall.mjs, causing ERR_MODULE_NOT_FOUND during npm ci:

  Cannot find module '/app/scripts/native-binary-compat.mjs'
  imported from /app/scripts/postinstall.mjs
2026-03-12 10:11:50 -03:00
diegosouzapw 2c1da9e146 fix(ci): resolve 3 GitHub Actions workflow failures
- docs/openapi.yaml: bump version 2.3.1 → 2.3.3 (fixes check:docs-sync CI step)
- tests/unit/model-parse.test.mjs: add missing 'import {test}' from node:test (fixes ReferenceError in unit tests)
- electron/package.json: convert author to object with email (fixes fpm .deb build: 'Please specify author email')
2026-03-12 10:10:45 -03:00
diegosouzapw 845787ab7f chore(release): v2.3.3
Build Electron Desktop App / Validate version (push) Failing after 37s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
fix(providers): prevent error boundary crash when Test All fails or times out (PR #330)
2026-03-12 09:56:51 -03:00
Diego Rodrigues de Sa e Souza 1db948e9bb Merge pull request #330 from diegosouzapw/fix/providers-test-all-crash
fix(providers): prevent error boundary crash when Test All fails or times out
2026-03-12 09:56:25 -03:00
diegosouzapw f0d00bcee5 fix(providers): prevent error boundary when 'Test All' times out or returns bad JSON
- Add AbortController (90s timeout) to handleBatchTest fetch
- Add inner try/catch for res.json() — handles truncated/non-JSON responses
- Guard ProviderTestResultsView against null/undefined results (was crashing → error boundary)
- Improve error check: error path now also guards results.results.length === 0
- Add 'providerTestTimeout' i18n key for friendly timeout message
2026-03-12 09:38:40 -03:00
diegosouzapw 1e9a9adbad chore(release): v2.3.2
Build Electron Desktop App / Validate version (push) Failing after 38s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
feat(claude): [1m] suffix for 1M extended context (PR #311 @DavyMassoneto)
feat(registry): new models for iFlow, Qwen, Kimi (PR #326 @nyatoru)
fix(cli): postinstall binary copy instead of rebuild (PR #327 @ardaaltinors, fixes #321)
docs: English Remote OAuth guide in README (PR #329, fixes #318)
test: 3 unit tests for parseModel [1m] suffix
2026-03-12 07:00:10 -03:00
Diego Rodrigues de Sa e Souza d87c7c3b8c Merge pull request #311 from DavyMassoneto/fix/merge-duplicates-and-lint-warnings
feat(claude): support [1m] suffix for 1M extended context window
2026-03-12 06:58:57 -03:00
Diego Rodrigues de Sa e Souza eb3c834609 Merge pull request #326 from nyatoru/update/sync-qwen-iflow-model
feat(registry): add new models to the provider registry
2026-03-12 06:58:12 -03:00
Diego Rodrigues de Sa e Souza e53c76081f Merge pull request #327 from ardaaltinors/fix/postinstall-copy-native-binary
fix(cli): fix postinstall native binary rebuild regression (#321)
2026-03-12 06:58:10 -03:00
Diego Rodrigues de Sa e Souza 134316328c Merge pull request #329 from diegosouzapw/fix/issue-318-readme-oauth-en
docs: add English Remote OAuth guide to README (#318)
2026-03-12 06:58:07 -03:00
diegosouzapw 4767561f02 docs: add English translation for Remote OAuth section in README (#318)
The '🔐 OAuth on a Remote Server' guide existed only in Portuguese (#oauth-em-servidor-remoto).
Multiple users (@hijak, @ldsgroups225, @vipinpg) couldn't find it in English.

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

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

New approach:
1. Check if existing binary is already compatible (dlopen)
2. Copy the correctly-built binary from root node_modules/ (npm already
   compiles it for the correct platform during install)
3. Fall back to npm rebuild if root binary is unavailable
4. Warn but don't fail the install if nothing works — the package stays
   installed and the CLI pre-flight check gives a clear error at startup
2026-03-12 10:07:43 +03:00
nyatoru e7b19758f3 feat(registry): add new models to the provider registry 2026-03-12 11:18:16 +08:00
DavyMassoneto 623c63baf6 feat(claude): support [1m] suffix for 1M context window
Parse [1m] suffix from model name (e.g. claude-sonnet-4-6[1m]) and
propagate extendedContext flag through the request pipeline to append
context-1m-2025-08-07 to the Anthropic-Beta header.
2026-03-11 23:53:09 -03:00
30 changed files with 1208 additions and 116 deletions
+66
View File
@@ -11,6 +11,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [2.3.4] — 2026-03-12
> ### UI Polish — Endpoint Music Section, Always-Visible Buttons & Provider Logos
### ✨ Improvements
- **Endpoint page: Music Generation section** — `/v1/music/generations` now appears in the "Media & Multi-Modal" category alongside Image, Audio Transcription, and Text-to-Speech. Music models (ComfyUI Stable Audio, MusicGen) show up in the endpoint's model count.
- **Provider connections: action buttons always visible** — Edit, Delete, Proxy Config, and Reauthenticate buttons on the provider connection rows are no longer hidden until hover. Improves discoverability on touch and keyboard-only navigation.
- **Combos: action buttons always visible** — Test, Duplicate, Proxy Config, Edit, and Delete buttons on combo cards are no longer hidden until hover on desktop screens.
- **Provider logos: SVG fallback chain** — `ProviderCard` and `ApiKeyProviderCard` now try `.png``.svg` → text initials, enabling proper logo rendering for providers without a PNG.
### 🎨 New Provider Logos
| Provider | Logo | Brand Colors |
| ------------ | ------------------ | -------------------------------------- |
| ElevenLabs | `elevenlabs.svg` | `#6C47FF` purple, double-bar "11" mark |
| Hyperbolic | `hyperbolic.svg` | dark + cyan→purple gradient "H" |
| AssemblyAI | `assemblyai.svg` | `#0062FF` blue waveform |
| PlayHT | `playht.svg` | dark + gradient play triangle |
| Inworld | `inworld.svg` | dark + `#5B4FFF`→cyan "i" |
| NanoBanana | `nanobanana.svg` | dark + yellow banana icon |
| Ollama Cloud | `ollama-cloud.png` | Official Ollama logo |
### 🔧 CI Fixes (included in commit `8630557`)
- **docs-sync** — Updated `docs/openapi.yaml` version to `2.3.3`
- **Unit tests** — Added missing `import { test } from 'node:test'` in `model-parse.test.mjs`
- **Electron `.deb`** — `electron/package.json` `author` changed from string to object with `email` field (required by `fpm`)
- **Docker build** — Added `COPY scripts/native-binary-compat.mjs` to `Dockerfile` (fixes `ERR_MODULE_NOT_FOUND` during `npm ci`)
---
## [2.3.3] — 2026-03-12
> ### Providers Test All Fix
### 🐛 Bug Fixes
- **Providers page crash when clicking 'Test All'** — Clicking 'Testar Todos' could trigger the error boundary ('Failed to load providers') when the batch test timed out or returned a non-JSON response. Fixed with:
- `AbortController` (90s timeout) on the `handleBatchTest` fetch
- Inner `try/catch` for `res.json()` — truncated/non-JSON responses no longer crash the component
- Null/type guard in `ProviderTestResultsView` — malformed results can no longer trigger a render exception
- New i18n key `providerTestTimeout` for friendly timeout message (PR #330)
---
## [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
+1
View File
@@ -3,6 +3,7 @@ WORKDIR /app
COPY package*.json ./
COPY scripts/postinstall.mjs ./scripts/postinstall.mjs
COPY scripts/native-binary-compat.mjs ./scripts/native-binary-compat.mjs
RUN if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; else npm install --no-audit --no-fund; fi
COPY . ./
+93 -2
View File
@@ -1508,11 +1508,102 @@ opencode
- OmniRoute v1.0.6+ includes fallback validation via chat completions
- Ensure base URL includes `/v1` suffix
### 🔐 OAuth em Servidor Remoto (Remote OAuth Setup)
### 🔐 OAuth on a Remote Server
<a name="oauth-on-a-remote-server"></a>
<a name="oauth-em-servidor-remoto"></a>
> **⚠️ IMPORTANTE para usuários com OmniRoute em VPS/Docker/servidor remoto**
> **⚠️ Important for users running OmniRoute on a VPS, Docker, or any remote server**
#### Why does Antigravity / Gemini CLI OAuth fail on remote servers?
The **Antigravity** and **Gemini CLI** providers use **Google OAuth 2.0**. Google requires the `redirect_uri` in the OAuth flow to exactly match one of the pre-registered URIs in the app's Google Cloud Console.
The OAuth credentials bundled in OmniRoute are registered **for `localhost` only**. When you access OmniRoute on a remote server (e.g. `https://omniroute.myserver.com`), Google rejects the authentication with:
```
Error 400: redirect_uri_mismatch
```
#### Solution: Configure your own OAuth credentials
You need to create an **OAuth 2.0 Client ID** in Google Cloud Console with your server's URI.
#### Step-by-step
**1. Open Google Cloud Console**
Go to: [https://console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials)
**2. Create a new OAuth 2.0 Client ID**
- Click **"+ Create Credentials"** → **"OAuth client ID"**
- Application type: **"Web application"**
- Name: anything you like (e.g. `OmniRoute Remote`)
**3. Add Authorized Redirect URIs**
In the **"Authorized redirect URIs"** field, add:
```
https://your-server.com/callback
```
> Replace `your-server.com` with your server's domain or IP (include the port if needed, e.g. `http://45.33.32.156:20128/callback`).
**4. Save and copy the credentials**
After creating, Google will show the **Client ID** and **Client Secret**.
**5. Set environment variables**
In your `.env` (or Docker environment variables):
```bash
# For Antigravity:
ANTIGRAVITY_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
ANTIGRAVITY_OAUTH_CLIENT_SECRET=GOCSPX-your-secret
# For Gemini CLI:
GEMINI_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
GEMINI_OAUTH_CLIENT_SECRET=GOCSPX-your-secret
GEMINI_CLI_OAUTH_CLIENT_SECRET=GOCSPX-your-secret
```
**6. Restart OmniRoute**
```bash
# npm:
npm run dev
# Docker:
docker restart omniroute
```
**7. Try connecting again**
Dashboard → Providers → Antigravity (or Gemini CLI) → OAuth
Google will now redirect correctly to `https://your-server.com/callback`.
---
#### Temporary workaround (without custom credentials)
If you don't want to set up your own credentials right now, you can still use the **manual URL flow**:
1. OmniRoute opens the Google authorization URL
2. After authorizing, Google tries to redirect to `localhost` (which fails on the remote server)
3. **Copy the full URL** from your browser's address bar (even if the page doesn't load)
4. Paste that URL into the field shown in the OmniRoute connection modal
5. Click **"Connect"**
> This works because the authorization code in the URL is valid regardless of whether the redirect page loaded.
---
<details>
<summary><b>🇧🇷 Versão em Português</b></summary>
#### Por que o OAuth do Antigravity / Gemini CLI falha em servidores remotos?
+12 -21
View File
@@ -17,6 +17,7 @@ import { existsSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { homedir, platform } from "node:os";
import { isNativeBinaryCompatible } from "../scripts/native-binary-compat.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -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
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 2.3.1
version: 2.3.4
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,
+4 -1
View File
@@ -3,7 +3,10 @@
"version": "2.0.13",
"description": "OmniRoute Desktop Application",
"main": "main.js",
"author": "OmniRoute Team",
"author": {
"name": "OmniRoute Team",
"email": "support@omniroute.online"
},
"license": "MIT",
"homepage": "https://omniroute.online",
"scripts": {
+13 -6
View File
@@ -225,6 +225,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "qwen3-coder-flash", name: "Qwen3 Coder Flash" },
{ id: "vision-model", name: "Qwen3 Vision Model" },
{ id: "coder-model", name: "Qwen3.5 (Coder Model)" },
],
},
@@ -248,15 +249,20 @@ export const REGISTRY: Record<string, RegistryEntry> = {
authUrl: "https://iflow.cn/oauth",
},
models: [
{ id: "iflow-rome-30ba3b", name: "iFlow ROME" },
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "qwen3-max", name: "Qwen3 Max" },
{ id: "qwen3-vl-plus", name: "Qwen3 Vision Plus" },
{ id: "kimi-k2-0905", name: "Kimi K2 0905" },
{ id: "qwen3-max-preview", name: "Qwen3 Max Preview" },
{ id: "kimi-k2", name: "Kimi K2" },
{ id: "kimi-k2-thinking", name: "Kimi K2 Thinking" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "deepseek-v3.2", name: "DeepSeek-V3.2-Exp" },
{ id: "deepseek-r1", name: "DeepSeek R1" },
{ id: "deepseek-v3.2-chat", name: "DeepSeek V3.2 Chat" },
{ id: "deepseek-v3.2-reasoner", name: "DeepSeek V3.2 Reasoner" },
{ id: "minimax-m2.1", name: "MiniMax M2.1" },
{ id: "glm-4.7", name: "GLM 4.7" },
{ id: "deepseek-v3", name: "DeepSeek V3" },
{ id: "qwen3-32b", name: "Qwen3 32B" },
{ id: "qwen3-235b-a22b-thinking-2507", name: "Qwen3 235B A22B Thinking 2507" },
{ id: "qwen3-235b-a22b-instruct", name: "Qwen3 235B A22B Instruct" },
{ id: "qwen3-235b", name: "Qwen3 235B" },
],
},
@@ -486,6 +492,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" },
{ id: "kimi-latest", name: "Kimi Latest" },
{ id: "kimi-for-coding", name: "Kimi For Coding" },
],
},
+25 -1
View File
@@ -40,6 +40,7 @@ export type ExecuteInput = {
credentials: ProviderCredentials;
signal?: AbortSignal | null;
log?: ExecutorLog | null;
extendedContext?: boolean;
};
function mergeAbortSignals(primary: AbortSignal, secondary: AbortSignal): AbortSignal {
@@ -174,7 +175,7 @@ export class BaseExecutor {
return { status: response.status, message: bodyText || `HTTP ${response.status}` };
}
async execute({ model, body, stream, credentials, signal, log }: ExecuteInput) {
async execute({ model, body, stream, credentials, signal, log, extendedContext }: ExecuteInput) {
const fallbackCount = this.getFallbackCount();
let lastError: unknown = null;
let lastStatus = 0;
@@ -182,6 +183,29 @@ export class BaseExecutor {
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
const url = this.buildUrl(model, stream, urlIndex, credentials);
const headers = this.buildHeaders(credentials, stream);
// Append 1M context beta header when [1m] suffix was used
// Only supported for specific Claude models per Anthropic docs
if (extendedContext) {
const EXTENDED_CONTEXT_MODELS = [
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-sonnet-4-5",
"claude-sonnet-4",
];
const baseModel = model.replace(/-\d{8}$/, "");
if (
EXTENDED_CONTEXT_MODELS.some((m) => baseModel === m || model === m || model.startsWith(m))
) {
const existing = headers["Anthropic-Beta"];
if (existing) {
headers["Anthropic-Beta"] = existing + ",context-1m-2025-08-07";
} else {
headers["Anthropic-Beta"] = "context-1m-2025-08-07";
}
}
}
const transformedBody = this.transformRequest(model, body, stream, credentials);
try {
+3 -1
View File
@@ -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) {
+35 -9
View File
@@ -59,29 +59,50 @@ function resolveProviderModelAlias(providerOrAlias, modelId) {
/**
* Parse model string: "alias/model" or "provider/model" or just alias
* Supports [1m] suffix for extended 1M context window (e.g. "claude-sonnet-4-6[1m]")
*/
export function parseModel(modelStr) {
if (!modelStr) {
return { provider: null, model: null, isAlias: false, providerAlias: null };
return {
provider: null,
model: null,
isAlias: false,
providerAlias: null,
extendedContext: false,
};
}
// Sanitize: reject strings with path traversal or control characters
if (/\.\.[\/\\]/.test(modelStr) || /[\x00-\x1f]/.test(modelStr)) {
console.log(`[MODEL] Warning: rejected malformed model string: "${modelStr.substring(0, 50)}"`);
return { provider: null, model: null, isAlias: false, providerAlias: null };
return {
provider: null,
model: null,
isAlias: false,
providerAlias: null,
extendedContext: false,
};
}
// Extract [1m] suffix before parsing provider/model
let extendedContext = false;
let cleanStr = modelStr;
if (cleanStr.endsWith("[1m]")) {
extendedContext = true;
cleanStr = cleanStr.slice(0, -4);
}
// Check if standard format: provider/model or alias/model
if (modelStr.includes("/")) {
const firstSlash = modelStr.indexOf("/");
const providerOrAlias = modelStr.slice(0, firstSlash);
const model = modelStr.slice(firstSlash + 1);
if (cleanStr.includes("/")) {
const firstSlash = cleanStr.indexOf("/");
const providerOrAlias = cleanStr.slice(0, firstSlash);
const model = cleanStr.slice(firstSlash + 1);
const provider = resolveProviderAlias(providerOrAlias);
return { provider, model, isAlias: false, providerAlias: providerOrAlias };
return { provider, model, isAlias: false, providerAlias: providerOrAlias, extendedContext };
}
// Alias format (model alias, not provider alias)
return { provider: null, model: modelStr, isAlias: true, providerAlias: null };
return { provider: null, model: cleanStr, isAlias: true, providerAlias: null, extendedContext };
}
/**
@@ -123,12 +144,14 @@ export function resolveModelAliasFromMap(alias, aliases) {
*/
export async function getModelInfoCore(modelStr, aliasesOrGetter) {
const parsed = parseModel(modelStr);
const { extendedContext } = parsed;
if (!parsed.isAlias) {
const canonicalModel = resolveProviderModelAlias(parsed.provider, parsed.model);
return {
provider: parsed.provider,
model: canonicalModel,
extendedContext,
};
}
@@ -142,6 +165,7 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
return {
provider: resolved.provider,
model: canonicalModel,
extendedContext,
};
}
@@ -153,6 +177,7 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
return {
provider: "openai",
model: modelId,
extendedContext,
};
}
@@ -160,7 +185,7 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
if (nonOpenAIProviders.length === 1) {
const provider = nonOpenAIProviders[0];
const canonicalModel = resolveProviderModelAlias(provider, modelId);
return { provider, model: canonicalModel };
return { provider, model: canonicalModel, extendedContext };
}
if (nonOpenAIProviders.length > 1) {
@@ -182,5 +207,6 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
return {
provider: "openai",
model: modelId,
extendedContext,
};
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "2.3.1",
"version": "2.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "2.3.1",
"version": "2.3.3",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "2.3.1",
"version": "2.3.4",
"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"
],
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="18" fill="#0062FF"/>
<!-- AssemblyAI — waveform/microphone mark -->
<rect x="47" y="18" width="6" height="30" rx="3" fill="white"/>
<rect x="35" y="26" width="6" height="22" rx="3" fill="white" opacity="0.8"/>
<rect x="59" y="26" width="6" height="22" rx="3" fill="white" opacity="0.8"/>
<rect x="23" y="34" width="6" height="14" rx="3" fill="white" opacity="0.5"/>
<rect x="71" y="34" width="6" height="14" rx="3" fill="white" opacity="0.5"/>
<!-- Bottom line -->
<rect x="30" y="62" width="40" height="4" rx="2" fill="white" opacity="0.7"/>
<rect x="45" y="66" width="10" height="14" rx="2" fill="white" opacity="0.7"/>
</svg>

After

Width:  |  Height:  |  Size: 749 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="18" fill="#6C47FF"/>
<!-- ElevenLabs "11" logo mark — two vertical bars -->
<rect x="24" y="20" width="20" height="60" rx="4" fill="white"/>
<rect x="56" y="20" width="20" height="60" rx="4" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 321 B

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="18" fill="#141414"/>
<!-- Hyperbolic — stylized "H" with gradient accent -->
<defs>
<linearGradient id="hg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#00D4FF"/>
<stop offset="100%" stop-color="#7B2FFF"/>
</linearGradient>
</defs>
<rect x="22" y="20" width="14" height="60" rx="3" fill="url(#hg)"/>
<rect x="22" y="41" width="56" height="14" rx="3" fill="url(#hg)"/>
<rect x="64" y="20" width="14" height="60" rx="3" fill="url(#hg)"/>
</svg>

After

Width:  |  Height:  |  Size: 600 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="18" fill="#0A0A1A"/>
<defs>
<linearGradient id="ig" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#5B4FFF"/>
<stop offset="100%" stop-color="#00E5FF"/>
</linearGradient>
</defs>
<!-- Inworld "i" with dot - futuristic -->
<circle cx="50" cy="28" r="8" fill="url(#ig)"/>
<rect x="42" y="42" width="16" height="38" rx="5" fill="url(#ig)"/>
</svg>

After

Width:  |  Height:  |  Size: 495 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="18" fill="#1C1A00"/>
<!-- NanoBanana - banana icon stylized -->
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FFE000"/>
<stop offset="100%" stop-color="#FF9500"/>
</linearGradient>
</defs>
<path d="M 35 75 Q 20 40 40 20 Q 55 10 70 18 Q 60 22 52 30 Q 38 45 42 65 Z" fill="url(#bg)"/>
<path d="M 42 65 Q 38 45 52 30 Q 60 22 70 18 Q 75 28 72 38 Q 68 55 55 65 Z" fill="#FFD700"/>
</svg>

After

Width:  |  Height:  |  Size: 566 B

+375
View File
@@ -0,0 +1,375 @@
<!doctype html>
<html class="h-full overflow-y-scroll">
<head>
<title>Ollama</title>
<meta charset="utf-8" />
<meta name="description" content="Get up and running with large language models."/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta property="og:title" content="Ollama" />
<meta property="og:description" content="Get up and running with large language models." />
<meta property="og:url" content="https://ollama.com" />
<meta property="og:image" content="https://ollama.com/public/og.png" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="628" />
<meta property="og:type" content="website" />
<meta name="robots" content="index, follow" />
<meta property="twitter:card" content="summary" />
<meta property="twitter:title" content="Ollama" />
<meta property="twitter:description" content="Get up and running with large language models." />
<meta property="twitter:site" content="ollama" />
<meta property="twitter:image:src" content="https://ollama.com/public/og-twitter.png" />
<meta property="twitter:image:width" content="1200" />
<meta property="twitter:image:height" content="628" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link rel="icon" type="image/png" sizes="16x16" href="/public/icon-16x16.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/public/icon-32x32.png" />
<link rel="icon" type="image/png" sizes="48x48" href="/public/icon-48x48.png" />
<link rel="icon" type="image/png" sizes="64x64" href="/public/icon-64x64.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/public/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/public/android-chrome-icon-192x192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/public/android-chrome-icon-512x512.png" />
<link href="/public/tailwind.css?v=9f0babb28a8cef23daf033b8840da7f9" rel="stylesheet" />
<link href="/public/vendor/prism/prism.css?v=9f0babb28a8cef23daf033b8840da7f9" rel="stylesheet" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Ollama",
"url": "https://ollama.com"
}
</script>
<script type="text/javascript">
function copyToClipboard(element) {
let commandElement = null;
const preElement = element.closest('pre');
const languageNoneElement = element.closest('.language-none');
if (preElement) {
commandElement = preElement.querySelector('code');
} else if (languageNoneElement) {
commandElement = languageNoneElement.querySelector('.command');
} else {
const parent = element.parentElement;
if (parent) {
commandElement = parent.querySelector('.command');
}
}
if (!commandElement) {
console.error('No code or command element found');
return;
}
const code = commandElement.textContent ? commandElement.textContent.trim() : commandElement.value;
navigator.clipboard
.writeText(code)
.then(() => {
const copyIcon = element.querySelector('.copy-icon')
const checkIcon = element.querySelector('.check-icon')
copyIcon.classList.add('hidden')
checkIcon.classList.remove('hidden')
setTimeout(() => {
copyIcon.classList.remove('hidden')
checkIcon.classList.add('hidden')
}, 2000)
})
}
</script>
<script>
function getIcon(url) {
url = url.toLowerCase();
if (url.includes('x.com') || url.includes('twitter.com')) return 'x';
if (url.includes('github.com')) return 'github';
if (url.includes('linkedin.com')) return 'linkedin';
if (url.includes('youtube.com')) return 'youtube';
if (url.includes('hf.co') || url.includes('huggingface.co') || url.includes('huggingface.com')) return 'hugging-face';
return 'default';
}
function setInputIcon(input) {
const icon = getIcon(input.value);
const img = input.previousElementSibling.querySelector('img');
img.src = `/public/social/${icon}.svg`;
img.alt = `${icon} icon`;
}
function setDisplayIcon(imgElement, url) {
const icon = getIcon(url);
imgElement.src = `/public/social/${icon}.svg`;
imgElement.alt = `${icon} icon`;
}
</script>
<script src="/public/vendor/htmx/bundle.js"></script>
</head>
<body
class="
antialiased
min-h-screen
w-full
m-0
flex
flex-col
"
hx-on:keydown="
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
// Ignore key events in input fields.
return;
}
if ((event.metaKey && event.key === 'k') || event.key === '/') {
event.preventDefault();
const sp = htmx.find('#search') || htmx.find('#navbar-input');
sp.focus();
}
"
>
<header class="sticky top-0 z-40 bg-white underline-offset-4 lg:static">
<nav class="flex w-full items-center justify-between px-6 py-[9px]">
<a href="/" class="z-50">
<img src="/public/ollama.png" class="w-8" alt="Ollama" />
</a>
<div class="hidden lg:flex xl:flex-1 items-center space-x-6 ml-6 mr-6 xl:mr-0 text-lg">
<a class="hover:underline focus:underline focus:outline-none focus:ring-0" href="/search">Models</a>
<a class="hover:underline focus:underline focus:outline-none focus:ring-0" href="/docs">Docs</a>
<a class="hover:underline focus:underline focus:outline-none focus:ring-0" href="/pricing">Pricing</a>
</div>
<div class="flex-grow justify-center items-center hidden lg:flex">
<div class="relative w-full xl:max-w-[28rem]">
<form action="/search" autocomplete="off">
<div
class="relative flex w-full appearance-none bg-black/5 border border-neutral-100 items-center rounded-full"
hx-on:focusout="
if (!this.contains(event.relatedTarget)) {
const searchPreview = document.querySelector('#searchpreview');
if (searchPreview) {
htmx.addClass('#searchpreview', 'hidden');
}
}
"
>
<span id="searchIcon" class="pl-2 text-2xl text-neutral-500">
<svg class="mt-0.25 ml-1.5 h-5 w-5 fill-current" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path d="m8.5 3c3.0375661 0 5.5 2.46243388 5.5 5.5 0 1.24832096-.4158777 2.3995085-1.1166416 3.3225711l4.1469717 4.1470988c.2928932.2928932.2928932.767767 0 1.0606602-.2662666.2662665-.6829303.2904726-.9765418.0726181l-.0841184-.0726181-4.1470988-4.1469717c-.9230626.7007639-2.07425014 1.1166416-3.3225711 1.1166416-3.03756612 0-5.5-2.4624339-5.5-5.5 0-3.03756612 2.46243388-5.5 5.5-5.5zm0 1.5c-2.209139 0-4 1.790861-4 4s1.790861 4 4 4 4-1.790861 4-4-1.790861-4-4-4z" />
</svg>
</span>
<input
id="search"
hx-get="/search"
hx-trigger="keyup changed delay:100ms, focus"
hx-target="#searchpreview"
hx-swap="innerHTML"
name="q"
class="resize-none rounded-full border-0 py-2.5 bg-transparent text-sm w-full placeholder:text-neutral-500 focus:outline-none focus:ring-0"
placeholder="Search models"
autocomplete="off"
hx-on:keydown="
if (event.key === 'Enter') {
event.preventDefault();
window.location.href = '/search?q=' + encodeURIComponent(this.value);
return;
}
if (event.key === 'Escape') {
event.preventDefault();
this.value = '';
this.blur();
htmx.addClass('#searchpreview', 'hidden');
return;
}
if (event.key === 'Tab') {
htmx.addClass('#searchpreview', 'hidden');
return;
}
if (event.key === 'ArrowDown') {
let first = document.querySelector('#search-preview-list a:first-of-type');
first?.focus();
event.preventDefault();
}
if (event.key === 'ArrowUp') {
let last = document.querySelector('#view-all-link');
last?.focus();
event.preventDefault();
}
htmx.removeClass('#searchpreview', 'hidden');
"
hx-on:focus="
htmx.removeClass('#searchpreview', 'hidden')
"
/>
</form>
<div id="searchpreview" class="hidden absolute left-0 right-0 top-12 z-50" style="width: calc(100% + 2px); margin-left: -1px;"></div>
</div>
</div>
</div>
<div class="hidden lg:flex xl:flex-1 items-center space-x-2 justify-end ml-6 xl:ml-0">
<a class="flex cursor-pointer items-center rounded-full bg-black/5 hover:bg-black/10 text-lg px-4 py-1.5 text-black whitespace-nowrap" href="/signin">Sign in</a>
<a class="flex cursor-pointer items-center rounded-full bg-neutral-800 text-lg px-4 py-1.5 text-white hover:bg-black whitespace-nowrap focus:bg-black" href="/download">Download</a>
</div>
<div class="lg:hidden flex items-center">
<input type="checkbox" id="menu" class="peer hidden" />
<label for="menu" class="z-50 cursor-pointer peer-checked:hidden block">
<svg
class="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
</label>
<label for="menu" class="z-50 cursor-pointer hidden peer-checked:block fixed top-4 right-6">
<svg
class="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</label>
<div class="fixed inset-0 bg-white z-40 hidden peer-checked:block overflow-y-auto">
<div class="flex flex-col space-y-5 pt-[5.5rem] text-3xl">
<a class="px-6" href="/search">Models</a>
<a class="px-6" href="/download">Download</a>
<a class="px-6" href="/docs">Docs</a>
<a class="px-6" href="/pricing">Pricing</a>
<a href="/signin" class="block px-6">Sign in</a>
</div>
</div>
</div>
</nav>
</header>
<main class="mx-auto flex max-w-4xl flex-1 flex-col-reverse items-center justify-center p-32 md:flex-row md:items-start md:justify-between">
<div class="space-y-2 text-center md:pt-6 md:text-left">
<h2 class="text-3xl font-normal tracking-tight md:text-4xl">
404.
<span class="text-neutral-400"> That's an error. </span>
</h2>
<p class="text-center text-lg md:text-left md:text-xl">
The page was not found.
</p>
</div>
<div class="pb-4 md:pb-0">
<img src="/public/400s.svg" class="w-40 md:w-48" alt="400s ollama" />
</div>
</main>
<footer class="mt-auto">
<div class="underline-offset-4 hidden md:block">
<div class="flex items-center justify-between px-6 py-3.5">
<div class="text-xs text-neutral-500">© 2026 Ollama</div>
<div class="flex space-x-6 text-xs text-neutral-500">
<a href="/download" class="hover:underline">Download</a>
<a href="/blog" class="hover:underline">Blog</a>
<a href="https://docs.ollama.com" class="hover:underline">Docs</a>
<a href="https://github.com/ollama/ollama" class="hover:underline">GitHub</a>
<a href="https://discord.com/invite/ollama" class="hover:underline">Discord</a>
<a href="https://twitter.com/ollama" class="hover:underline">X (Twitter)</a>
<a href="mailto:hello@ollama.com" class="hover:underline">Contact</a>
</div>
</div>
</div>
<div class="py-4 md:hidden">
<div class="flex flex-col items-center justify-center">
<ul class="flex flex-wrap items-center justify-center text-sm text-neutral-500">
<li class="mx-2 my-1">
<a href="/blog" class="hover:underline">Blog</a>
</li>
<li class="mx-2 my-1">
<a href="/download" class="hover:underline">Download</a>
</li>
<li class="mx-2 my-1">
<a href="https://docs.ollama.com" class="hover:underline">Docs</a>
</li>
</ul>
<ul class="flex flex-wrap items-center justify-center text-sm text-neutral-500">
<li class="mx-2 my-1">
<a href="https://github.com/ollama/ollama" class="hover:underline">GitHub</a>
</li>
<li class="mx-2 my-1">
<a href="https://discord.com/invite/ollama" class="hover:underline">Discord</a>
</li>
<li class="mx-2 my-1">
<a href="https://twitter.com/ollama" class="hover:underline">X (Twitter)</a>
</li>
<li class="mx-2 my-1">
<a href="https://lu.ma/ollama" class="hover:underline">Meetups</a>
</li>
</ul>
<div class="mt-2 flex items-center justify-center text-sm text-neutral-500">
© 2026 Ollama Inc.
</div>
</div>
</div>
</footer>
<span class="hidden" id="end_of_template"></span>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="18" fill="#1A1A2E"/>
<defs>
<linearGradient id="pg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#7B2FFF"/>
<stop offset="100%" stop-color="#FF6B6B"/>
</linearGradient>
</defs>
<!-- Play triangle -->
<polygon points="28,22 28,78 78,50" fill="url(#pg)" rx="4"/>
</svg>

After

Width:  |  Height:  |  Size: 418 B

+163
View File
@@ -0,0 +1,163 @@
import { existsSync, openSync, readSync, closeSync } from "node:fs";
export const PUBLISHED_BUILD_PLATFORM = "linux";
export const PUBLISHED_BUILD_ARCH = "x64";
const HEADER_SIZE = 4096;
const MAX_FAT_ARCH_COUNT = 30;
function mapElfMachine(machine) {
switch (machine) {
case 62:
return "x64";
case 183:
return "arm64";
default:
return null;
}
}
function mapMachCpuType(cpuType) {
switch (cpuType) {
case 0x01000007:
return "x64";
case 0x0100000c:
return "arm64";
default:
return null;
}
}
function mapPeMachine(machine) {
switch (machine) {
case 0x8664:
return "x64";
case 0xaa64:
return "arm64";
default:
return null;
}
}
function readUInt16(buffer, offset, littleEndian) {
return littleEndian ? buffer.readUInt16LE(offset) : buffer.readUInt16BE(offset);
}
function readUInt32(buffer, offset, littleEndian) {
return littleEndian ? buffer.readUInt32LE(offset) : buffer.readUInt32BE(offset);
}
const ELF_MAGIC = 0x7f454c46;
function detectElfTarget(buffer) {
if (buffer.length < 20) return null;
if (buffer.readUInt32BE(0) !== ELF_MAGIC) return null;
const littleEndian = buffer[5] !== 2;
const arch = mapElfMachine(readUInt16(buffer, 18, littleEndian));
if (!arch) return null;
return { platform: "linux", architectures: [arch] };
}
const THIN_MACH_MAGIC = new Map([
[0xfeedface, false],
[0xfeedfacf, false],
[0xcefaedfe, true],
[0xcffaedfe, true],
]);
const FAT_MACH_MAGIC = new Map([
[0xcafebabe, false],
[0xcafebabf, false],
[0xbebafeca, true],
[0xbfbafeca, true],
]);
function detectMachTarget(buffer) {
if (buffer.length < 8) return null;
const magic = buffer.readUInt32BE(0);
if (THIN_MACH_MAGIC.has(magic)) {
const littleEndian = THIN_MACH_MAGIC.get(magic);
const arch = mapMachCpuType(readUInt32(buffer, 4, littleEndian));
if (!arch) return null;
return { platform: "darwin", architectures: [arch] };
}
if (!FAT_MACH_MAGIC.has(magic)) return null;
const littleEndian = FAT_MACH_MAGIC.get(magic);
const isFat64 = magic === 0xcafebabf || magic === 0xbfbafeca;
const archCount = readUInt32(buffer, 4, littleEndian);
if (archCount > MAX_FAT_ARCH_COUNT) return null;
const entrySize = isFat64 ? 32 : 20;
const architectures = new Set();
for (let index = 0; index < archCount; index += 1) {
const offset = 8 + index * entrySize;
if (offset + 4 > buffer.length) break;
const arch = mapMachCpuType(readUInt32(buffer, offset, littleEndian));
if (arch) architectures.add(arch);
}
if (architectures.size === 0) return null;
return { platform: "darwin", architectures: [...architectures] };
}
function detectPeTarget(buffer) {
if (buffer.length < 0x40) return null;
if (buffer.readUInt16LE(0) !== 0x5a4d) return null;
const peHeaderOffset = buffer.readUInt32LE(0x3c);
if (peHeaderOffset + 6 > buffer.length) return null;
if (buffer.readUInt32LE(peHeaderOffset) !== 0x00004550) return null;
const arch = mapPeMachine(buffer.readUInt16LE(peHeaderOffset + 4));
if (!arch) return null;
return { platform: "win32", architectures: [arch] };
}
export function detectNativeBinaryTarget(buffer) {
return detectElfTarget(buffer) ?? detectMachTarget(buffer) ?? detectPeTarget(buffer);
}
export function readNativeBinaryTarget(binaryPath) {
if (!existsSync(binaryPath)) return null;
let fd;
try {
fd = openSync(binaryPath, "r");
const buffer = Buffer.alloc(HEADER_SIZE);
const bytesRead = readSync(fd, buffer, 0, HEADER_SIZE, 0);
return detectNativeBinaryTarget(buffer.subarray(0, bytesRead));
} catch (err) {
console.warn(` ⚠️ Could not read native binary at ${binaryPath}: ${err.message}`);
return null;
} finally {
if (fd !== undefined) closeSync(fd);
}
}
export function isNativeBinaryCompatible(
binaryPath,
{ runtimePlatform = process.platform, runtimeArch = process.arch, dlopen = process.dlopen } = {}
) {
const target = readNativeBinaryTarget(binaryPath);
if (target) {
if (target.platform !== runtimePlatform || !target.architectures.includes(runtimeArch)) {
return false;
}
} else if (runtimePlatform !== PUBLISHED_BUILD_PLATFORM || runtimeArch !== PUBLISHED_BUILD_ARCH) {
return false;
}
try {
dlopen({ exports: {} }, binaryPath);
return true;
} catch (err) {
console.warn(` ⚠️ Native binary dlopen failed: ${err.message}`);
return false;
}
}
+78 -44
View File
@@ -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("");
@@ -976,7 +976,7 @@ function ComboCard({
onChange={onToggle}
title={isDisabled ? t("enableCombo") : t("disableCombo")}
/>
<div className="flex items-center gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
<div className="flex items-center gap-1 transition-opacity">
<button
onClick={onTest}
disabled={testing}
@@ -86,7 +86,8 @@ export default function APIPageClient({ machineId }) {
(m) => m.type === "audio" && m.subtype === "speech" && !m.parent
);
const moderation = allModels.filter((m) => m.type === "moderation" && !m.parent);
return { chat, embeddings, images, rerank, audioTranscription, audioSpeech, moderation };
const music = allModels.filter((m) => m.type === "music" && !m.parent);
return { chat, embeddings, images, rerank, audioTranscription, audioSpeech, moderation, music };
}, [allModels]);
const postCloudAction = async (action, timeoutMs = CLOUD_ACTION_TIMEOUT_MS) => {
@@ -392,6 +393,7 @@ export default function APIPageClient({ machineId }) {
endpointData.audioTranscription,
endpointData.audioSpeech,
endpointData.moderation,
endpointData.music,
].filter((a) => a.length > 0).length + 2,
})}
</p>
@@ -530,6 +532,25 @@ export default function APIPageClient({ machineId }) {
copied={copied}
baseUrl={currentEndpoint}
/>
{/* Music Generation */}
<EndpointSection
icon="music_note"
iconColor="text-fuchsia-500"
iconBg="bg-fuchsia-500/10"
title={t("musicGeneration") || "Music Generation"}
path="/v1/music/generations"
description={
t("musicDesc") ||
"Generate music and audio tracks via ComfyUI (Stable Audio, MusicGen)"
}
models={endpointData.music}
expanded={expandedEndpoint === "music"}
onToggle={() => setExpandedEndpoint(expandedEndpoint === "music" ? null : "music")}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
</div>
</div>
@@ -1631,16 +1631,23 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
</div>
<div className="flex-1 min-w-[240px]">
<span className="text-xs text-text-muted mb-1 block">Supported Endpoints</span>
<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">
<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]));
setEditingEndpoints((prev) =>
prev.includes(ep) ? prev : [...prev, ep]
);
} else {
setEditingEndpoints((prev) => prev.filter((x) => x !== ep));
}
@@ -2312,7 +2319,7 @@ function ConnectionRow({
onChange={onToggleActive}
title={(connection.isActive ?? true) ? t("disableConnection") : t("enableConnection")}
/>
<div className="flex gap-1 ml-1 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex gap-1 ml-1 transition-opacity">
{onReauth && (
<button
onClick={onReauth}
@@ -189,23 +189,35 @@ export default function ProvidersPage() {
if (testingMode) return;
setTestingMode(mode === "provider" ? providerId : mode);
setTestResults(null);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 90_000); // 90s max
try {
const res = await fetch("/api/providers/test-batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode, providerId }),
signal: controller.signal,
});
const data = await res.json();
let data: any;
try {
data = await res.json();
} catch {
// Response body is not valid JSON (e.g. truncated due to timeout)
data = { error: t("providerTestFailed"), results: [], summary: null };
}
setTestResults(data);
if (data.summary) {
if (data?.summary) {
const { passed, failed, total } = data.summary;
if (failed === 0) notify.success(t("allTestsPassed", { total }));
else notify.warning(t("testSummary", { passed, failed, total }));
}
} catch (error) {
setTestResults({ error: t("providerTestFailed") });
notify.error(t("providerTestFailed"));
} catch (error: any) {
const isAbort = error?.name === "AbortError";
const msg = isAbort ? t("providerTestTimeout") : t("providerTestFailed");
setTestResults({ error: msg, results: [], summary: null });
notify.error(msg);
} finally {
clearTimeout(timeoutId);
setTestingMode(null);
}
};
@@ -470,8 +482,17 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
const t = useTranslations("providers");
const tc = useTranslations("common");
const { connected, error, errorCode, errorTime, allDisabled } = stats;
const [imgSrc, setImgSrc] = useState(`/providers/${provider.id}.png`);
const [imgError, setImgError] = useState(false);
const handleImgError = () => {
if (imgSrc.endsWith(".png")) {
setImgSrc(`/providers/${provider.id}.svg`);
} else {
setImgError(true);
}
};
const dotColors = {
free: "bg-green-500",
oauth: "bg-blue-500",
@@ -503,13 +524,13 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
</span>
) : (
<Image
src={`/providers/${provider.id}.png`}
src={imgSrc}
alt={provider.name}
width={30}
height={30}
className="object-contain rounded-lg max-w-[32px] max-h-[32px]"
sizes="32px"
onError={() => setImgError(true)}
onError={handleImgError}
/>
)}
</div>
@@ -590,7 +611,6 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
const { connected, error, errorCode, errorTime, allDisabled } = stats;
const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
const isAnthropicCompatible = providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
const [imgError, setImgError] = useState(false);
const dotColors = {
free: "bg-green-500",
@@ -616,6 +636,18 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
return `/providers/${provider.id}.png`;
};
const [imgSrc, setImgSrc] = useState<string>(() => getIconPath());
const [imgError, setImgError] = useState(false);
const handleImgError = () => {
const basePath = getIconPath();
if (imgSrc.endsWith(".png") && !isCompatible && !isAnthropicCompatible) {
setImgSrc(`/providers/${provider.id}.svg`);
} else {
setImgError(true);
}
};
return (
<Link href={`/dashboard/providers/${providerId}`} className="group">
<Card
@@ -634,13 +666,13 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
</span>
) : (
<Image
src={getIconPath()}
src={imgSrc || getIconPath()}
alt={provider.name}
width={30}
height={30}
className="object-contain rounded-lg max-w-[30px] max-h-[30px]"
sizes="30px"
onError={() => setImgError(true)}
onError={handleImgError}
/>
)}
</div>
@@ -1041,17 +1073,23 @@ function ProviderTestResultsView({ results }) {
const t = useTranslations("providers");
const tc = useTranslations("common");
if (results.error && !results.results) {
// Guard: never crash on malformed/null results (would trigger error boundary)
if (!results || typeof results !== "object") {
return null;
}
if (results.error && (!results.results || results.results.length === 0)) {
return (
<div className="text-center py-6">
<span className="material-symbols-outlined text-red-500 text-[32px] mb-2 block">error</span>
<p className="text-sm text-red-400">{results.error}</p>
<p className="text-sm text-red-400">{String(results.error)}</p>
</div>
);
}
const { summary, mode } = results;
const items = results.results || [];
const summary = results.summary ?? null;
const mode = results.mode ?? "";
const items = Array.isArray(results.results) ? results.results : [];
const modeLabel =
{
+1
View File
@@ -1184,6 +1184,7 @@
"clearing": "Clearing...",
"until": "Until {time}",
"providerTestFailed": "Provider test failed",
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
"modeTest": "{mode} Test",
"passedCount": "{count} passed",
"failedCount": "{count} failed",
+9 -6
View File
@@ -227,7 +227,7 @@ async function handleSingleModelChat(
const resolved = await resolveModelOrError(modelStr, body);
if (resolved.error) return resolved.error;
const { provider, model, sourceFormat, targetFormat } = resolved;
const { provider, model, sourceFormat, targetFormat, extendedContext } = resolved;
// 2. Pipeline gates (availability + circuit breaker)
const gate = checkPipelineGates(provider, model);
@@ -290,6 +290,7 @@ async function handleSingleModelChat(
apiKeyInfo,
userAgent,
comboName,
extendedContext,
});
if (telemetry) telemetry.endPhase();
@@ -366,7 +367,7 @@ async function resolveModelOrError(modelStr: string, body: any) {
return { error: errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format") };
}
const { provider, model } = modelInfo;
const { provider, model, extendedContext } = modelInfo;
const sourceFormat = detectFormat(body);
const providerAlias = PROVIDER_ID_TO_ALIAS[provider] || provider;
@@ -378,13 +379,14 @@ async function resolveModelOrError(modelStr: string, body: any) {
log.info("ROUTING", `Custom model apiFormat=responses → targetFormat=openai-responses`);
}
const ctxTag = extendedContext && providerAlias === "claude" ? " [1m]" : "";
if (modelStr !== `${provider}/${model}`) {
log.info("ROUTING", `${modelStr}${provider}/${model}`);
log.info("ROUTING", `${modelStr}${provider}/${model}${ctxTag}`);
} else {
log.info("ROUTING", `Provider: ${provider}, Model: ${model}`);
log.info("ROUTING", `Provider: ${provider}, Model: ${model}${ctxTag}`);
}
return { provider, model, sourceFormat, targetFormat };
return { provider, model, sourceFormat, targetFormat, extendedContext };
}
/**
@@ -437,6 +439,7 @@ async function executeChatWithBreaker({
apiKeyInfo,
userAgent,
comboName,
extendedContext,
}: any): Promise<{ result: any; tlsFingerprintUsed: boolean }> {
let tlsFingerprintUsed = false;
@@ -445,7 +448,7 @@ async function executeChatWithBreaker({
runWithProxyContext(proxyInfo?.proxy || null, () =>
(handleChatCore as any)({
body: { ...body, model: `${provider}/${model}` },
modelInfo: { provider, model },
modelInfo: { provider, model, extendedContext },
credentials: refreshedCredentials,
log: logger,
clientRawRequest,
+8 -1
View File
@@ -39,6 +39,7 @@ async function lookupCustomModelApiFormat(
*/
export async function getModelInfo(modelStr) {
const parsed = parseModel(modelStr);
const { extendedContext } = parsed;
// Check custom provider nodes first (for both alias and non-alias formats)
if (parsed.providerAlias || parsed.provider) {
@@ -53,7 +54,12 @@ export async function getModelInfo(modelStr) {
matchedOpenAI.id as string,
parsed.model as string
);
return { provider: matchedOpenAI.id, model: parsed.model, ...(apiFormat && { apiFormat }) };
return {
provider: matchedOpenAI.id,
model: parsed.model,
extendedContext,
...(apiFormat && { apiFormat }),
};
}
// Check Anthropic Compatible nodes
@@ -67,6 +73,7 @@ export async function getModelInfo(modelStr) {
return {
provider: matchedAnthropic.id,
model: parsed.model,
extendedContext,
...(apiFormat && { apiFormat }),
};
}
+22
View File
@@ -0,0 +1,22 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseModel } from "../../open-sse/services/model.ts";
// [1m] extended context suffix — PR #311 (DavyMassoneto)
test("[1m] suffix: strips suffix and sets extendedContext=true", () => {
const result = parseModel("claude-sonnet-4-6[1m]");
assert.strictEqual(result.model, "claude-sonnet-4-6");
assert.strictEqual(result.extendedContext, true);
});
test("[1m] suffix: normal model has extendedContext=false", () => {
const result = parseModel("claude-sonnet-4-6");
assert.strictEqual(result.model, "claude-sonnet-4-6");
assert.strictEqual(result.extendedContext, false);
});
test("[1m] suffix: works with provider prefix", () => {
const result = parseModel("claude/claude-sonnet-4-6[1m]");
assert.strictEqual(result.model, "claude-sonnet-4-6");
assert.strictEqual(result.extendedContext, true);
});
+143
View File
@@ -0,0 +1,143 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
detectNativeBinaryTarget,
isNativeBinaryCompatible,
} from "../../scripts/native-binary-compat.mjs";
function makeElfBinary(machine) {
const buffer = Buffer.alloc(64);
buffer[0] = 0x7f;
buffer[1] = 0x45;
buffer[2] = 0x4c;
buffer[3] = 0x46;
buffer[4] = 2;
buffer[5] = 1;
buffer.writeUInt16LE(machine, 18);
return buffer;
}
function makeMachBinary(cpuType) {
const buffer = Buffer.alloc(32);
buffer.writeUInt32BE(0xcffaedfe, 0);
buffer.writeUInt32LE(cpuType, 4);
return buffer;
}
function makePeBinary(machine) {
const buffer = Buffer.alloc(160);
buffer[0] = 0x4d;
buffer[1] = 0x5a;
buffer.writeUInt32LE(0x80, 0x3c);
buffer.write("PE\0\0", 0x80, "ascii");
buffer.writeUInt16LE(machine, 0x84);
return buffer;
}
describe("detectNativeBinaryTarget", () => {
it("detects linux x64 ELF binaries", () => {
assert.deepEqual(detectNativeBinaryTarget(makeElfBinary(62)), {
platform: "linux",
architectures: ["x64"],
});
});
it("detects darwin arm64 Mach-O binaries", () => {
assert.deepEqual(detectNativeBinaryTarget(makeMachBinary(0x0100000c)), {
platform: "darwin",
architectures: ["arm64"],
});
});
it("detects win32 x64 PE binaries", () => {
assert.deepEqual(detectNativeBinaryTarget(makePeBinary(0x8664)), {
platform: "win32",
architectures: ["x64"],
});
});
});
describe("isNativeBinaryCompatible", () => {
function withTempBinary(buffer, callback) {
const dir = mkdtempSync(join(tmpdir(), "omniroute-native-"));
const file = join(dir, "better_sqlite3.node");
writeFileSync(file, buffer);
try {
callback(file);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
it("accepts linux-x64 binaries when the target matches and dlopen succeeds", () => {
withTempBinary(makeElfBinary(62), (binaryPath) => {
assert.equal(
isNativeBinaryCompatible(binaryPath, {
runtimePlatform: "linux",
runtimeArch: "x64",
dlopen() {},
}),
true
);
});
});
it("rejects linux-x64 binaries when dlopen fails on the same platform", () => {
withTempBinary(makeElfBinary(62), (binaryPath) => {
assert.equal(
isNativeBinaryCompatible(binaryPath, {
runtimePlatform: "linux",
runtimeArch: "x64",
dlopen() {
throw new Error("abi mismatch");
},
}),
false
);
});
});
it("rejects macOS false positives for bundled linux binaries", () => {
withTempBinary(makeElfBinary(62), (binaryPath) => {
assert.equal(
isNativeBinaryCompatible(binaryPath, {
runtimePlatform: "darwin",
runtimeArch: "arm64",
dlopen() {},
}),
false
);
});
});
it("rejects Windows false positives for bundled linux binaries", () => {
withTempBinary(makeElfBinary(62), (binaryPath) => {
assert.equal(
isNativeBinaryCompatible(binaryPath, {
runtimePlatform: "win32",
runtimeArch: "x64",
dlopen() {},
}),
false
);
});
});
it("accepts copied darwin binaries after postinstall replacement", () => {
withTempBinary(makeMachBinary(0x0100000c), (binaryPath) => {
assert.equal(
isNativeBinaryCompatible(binaryPath, {
runtimePlatform: "darwin",
runtimeArch: "arm64",
dlopen() {},
}),
true
);
});
});
});