Release v3.8.40

v3.8.40 cycle integration → main. All test gates green (Unit/Integration/Coverage/Node-compat/Quality-Ratchet). The only red check, 'PR Test Policy', is the test-masking heuristic firing on the cumulative ~57-commit release diff (legitimate assert consolidations already reviewed per-PR — Gemini CLI removal #5246, retired GPT models #5280, provider catalog refreshes); overridden with --admin per the documented release-PR convention. CodeQL/SonarQube advisory scans non-blocking; #5278's code already passed CodeQL on main. Homologated on VPS 192.168.0.15 (v3.8.40 healthy).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-29 08:40:06 -03:00
committed by GitHub
parent 1c18be4f8f
commit 7c23dab64d
1007 changed files with 16451 additions and 21343 deletions

View File

@@ -1,8 +1,8 @@
# bin/_ops-common.sh — shared helpers for the OmniRoute ops runbook scripts.
#
# Sourced (not executed) by rollback.sh / snapshot-data.sh / restore-data.sh /
# restore-policies.sh / cold-start-bench.sh. The runbook context lives in
# docs/INCIDENT_RESPONSE.md and docs/PERF_BUDGETS.md.
# restore-policies.sh / cold-start-bench.sh — the self-hoster incident-recovery
# and cold-start ops tooling. Each script documents its own contract via --help.
#
# Path resolution mirrors the app (src/lib/db/core.ts): the SQLite store is
# $DATA_DIR/storage.sqlite and managed backups go to $DATA_DIR/db_backups

View File

@@ -71,7 +71,6 @@ import { registerSetupCrush } from "./setup-crush.mjs";
import { registerSetupGoose } from "./setup-goose.mjs";
import { registerSetupQwen } from "./setup-qwen.mjs";
import { registerSetupAider } from "./setup-aider.mjs";
import { registerSetupGemini } from "./setup-gemini.mjs";
import { registerConnect } from "./connect.mjs";
import { registerContexts } from "./contexts.mjs";
import { registerTokens } from "./tokens.mjs";
@@ -154,7 +153,6 @@ export function registerCommands(program) {
registerSetupGoose(program);
registerSetupQwen(program);
registerSetupAider(program);
registerSetupGemini(program);
registerConnect(program);
registerContexts(program);
registerTokens(program);

View File

@@ -313,7 +313,7 @@ async function maybeStartTray(port, apiPort, supervisor) {
if (!isTraySupported()) return;
const { default: open } = await import("open").catch(() => ({ default: null }));
const dashboardUrl = `http://localhost:${port}`;
const tray = initTray({
const tray = await initTray({
port,
onQuit: () => {
killTrayIfActive();
@@ -329,8 +329,12 @@ async function maybeStartTray(port, apiPort, supervisor) {
const { killTray } = await import("../tray/index.mjs");
_killTray = killTray;
}
} catch {
// tray is optional — do not fail the server
} catch (err) {
// tray is optional — do not fail the server, but surface why it failed so
// "--tray shows nothing" is diagnosable instead of silent (#4605).
process.stderr.write(
`[omniroute][tray] failed to start: ${err?.message ?? String(err)}\n`
);
}
}

View File

@@ -1,148 +0,0 @@
/**
* omniroute setup-gemini — point the Gemini CLI at OmniRoute's Gemini endpoint.
*
* The Gemini CLI is NOT OpenAI-compatible — it speaks the native Gemini API.
* OmniRoute exposes a Gemini-native surface at /v1beta (e.g.
* /v1beta/models/<model>:generateContent), so the CLI can target it via the
* @google/genai SDK env `GOOGLE_GEMINI_BASE_URL` (ROOT — the SDK appends /v1beta)
* + `GEMINI_API_KEY`. There is no settings.json key for the base URL, so this is
* primarily an env recipe; we optionally write ~/.gemini/settings.json `model`.
*
* ⚠ Known Gemini CLI caveat: it may ignore GOOGLE_GEMINI_BASE_URL if a cached
* Google login exists — run `gemini` logged-out / API-key-only for it to take.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import os from "node:os";
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs";
function stripToRoot(url) {
const s = String(url || "").replace(/\/+$/, "");
return s.endsWith("/v1beta") ? s.slice(0, -7) : s.endsWith("/v1") ? s.slice(0, -3) : s;
}
/** Resolve GOOGLE_GEMINI_BASE_URL (ROOT — SDK appends /v1beta) + apiKey. */
export function resolveGeminiTarget(opts = {}) {
let root;
if (opts.remote) root = stripToRoot(opts.remote);
else {
try {
root = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl);
} catch {
/* none */
}
if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
}
let apiKey = opts.apiKey ?? opts["api-key"];
if (!apiKey) {
try {
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
apiKey = c?.accessToken || c?.apiKey;
} catch {
/* none */
}
}
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
return { baseUrl: root, apiKey };
}
/** The guaranteed env recipe (pure → testable). */
export function buildGeminiRecipe({ baseUrl, model }) {
return [
`export GOOGLE_GEMINI_BASE_URL=${baseUrl}`,
"export GEMINI_API_KEY=$OMNIROUTE_API_KEY",
`export GEMINI_MODEL=${model}`,
`gemini -p "reply OK" # or: gemini (interactive)`,
].join("\n");
}
/** Merge the model into ~/.gemini/settings.json (base URL is env-only). */
export function buildGeminiSettings(existing, { model }) {
const s = existing && typeof existing === "object" ? { ...existing } : {};
if (model) s.model = model;
return s;
}
function readJson(path) {
try {
if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
} catch {
/* corrupt/missing */
}
return {};
}
async function fetchGeminiModelIds(baseUrl, apiKey) {
try {
const res = await fetch(`${baseUrl}/v1beta/models`, {
headers: { "x-goog-api-key": apiKey || "" },
signal: AbortSignal.timeout(8000),
});
if (!res.ok) return [];
const body = await res.json();
return (body.models || []).map((m) => String(m.name || "").replace(/^models\//, "")).filter(Boolean);
} catch {
return [];
}
}
export async function runSetupGeminiCommand(opts = {}) {
const { baseUrl, apiKey } = resolveGeminiTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".gemini", "settings.json");
printHeading("OmniRoute → Gemini CLI (native Gemini /v1beta endpoint)");
printInfo(`GOOGLE_GEMINI_BASE_URL: ${baseUrl} (root — SDK appends /v1beta)`);
let model = opts.model;
if (!model) {
const ids = await fetchGeminiModelIds(baseUrl, apiKey);
if (ids.length && !opts.yes) {
printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`);
const prompt = createPrompt();
try {
model = await prompt.ask("Model id for Gemini CLI");
} finally {
prompt.close();
}
}
}
if (!model) {
printError("A model is required. Pass --model <id>.");
return 2;
}
if (dryRun) {
console.log(`\n── [dry-run] ${configPath} ── { "model": "${model}" }`);
} else {
const merged = buildGeminiSettings(readJson(configPath), { model });
mkdirSync(join(configPath, ".."), { recursive: true });
writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n", "utf8");
printSuccess(`Wrote ${configPath} (model)`);
}
printInfo("\nThe base URL is env-only for Gemini CLI — export these:");
console.log(buildGeminiRecipe({ baseUrl, model }));
printInfo("\n⚠ If Gemini CLI ignores the base URL, you have a cached Google login —");
printInfo(" run logged-out (API-key only) so GOOGLE_GEMINI_BASE_URL takes effect.");
return 0;
}
export function registerSetupGemini(program) {
program
.command("setup-gemini")
.description("Point the Gemini CLI at OmniRoute's native Gemini /v1beta endpoint (env recipe + settings model)")
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
.option("--model <id>", "Model id for Gemini CLI (required unless picked interactively)")
.option("--config-path <path>", "settings.json path (default: ~/.gemini/settings.json)")
.option("--yes", "Non-interactive: do not prompt (requires --model)")
.option("--dry-run", "Print what would be written without touching the filesystem")
.action(async (opts) => {
const code = await runSetupGeminiCommand(opts);
if (code !== 0) process.exit(code);
});
}

View File

@@ -1,7 +1,7 @@
/**
* omniroute setup-qwen — configure Qwen Code (QwenLM/qwen-code) for OmniRoute.
*
* Qwen Code is a terminal AI agent (gemini-cli fork) with a file-based config at
* Qwen Code is a terminal AI agent with a file-based config at
* ~/.qwen/settings.json. For a custom OpenAI-compatible endpoint it uses a
* `modelProviders` entry with authType "openai", baseUrl WITH /v1, and an
* `envKey` naming the env var holding the key (secret stays in the env, never the
@@ -47,7 +47,9 @@ export function resolveQwenTarget(opts = {}) {
/** Merge the OmniRoute modelProvider into Qwen's settings.json (preserve rest). */
export function buildQwenSettings(existing, { baseUrl, model }) {
const s = existing && typeof existing === "object" ? { ...existing } : {};
const providers = Array.isArray(s.modelProviders) ? s.modelProviders.filter((p) => p?.id !== "omniroute") : [];
const providers = Array.isArray(s.modelProviders)
? s.modelProviders.filter((p) => p?.id !== "omniroute")
: [];
providers.push({
id: "omniroute",
name: "OmniRoute",
@@ -82,7 +84,7 @@ async function fetchModelIds(baseUrl, apiKey) {
});
if (!res.ok) return [];
const body = await res.json();
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
} catch {
return [];
@@ -92,7 +94,8 @@ async function fetchModelIds(baseUrl, apiKey) {
export async function runSetupQwenCommand(opts = {}) {
const { baseUrl, apiKey } = resolveQwenTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".qwen", "settings.json");
const configPath =
opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".qwen", "settings.json");
printHeading("OmniRoute → Qwen Code (openai-compatible)");
printInfo(`baseUrl: ${baseUrl}`);
@@ -126,7 +129,9 @@ export async function runSetupQwenCommand(opts = {}) {
writeFileSync(configPath, out, "utf8");
printSuccess(`Wrote ${configPath}`);
}
printInfo("\nProvide the key (settings reference OMNIROUTE_API_KEY): export OMNIROUTE_API_KEY=...");
printInfo(
"\nProvide the key (settings reference OMNIROUTE_API_KEY): export OMNIROUTE_API_KEY=..."
);
printInfo('Then run: qwen (or headless: qwen -p "reply OK")');
return 0;
}
@@ -134,7 +139,9 @@ export async function runSetupQwenCommand(opts = {}) {
export function registerSetupQwen(program) {
program
.command("setup-qwen")
.description("Configure Qwen Code for OmniRoute: write ~/.qwen/settings.json (openai modelProvider)")
.description(
"Configure Qwen Code for OmniRoute: write ~/.qwen/settings.json (openai modelProvider)"
)
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")

View File

@@ -5,10 +5,12 @@ let active = null;
export { isTraySupported };
export function initTray({ port, onQuit, onOpenDashboard, onShowLogs }) {
export async function initTray({ port, onQuit, onOpenDashboard, onShowLogs }) {
if (!isTraySupported()) return null;
const ctx = { port, onQuit, onOpenDashboard, onShowLogs };
active = process.platform === "win32" ? initWinTray(ctx) : initSystrayUnix(ctx);
// initSystrayUnix is async: it lazily installs/loads systray2 from the runtime
// dir (trayRuntime.ts) rather than from node_modules. (#4605)
active = process.platform === "win32" ? initWinTray(ctx) : await initSystrayUnix(ctx);
return active;
}

View File

@@ -14,30 +14,31 @@ export function isTraySupported() {
return true;
}
function loadSystray2() {
const candidates = [
() => {
const { createRequire } = require("module");
const req = createRequire(import.meta.url);
return req("systray2").default;
},
];
for (const attempt of candidates) {
try {
return attempt();
} catch {}
}
return null;
// systray2 is NOT a static dependency — it is lazily installed into
// ~/.omniroute/runtime by trayRuntime.ts (loadSystray). The previous inline
// loader called `require("module")`, which throws `ReferenceError: require is
// not defined` in this ESM file (package "type":"module"); the throw was
// silently swallowed, so the tray never appeared on macOS/Linux with no error
// printed (#4605, regressed in v3.8.34). Delegate to the runtime loader, which
// resolves systray2 from the runtime dir and surfaces install/import failures.
async function loadSystray2() {
const { loadSystray } = await import("../runtime/trayRuntime.ts");
return loadSystray();
}
function getIconBase64() {
const iconPath = join(__dirname, "icons", "icon.png");
// Icon ships at bin/cli/tray/icon.png — the previous "icons/icon.png" path
// never existed, so the tray was created with an empty icon (#4605).
const iconPath = join(__dirname, "icon.png");
if (existsSync(iconPath)) return readFileSync(iconPath).toString("base64");
return "";
}
export function initSystrayUnix({ port, onQuit, onOpenDashboard, onShowLogs }) {
const SysTray = loadSystray2();
export async function initSystrayUnix(
{ port, onQuit, onOpenDashboard, onShowLogs },
loadCtor = loadSystray2
) {
const SysTray = await loadCtor();
if (!SysTray) return null;
const autostartEnabled = isAutostartEnabled();
@@ -57,7 +58,10 @@ export function initSystrayUnix({ port, onQuit, onOpenDashboard, onShowLogs }) {
tray = new SysTray({
menu: {
icon: getIconBase64(),
isTemplateIcon: process.platform === "darwin",
// isTemplateIcon must be false: icon.png is a full-color RGBA logo, and
// macOS template mode uses only the alpha channel → a solid white square
// (the icon looked "missing" even when the tray loaded). (PR #1080)
isTemplateIcon: false,
title: "",
tooltip: `OmniRoute — port ${port}`,
items,

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# bin/cold-start-bench.sh — measure OmniRoute cold-start against the budgets in
# docs/PERF_BUDGETS.md §5 (container start → HTTP listening ≤ 800 ms; first warm
# bin/cold-start-bench.sh — measure OmniRoute cold-start against the target
# budgets (container start → HTTP listening ≤ 800 ms; first warm
# TTFB ≤ 200 ms). Boots the server on a throwaway port, times until
# /api/health/ping answers 200, measures a warm request, and reports PASS/FAIL.
set -euo pipefail
@@ -13,7 +13,7 @@ Usage: bin/cold-start-bench.sh [--port <n>] [--start-cmd "<cmd>"] [--url <base>]
[--listen-budget-ms <n>] [--ttfb-budget-ms <n>] [-h|--help]
Boots OmniRoute, times cold-start to the first /api/health/ping 200, measures
warm TTFB, and compares against the PERF_BUDGETS.md §5 budgets
warm TTFB, and compares against the cold-start budgets
(listen ≤ 800 ms, TTFB ≤ 200 ms). Exits non-zero if a budget is exceeded.
--url benches an already-running server instead of booting one (skips the boot

View File

@@ -1,7 +1,6 @@
#!/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 }),
Object.freeze({ major: 24, minor: 0, patch: 0 }),
Object.freeze({ major: 25, minor: 0, patch: 0 }),
@@ -9,9 +8,9 @@ export const SECURE_NODE_LINES = Object.freeze([
]);
export const RECOMMENDED_NODE_VERSION = "24.14.1";
export const SUPPORTED_NODE_RANGE = ">=20.20.2 <21 || >=22.22.2 <23 || >=24.0.0 <27";
export const SUPPORTED_NODE_RANGE = ">=22.22.2 <23 || >=24.0.0 <27";
export const SUPPORTED_NODE_DISPLAY =
"Node.js 20.20.2+ (20.x LTS), 22.22.2+ (22.x LTS), 24.0.0+ (24.x LTS), 25.0.0+ (25.x), or 26.0.0+ (26.x)";
"Node.js 22.22.2+ (22.x LTS), 24.0.0+ (24.x LTS), 25.0.0+ (25.x), or 26.0.0+ (26.x)";
function formatVersion(version) {
return `${version.major}.${version.minor}.${version.patch}`;
@@ -78,7 +77,7 @@ export function getNodeRuntimeWarning(version = process.versions.node) {
}
if (support.reason === "unreleased-major") {
return `Node.js ${support.nodeVersion} is outside the supported LTS lines. OmniRoute currently supports Node.js 20.x, 22.x, 24.x, 25.x, and 26.x.`;
return `Node.js ${support.nodeVersion} is outside the supported LTS lines. OmniRoute currently supports Node.js 22.x, 24.x, 25.x, and 26.x.`;
}
return `Node.js ${support.nodeVersion} is outside OmniRoute's approved secure runtime policy.`;

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# bin/restore-data.sh — restore the OmniRoute SQLite data volume from a snapshot
# created by bin/snapshot-data.sh. Used by the data-layer incident runbook
# (docs/INCIDENT_RESPONSE.md §4.4) after stopping writers.
# created by bin/snapshot-data.sh. Used by the data-layer incident-recovery
# flow after stopping writers.
#
# Safety: takes a pre-restore snapshot of the CURRENT data, refuses to run
# unattended without --yes, and verifies the snapshot before overwriting.

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# bin/restore-policies.sh — restore ONLY the API-key policy tables from a
# snapshot, leaving request/audit/runtime state intact. Used by the auth-layer
# incident runbook (docs/INCIDENT_RESPONSE.md §4.3) when policies_active is empty
# incident-recovery flow when policies_active is empty
# but the rest of the database is healthy (so a full restore-data is overkill).
#
# "Policy" tables = api_key* definition tables (the key + its limits/allowed

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# bin/rollback.sh — roll OmniRoute back to a previous release to mitigate a bad
# deploy. Used by the incident runbook (docs/INCIDENT_RESPONSE.md §3 / §4).
# deploy. Part of the deploy-rollback incident-recovery flow.
#
# Methods (auto-detected; override with --method):
# • npm — `npm install -g omniroute@<version>` and, if PM2 manages it,

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# bin/snapshot-data.sh — consistent point-in-time snapshot of the OmniRoute data
# volume (the SQLite store under $DATA_DIR). Used by the data-layer incident
# runbook (docs/INCIDENT_RESPONSE.md §4.4) before any restore.
# volume (the SQLite store under $DATA_DIR). Used by the data-layer
# incident-recovery flow before any restore.
#
# Output: a directory $DB_BACKUPS_DIR/snapshot_<UTC>[_<label>] holding a
# `VACUUM INTO` copy of storage.sqlite (consistent even with WAL writers active)