Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ _In development — bullets added per PR; finalized at release._
- **fix(sse): normalize array user-message content in the Command Code executor to prevent upstream 400** — when a client sends a user turn whose `content` is an array of content parts (e.g. `[{type:"text",text:"…"}, …]`), the raw array was forwarded verbatim to the Command Code upstream, which requires `messages[N].content` for the `user` role to be a plain string — resulting in `expected string, received array` / HTTP 400 on DeepSeek V4-Pro and other Command Code models. The user branch of `convertMessages` now calls `normalizeContentText()` (already used by system, assistant, and tool branches) so multi-part user content is joined to a string before dispatch. Partially addresses ([#5166](https://github.com/diegosouzapw/OmniRoute/issues/5166)); the 0-output-token symptom on reasoning-only models is tracked separately.
- **fix(mcp): return HTTP 404 (not 400) for an unknown/expired Streamable HTTP session id** — when an MCP session is terminated or idles out and the client reuses the stale `Mcp-Session-Id` header, the Streamable HTTP transport replied with HTTP 400. The MCP spec (2025-03-26 and 2025-11-25, Session Management) mandates HTTP 404 Not Found in that case, and spec-compliant clients only re-initialize a session on 404 — so the 400 was non-recoverable. The handler now returns 404 for a present-but-unknown session id, while a *missing* session id on a non-initialize request correctly stays 400. ([#5169](https://github.com/diegosouzapw/OmniRoute/issues/5169) — thanks @czer323)
- **fix(api): blocking "Auto (Zero-Config)" in Security settings now removes `auto/*` from `/v1/models`** — the built-in `auto/*` combo advertiser (#4164 / #4235) at the top of the models catalog ignored `settings.blockedProviders`, so checking **Auto (Zero-Config)** under Security → Blocked Providers had no effect and the model picker kept listing every `auto/*` entry. The injection loop now skips the entire `auto/*` block when the system provider `auto` (its id and alias are both `auto`) is blocked, consistent with how every other provider is filtered from the catalog. ([#5192](https://github.com/diegosouzapw/OmniRoute/issues/5192) — thanks @WslzGmzs)
- **fix(cli): auto-calibrate the server V8 heap from physical RAM instead of a fixed 512MB default** — the server was spawned with a hard-coded `--max-old-space-size=512` (`omniroute serve`) or with no heap flag at all (Electron desktop, which then inherited the runtime's low ~512MB default), so RAM-rich machines still OOM-crashed under load (`FATAL ERROR: Ineffective mark-compacts near heap limit … ~500MB` at code=134) with many providers/accounts and large model catalogs (one report: 16GB RAM, 65 providers, ~100 accounts, ~2600 models). A new `calibrateHeapFallbackMb(os.totalmem())` helper derives the default heap as ~35% of physical RAM, clamped to `[512, 4096]`, and is wired into both `bin/cli/commands/serve.mjs` and `electron/main.js`. An explicit `OMNIROUTE_MEMORY_MB` (or a pre-set `--max-old-space-size`) still wins, so the #2939 override contract is unchanged. ([#5172](https://github.com/diegosouzapw/OmniRoute/issues/5172), [#5160](https://github.com/diegosouzapw/OmniRoute/issues/5160), [#5152](https://github.com/diegosouzapw/OmniRoute/issues/5152) — thanks @manchairwang, @Xyzjesus)

---

Expand Down
16 changes: 12 additions & 4 deletions bin/cli/commands/serve.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { platform } from "node:os";
import { platform, totalmem } from "node:os";
import { t } from "../i18n.mjs";
import { writePidFile, cleanupPidFile, waitForServer } from "../utils/pid.mjs";
import { ServerSupervisor, detectMitmCrash } from "../runtime/processSupervisor.mjs";
import { isTermux } from "../../../scripts/build/postinstallSupport.mjs";
import {
resolveMaxOldSpaceMb,
calibrateHeapFallbackMb,
} from "../../../scripts/build/runtime-env.mjs";

const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, "..", "..", "..");
Expand Down Expand Up @@ -126,9 +130,13 @@ export async function runServe(opts = {}) {

console.log(` \x1b[2m⏳ Starting server...\x1b[0m\n`);

const rawMemory = parseInt(process.env.OMNIROUTE_MEMORY_MB || "512", 10);
const memoryLimit =
Number.isFinite(rawMemory) && rawMemory >= 64 && rawMemory <= 16384 ? rawMemory : 512;
// #5172/#5160/#5152: default the V8 heap to ~35% of physical RAM (clamped
// [512, 4096]) instead of a fixed 512MB, which OOM-crashed boxes with plenty
// of RAM under load. An explicit OMNIROUTE_MEMORY_MB still wins.
const memoryLimit = resolveMaxOldSpaceMb(
process.env.OMNIROUTE_MEMORY_MB,
calibrateHeapFallbackMb(totalmem())
);

const env = {
...process.env,
Expand Down
25 changes: 25 additions & 0 deletions electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -614,8 +614,32 @@ function startNextServer() {

const nodeExecutable = resolveNodeExecutable(serverEnv);

// #5172/#5160/#5152: the Electron-spawned server inherited the runtime's low
// default V8 heap (~512MB) and OOM-crashed on RAM-rich boxes under load
// (65 providers / 2600 models → "Ineffective mark-compacts near heap limit").
// Default the heap to ~35% of physical RAM (clamped [512, 4096]); an explicit
// OMNIROUTE_MEMORY_MB or a pre-set --max-old-space-size still wins. Mirrors
// scripts/build/runtime-env.mjs (CJS can't import the ESM helper).
const serverNodeOptions = (() => {
const existing = serverEnv.NODE_OPTIONS || "";
if (existing.includes("--max-old-space-size")) return existing;
const explicit = parseInt(serverEnv.OMNIROUTE_MEMORY_MB, 10);
let heapMb;
if (Number.isFinite(explicit) && explicit >= 64 && explicit <= 16384) {
heapMb = explicit;
} else {
const totalMb = require("os").totalmem() / (1024 * 1024);
heapMb =
Number.isFinite(totalMb) && totalMb > 0
? Math.min(4096, Math.max(512, Math.floor(totalMb * 0.35)))
: 512;
}
return `${existing} --max-old-space-size=${heapMb}`.trim();
})();

console.log("[Electron] Starting Next.js server on port", serverPort);
console.log("[Electron] Using Node executable:", nodeExecutable);
console.log("[Electron] Server NODE_OPTIONS:", serverNodeOptions);
sendToRenderer("server-status", { status: "starting", port: serverPort });

// Fix #10: Use pipe instead of inherit for logging & readiness detection
Expand All @@ -630,6 +654,7 @@ function startNextServer() {
NODE_ENV: "production",
ELECTRON_RUN_AS_NODE: "1",
NODE_PATH: resolveServerNodePath(serverEnv),
NODE_OPTIONS: serverNodeOptions,
},
stdio: "pipe",
windowsHide: true,
Expand Down
17 changes: 17 additions & 0 deletions scripts/build/runtime-env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ export function resolveMaxOldSpaceMb(value, fallback = 512) {
return Number.isFinite(parsed) && parsed >= 64 && parsed <= 16384 ? parsed : fallback;
}

/**
* Derive a sane DEFAULT V8 heap ceiling (MB) from the host's physical RAM, used
* when `OMNIROUTE_MEMORY_MB` is unset. A fixed 512MB default crashed boxes with
* plenty of RAM under load (65 providers / 2600 models → "Ineffective
* mark-compacts near heap limit ~500MB"); see #5172 / #5160 / #5152. Targets
* ~35% of total RAM, clamped to [512, 4096]. Invalid/zero totalmem → 512.
* Pass the result as the `fallback` of {@link resolveMaxOldSpaceMb} so an
* explicit OMNIROUTE_MEMORY_MB override always wins.
* @param {number | undefined | null} totalmemBytes — typically `os.totalmem()`
*/
export function calibrateHeapFallbackMb(totalmemBytes) {
const totalMb = Number(totalmemBytes) / (1024 * 1024);
if (!Number.isFinite(totalMb) || totalMb <= 0) return 512;
const target = Math.floor(totalMb * 0.35);
return Math.min(4096, Math.max(512, target));
}

/**
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [fromEnv]
* Defaults to process.env. Pass bootstrap `merged` so project `.env` PORT applies before spawn.
Expand Down
50 changes: 50 additions & 0 deletions tests/unit/heap-autocalibrate-5172.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Issue #5172 / #5160 / #5152 — server OOM ("Ineffective mark-compacts near heap
* limit ... ~500MB") on machines with plenty of RAM. Root cause: the server was
* spawned with a FIXED 512MB heap default (`omniroute serve`) or with no
* `--max-old-space-size` at all (Electron), so a 16GB box with 65 providers /
* 2600 models still crashed at ~512MB.
*
* Fix: `calibrateHeapFallbackMb(totalmemBytes)` derives a sane default heap from
* the host's physical RAM (~35%, clamped to [512, 4096]) so the out-of-the-box
* ceiling scales with the machine. An explicit `OMNIROUTE_MEMORY_MB` still wins
* (resolveMaxOldSpaceMb), and the existing #2939 contract is unchanged.
*/
import test from "node:test";
import assert from "node:assert/strict";

const { calibrateHeapFallbackMb, resolveMaxOldSpaceMb } =
await import("../../scripts/build/runtime-env.mjs");

const GB = 1024 * 1024 * 1024;

test("#5172 calibrates the default heap to ~35% of physical RAM", () => {
// 8GB → 8192 * 0.35 ≈ 2867
assert.equal(calibrateHeapFallbackMb(8 * GB), 2867);
// 4GB → floor(4096 * 0.35) = 1433
assert.equal(calibrateHeapFallbackMb(4 * GB), 1433);
});

test("#5172 clamps the calibrated default to [512, 4096]", () => {
// 16GB → 5734 → clamped to the 4096 ceiling (the reporter's box)
assert.equal(calibrateHeapFallbackMb(16 * GB), 4096);
assert.equal(calibrateHeapFallbackMb(64 * GB), 4096);
// 1GB → 358 → floored to 512
assert.equal(calibrateHeapFallbackMb(1 * GB), 512);
});

test("#5172 falls back to 512 for missing/invalid totalmem", () => {
assert.equal(calibrateHeapFallbackMb(0), 512);
assert.equal(calibrateHeapFallbackMb(undefined), 512);
assert.equal(calibrateHeapFallbackMb(null), 512);
assert.equal(calibrateHeapFallbackMb(NaN), 512);
assert.equal(calibrateHeapFallbackMb(-1), 512);
});

test("#5172 an explicit OMNIROUTE_MEMORY_MB still wins over the calibrated default", () => {
const calibrated = calibrateHeapFallbackMb(16 * GB); // 4096
// explicit override (in-range) is honored verbatim, not the calibrated default
assert.equal(resolveMaxOldSpaceMb("1536", calibrated), 1536);
// unset → the calibrated default is used (not the old fixed 512)
assert.equal(resolveMaxOldSpaceMb(undefined, calibrated), 4096);
});
Loading