Skip to content
Closed
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
81 changes: 81 additions & 0 deletions bin/nodeRuntimeSupport.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#!/usr/bin/env node

export const SECURE_NODE_LINES = Object.freeze([
Object.freeze({ major: 20, minor: 20, patch: 2 }),
Object.freeze({ major: 22, minor: 22, patch: 2 }),
]);

export const RECOMMENDED_NODE_VERSION = "22.22.2";
export const SUPPORTED_NODE_RANGE = ">=20.20.2 <21 || >=22.22.2 <23";
export const SUPPORTED_NODE_DISPLAY = "Node.js 20.20.2+ (20.x LTS) or 22.22.2+ (22.x LTS)";

function formatVersion(version) {
return `${version.major}.${version.minor}.${version.patch}`;
}

export function parseNodeVersion(version = process.versions.node) {
const rawInput = String(version || process.versions.node || "0.0.0").trim();
const normalized = rawInput.replace(/^v/i, "");
const parts = normalized.split(".");
const major = Number.parseInt(parts[0] || "0", 10);
const minor = Number.parseInt(parts[1] || "0", 10);
const patch = Number.parseInt(parts[2] || "0", 10);

return {
raw: normalized ? `v${normalized}` : "v0.0.0",
normalized: normalized || "0.0.0",
major: Number.isFinite(major) ? major : 0,
minor: Number.isFinite(minor) ? minor : 0,
patch: Number.isFinite(patch) ? patch : 0,
};
}

export function compareNodeVersions(a, b) {
if (a.major !== b.major) return a.major - b.major;
if (a.minor !== b.minor) return a.minor - b.minor;
return a.patch - b.patch;
}

export function getSecureFloorForMajor(major) {
return SECURE_NODE_LINES.find((line) => line.major === major) || null;
}

export function getNodeRuntimeSupport(version = process.versions.node) {
const parsed = parseNodeVersion(version);
const secureFloor = getSecureFloorForMajor(parsed.major);
const nodeCompatible = secureFloor ? compareNodeVersions(parsed, secureFloor) >= 0 : false;

let reason = "unsupported-major";
if (nodeCompatible) {
reason = "supported";
} else if (secureFloor) {
reason = "below-security-floor";
} else if (parsed.major >= 24) {
reason = "native-addon-incompatible";
}

return {
nodeVersion: parsed.raw,
nodeCompatible,
reason,
supportedRange: SUPPORTED_NODE_RANGE,
supportedDisplay: SUPPORTED_NODE_DISPLAY,
recommendedVersion: `v${RECOMMENDED_NODE_VERSION}`,
minimumSecureVersion: secureFloor ? `v${formatVersion(secureFloor)}` : null,
};
}

export function getNodeRuntimeWarning(version = process.versions.node) {
const support = getNodeRuntimeSupport(version);
if (support.nodeCompatible) return null;

if (support.reason === "below-security-floor" && support.minimumSecureVersion) {
return `Node.js ${support.nodeVersion} is below the patched minimum ${support.minimumSecureVersion} for this LTS line.`;
}

if (support.reason === "native-addon-incompatible") {
return `Node.js ${support.nodeVersion} is outside the supported LTS lines and may fail at runtime because better-sqlite3 does not support Node.js 24+ here.`;
}

return `Node.js ${support.nodeVersion} is outside OmniRoute's approved secure runtime policy.`;
}
42 changes: 7 additions & 35 deletions bin/omniroute.ts → bin/omniroute.mjs
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,20 @@ 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";
import {
getNodeRuntimeSupport,
getNodeRuntimeWarning,
} from "../src/shared/utils/nodeRuntimeSupport.ts";
import { getNodeRuntimeSupport, getNodeRuntimeWarning } from "./nodeRuntimeSupport.mjs";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ROOT = join(__dirname, "..");
const APP_DIR = join(ROOT, "app");

// ── Load .env file (for global npm install) ─────────────────
function loadEnvFile() {
const envPaths = [];

// 1. DATA_DIR/.env if set
if (process.env.DATA_DIR) {
envPaths.push(join(process.env.DATA_DIR, ".env"));
}

// 2. ~/.omniroute/.env (default data dir)
const home = homedir();
if (home) {
if (platform() === "win32") {
Expand All @@ -48,7 +42,6 @@ function loadEnvFile() {
}
}

// 3. ./.env (current working directory)
envPaths.push(join(process.cwd(), ".env"));

for (const envPath of envPaths) {
Expand All @@ -57,15 +50,12 @@ function loadEnvFile() {
const content = readFileSync(envPath, "utf-8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
// Skip empty lines and comments
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx > 0) {
const key = trimmed.slice(0, eqIdx).trim();
const value = trimmed.slice(eqIdx + 1).trim();
// Don't override existing env vars
if (process.env[key] === undefined) {
// Remove surrounding quotes
process.env[key] = value.replace(/^["']|["']$/g, "");
}
}
Expand All @@ -74,14 +64,13 @@ function loadEnvFile() {
return;
}
} catch {
// Ignore errors reading env files
// Ignore errors reading env files.
}
}
}

loadEnvFile();

// ── Parse args ─────────────────────────────────────────────
const args = process.argv.slice(2);

if (args.includes("--help") || args.includes("-h")) {
Expand Down Expand Up @@ -128,7 +117,6 @@ if (args.includes("--version") || args.includes("-v")) {
process.exit(0);
}

// ── MCP Server Mode ───────────────────────────────────────
if (args.includes("--mcp")) {
try {
const { startMcpCli } = await import(join(ROOT, "bin", "mcp-server.mjs"));
Expand All @@ -145,7 +133,6 @@ function parsePort(value, fallback) {
return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback;
}

// Parse --port (canonical/base port)
let port = parsePort(process.env.PORT || "20128", 20128);
const portIdx = args.indexOf("--port");
if (portIdx !== -1 && args[portIdx + 1]) {
Expand All @@ -159,20 +146,17 @@ if (portIdx !== -1 && args[portIdx + 1]) {

const apiPort = parsePort(process.env.API_PORT || String(port), port);
const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(port), port);

const noOpen = args.includes("--no-open");

// ── Banner ─────────────────────────────────────────────────
console.log(`
\x1b[36m ____ _ ____ _
/ __ \\ (_) __ \\ | |
/ __ \\\\ (_) __ \\\\ | |
| | | |_ __ ___ _ __ _| |__) |___ _ _| |_ ___
| | | | '_ \` _ \\| '_ \\ | _ // _ \\| | | | __/ _ \\
| |__| | | | | | | | | | | | \\ \\ (_) | |_| | || __/
\\____/|_| |_| |_|_| |_|_|_| \\_\\___/ \\__,_|\\__\\___|
| | | | '_ \` _ \\\\| '_ \\\\ | _ // _ \\\\| | | | __/ _ \\\\
| |__| | | | | | | | | | | | \\\\ \\\\ (_) | |_| | || __/
\\\\____/|_| |_| |_|_| |_|_|_| \\\\_\\\\___/ \\\\__,_|\\\\__\\\\___|
\x1b[0m`);

// ── Node.js version check ──────────────────────────────────
const nodeSupport = getNodeRuntimeSupport();
if (!nodeSupport.nodeCompatible) {
const runtimeWarning = getNodeRuntimeWarning() || "Unsupported Node.js runtime detected.";
Expand All @@ -185,14 +169,12 @@ if (!nodeSupport.nodeCompatible) {
`);
}

// ── Resolve server entry ───────────────────────────────────
const serverJs = join(APP_DIR, "server.js");

if (!existsSync(serverJs)) {
console.error("\x1b[31m✖ Server not found at:\x1b[0m", serverJs);
console.error(" The package may not have been built correctly.");
console.error("");
// (#492) Detect common non-standard Node managers that cause this issue
const nodeExec = process.execPath || "";
const isMise = nodeExec.includes("mise") || nodeExec.includes(".local/share/mise");
const isNvm = nodeExec.includes(".nvm") || nodeExec.includes("nvm");
Expand All @@ -214,10 +196,6 @@ if (!existsSync(serverJs)) {
process.exit(1);
}

// ── Pre-flight: verify better-sqlite3 native binary ───────
// Verify the binary's actual target platform/arch before trusting dlopen.
// This avoids the macOS false positive where a bundled linux-x64 addon can
// appear to load even though the runtime will fail when better-sqlite3 starts.
const sqliteBinary = join(
APP_DIR,
"node_modules",
Expand All @@ -237,10 +215,8 @@ if (existsSync(sqliteBinary) && !isNativeBinaryCompatible(sqliteBinary)) {
process.exit(1);
}

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

// Sanitize memory limit — parseInt to prevent command injection (#150)
const rawMemory = parseInt(process.env.OMNIROUTE_MEMORY_MB || "512", 10);
const memoryLimit =
Number.isFinite(rawMemory) && rawMemory >= 64 && rawMemory <= 16384 ? rawMemory : 512;
Expand Down Expand Up @@ -268,7 +244,6 @@ server.stdout.on("data", (data) => {
const text = data.toString();
process.stdout.write(text);

// Detect server ready
if (
!started &&
(text.includes("Ready") || text.includes("started") || text.includes("listening"))
Expand All @@ -294,7 +269,6 @@ server.on("exit", (code) => {
process.exit(code ?? 0);
});

// ── Graceful shutdown ──────────────────────────────────────
function shutdown() {
console.log("\n\x1b[33m⏹ Shutting down OmniRoute...\x1b[0m");
server.kill("SIGTERM");
Expand All @@ -307,7 +281,6 @@ function shutdown() {
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);

// ── On ready ───────────────────────────────────────────────
async function onReady() {
const dashboardUrl = `http://localhost:${dashboardPort}`;
const apiUrl = `http://localhost:${apiPort}`;
Expand All @@ -329,12 +302,11 @@ async function onReady() {
const open = await import("open");
await open.default(dashboardUrl);
} catch {
// open is optional — if not available, just skip
// open is optional — if not available, just skip.
}
}
}

// Fallback: if no "Ready" message detected in 15s, assume server is up
setTimeout(() => {
if (!started) {
started = true;
Expand Down
5 changes: 4 additions & 1 deletion open-sse/handlers/chatCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -914,7 +914,10 @@ export async function handleChatCore({
// For Responses API targets, max_output_tokens is the canonical field. For others,
// max_tokens is preferred. We handle normalization here to support passthrough
// paths where the translator is skipped.
if (targetFormat === FORMATS.OPENAI_RESPONSES) {
const prefersResponsesTokenField =
sourceFormat === FORMATS.OPENAI_RESPONSES || targetFormat === FORMATS.OPENAI_RESPONSES;

if (prefersResponsesTokenField) {
if (body.max_output_tokens === undefined) {
if (body.max_completion_tokens !== undefined) {
body.max_output_tokens = body.max_completion_tokens;
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"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": {
"omniroute": "bin/omniroute.ts",
"omniroute": "bin/omniroute.mjs",
"omniroute-reset-password": "bin/reset-password.mjs"
},
"files": [
Expand Down
6 changes: 4 additions & 2 deletions scripts/pack-artifact-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [
"LICENSE",
"README.md",
"bin/mcp-server.mjs",
"bin/omniroute.ts",
"bin/nodeRuntimeSupport.mjs",
"bin/omniroute.mjs",
"bin/reset-password.mjs",
"open-sse/mcp-server/README.md",
"open-sse/mcp-server/audit.ts",
Expand All @@ -85,7 +86,8 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_PATH_PREFIXES: string[] = [
export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [
"app/server.js",
"bin/mcp-server.mjs",
"bin/omniroute.ts",
"bin/nodeRuntimeSupport.mjs",
"bin/omniroute.mjs",
"package.json",
"scripts/native-binary-compat.mjs",
"scripts/postinstall.mjs",
Expand Down
Loading