@@ -461,7 +461,7 @@ All **19** strategies — mix & match per combo step:
-
+📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md)
@@ -559,7 +559,7 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
- **🖼️ New endpoints** — `/v1/ocr` (Mistral OCR) and `/v1/audio/translations` (Whisper-style) round out the media surface. → [API Reference](docs/reference/API_REFERENCE.md)
- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind, EdgeTTS. → [API Reference](docs/reference/API_REFERENCE.md)
- **🌍 Deployment & ops** — reverse-proxy `basePath`, browser-language auto-detect, per-key device tracking, root-less MITM trust, zh-TW localization. → [Environment](docs/reference/ENVIRONMENT.md)
-- **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **346-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md)
+- **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **348-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md)
- **📡 Routing transparency** — every response carries an `X-OmniRoute-Decision` header naming the strategy/provider/latency that served it, a new `cache-optimized` combo strategy + Auto-Combo `cacheAffinity` factor route repeat requests back to the connection holding the cached prefix, and a read-only `/v1/auto-combo/{channel}/candidates` endpoint exposes an `auto/*` channel's live candidate pool. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
- **⚡ Local performance & infra** — one-click local Redis, Cloudflare Workers / Deno Deploy relay deployers, Bifrost & Mux as supervised embedded services. → [Embedded Services](docs/frameworks/EMBEDDED-SERVICES.md)
@@ -642,11 +642,11 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
-## 🌐 346 AI Providers — 90+ Free
+## 🌐 348 AI Providers — 90+ Free
-> The most complete catalog of any open-source router: **346 providers**, **90+ with a free tier**, **57 free forever**.
+> The most complete catalog of any open-source router: **348 providers**, **90+ with a free tier**, **56 free forever**.
@@ -821,7 +821,7 @@ Expose OmniRoute over **MCP**, **A2A**, a **REST API**, **webhooks** or a **remo
Interface
Endpoint / command
Use it for
🧰 MCP (stdio)
omniroute --mcp
Plug into Claude Desktop, Cursor, any MCP client
-
🌊 MCP (HTTP)
/api/mcp/stream
Remote MCP — 109 tools, 33 scopes, full audit trail
+
🌊 MCP (HTTP)
/api/mcp/stream
Remote MCP — 110 tools, 33 scopes, full audit trail
@@ -988,14 +988,40 @@ docker run -d --name omniroute --restart unless-stopped --stop-timeout 40 \
-p 127.0.0.1:20128:20128 -v omniroute-data:/app/data diegosouzapw/omniroute:latest
```
-`:latest` follows the highest **published** stable SemVer. It does not track git `main`. Pin `:X.Y.Z` for GitOps. See [Docker Release Channels](docs/guides/DOCKER_GUIDE.md#release-channels).
+`:latest` follows the highest **published** stable SemVer. It does not track git `main`. Pin `:X.Y.Z` for GitOps. See [Docker Release Channels](docs/guides/DOCKER_GUIDE.md#release-channels).The image pins **`OMNIROUTE_MEMORY_MB=1024`**. That is enough for the dashboard and a light chat. **Coding agents** (`POST /v1/responses` from Claude Code, Codex, Grok, …) need a much larger V8 heap or the process `FATAL ERROR`s at ~12 GiB under two overlapping long contexts. Size the container above the heap (native buffers sit outside V8):
+| Workload | Heap (`-e OMNIROUTE_MEMORY_MB`) | Container (`--memory`) |
+| --- | --- | --- |
+| Dashboard / light chat | `1024` (image default) | ≥2 g |
+| One coding agent | `8192` | ≥10 g |
+| Two concurrent long `/v1/responses` | `10240`–`12288` | ≥12–16 g |
+
+```bash
+docker run -d --name omniroute --restart unless-stopped --stop-timeout 40 \
+ -e OMNIROUTE_MEMORY_MB=8192 --memory=10g \
+ -p 127.0.0.1:20128:20128 -v omniroute-data:/app/data diegosouzapw/omniroute:latest
+```
+
+Full table: [Docker Guide — runtime RAM](docs/guides/DOCKER_GUIDE.md#runtime-ram-for-coding-agents).
> **Pre-release Docker channel:** `diegosouzapw/omniroute:next` and
> `diegosouzapw/omniroute:next-web` follow the current default `release/v*`
> branch. These mutable tags are intended only for testing unreleased fixes and
> are **not supported for production**. See
> [Docker Release Channels](docs/guides/DOCKER_GUIDE.md#release-channels).
+**🥟 Bun**
+
+Standard `bun install` and global installation (`bun install -g omniroute`) are supported via Bun runtime detection:
+- **Built-in `bun:sqlite`**: OmniRoute uses Bun's built-in `bun:sqlite` driver when running under Bun, falling back to `better-sqlite3` on Node.js or `sql.js`.
+- **Automatic Webpack bundler selection**: Development (`bun run dev`) and production builds (`bun run build`) automatically detect Bun and disable Turbopack in favor of Webpack to prevent native V8 binding incompatibilities.
+- **Dedicated Bun Dockerfile**: Multi-stage `Dockerfile.bun` for native Bun production deployments (`docker build -f Dockerfile.bun -t omniroute:bun .`).
+
+```bash
+# Install and run with Bun
+bun install
+bun run dev
+```
+
**🛠️ From source**
```bash
@@ -1174,7 +1200,7 @@ Métricas de validação: 1002 vídeos rastreados · 7,069,190 visualizações c
diff --git a/bin/cli/api-commands/combos.mjs b/bin/cli/api-commands/combos.mjs
index e4e4ff62f50..8f1976be239 100644
--- a/bin/cli/api-commands/combos.mjs
+++ b/bin/cli/api-commands/combos.mjs
@@ -30,20 +30,60 @@ export function register_combos(parent) {
const data = res.ok ? await res.json() : await res.text();
emit(data, gOpts);
});
+ tag.command("get-api-combos-id-")
+ .description("Get combo by ID")
+ .requiredOption("--id ", "")
+ .action(async (opts, cmd) => {
+ const gOpts = cmd.optsWithGlobals();
+ let url = "/api/combos/{id}";
+ url = url.replace("{id}", encodeURIComponent(opts.id ?? ""));
+ const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
+ const data = res.ok ? await res.json() : await res.text();
+ emit(data, gOpts);
+ });
+ tag.command("put-api-combos-id-")
+ .description("Update combo")
+ .requiredOption("--id ", "")
+ .option("--body ", "JSON body or @path/to/file.json")
+ .action(async (opts, cmd) => {
+ const gOpts = cmd.optsWithGlobals();
+ let url = "/api/combos/{id}";
+ url = url.replace("{id}", encodeURIComponent(opts.id ?? ""));
+ let body;
+ if (opts.body) {
+ body = opts.body.startsWith("@")
+ ? JSON.parse(readFileSync(opts.body.slice(1), "utf8"))
+ : JSON.parse(opts.body);
+ }
+ const res = await apiFetch(url, { method: "PUT", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
+ const data = res.ok ? await res.json() : await res.text();
+ emit(data, gOpts);
+ });
tag.command("patch-api-combos-id-")
.description("Update combo")
+ .requiredOption("--id ", "")
+ .option("--body ", "JSON body or @path/to/file.json")
.action(async (opts, cmd) => {
const gOpts = cmd.optsWithGlobals();
let url = "/api/combos/{id}";
- const res = await apiFetch(url, { method: "PATCH", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
+ url = url.replace("{id}", encodeURIComponent(opts.id ?? ""));
+ let body;
+ if (opts.body) {
+ body = opts.body.startsWith("@")
+ ? JSON.parse(readFileSync(opts.body.slice(1), "utf8"))
+ : JSON.parse(opts.body);
+ }
+ const res = await apiFetch(url, { method: "PATCH", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
const data = res.ok ? await res.json() : await res.text();
emit(data, gOpts);
});
tag.command("delete-api-combos-id-")
.description("Delete combo")
+ .requiredOption("--id ", "")
.action(async (opts, cmd) => {
const gOpts = cmd.optsWithGlobals();
let url = "/api/combos/{id}";
+ url = url.replace("{id}", encodeURIComponent(opts.id ?? ""));
const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
const data = res.ok ? await res.json() : await res.text();
emit(data, gOpts);
diff --git a/bin/cli/commands/combo.mjs b/bin/cli/commands/combo.mjs
index 1cd8bb06001..8d58cf73bd9 100644
--- a/bin/cli/commands/combo.mjs
+++ b/bin/cli/commands/combo.mjs
@@ -4,6 +4,7 @@ import { withRuntime } from "../runtime.mjs";
import { t } from "../i18n.mjs";
import { apiFetch } from "../api.mjs";
import { emit } from "../output.mjs";
+import { resolveComboModels, collectModel } from "./comboModels.mjs";
const VALID_STRATEGIES = [
"priority",
@@ -125,10 +126,31 @@ export function registerCombo(program) {
.choices(VALID_STRATEGIES)
.default("priority")
)
+ .option(
+ "--models ",
+ "Models for the combo: comma-separated provider/model entries, or a JSON array " +
+ '(e.g. --models "openai/gpt-4o,anthropic/claude-3-opus" or ' +
+ '--models \'[{"model":"gpt-4o","providerId":"openai"}]\')'
+ )
+ .option(
+ "--model ",
+ "Add one model to the combo (provider/model or bare model id) — repeatable",
+ collectModel,
+ []
+ )
.action(async (name, opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
+ let models;
+ try {
+ models = resolveComboModels(opts);
+ } catch (err) {
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
+ process.exit(1);
+ return;
+ }
const exitCode = await runComboCreateCommand(name, opts.strategy, {
...opts,
+ models,
output: globalOpts.output,
});
if (exitCode !== 0) process.exit(exitCode);
@@ -284,12 +306,20 @@ export async function runComboCreateCommand(name, strategy = "priority", opts =
return 1;
}
+ const models = Array.isArray(opts.models) ? opts.models : [];
+ if (!models.length) {
+ console.error(
+ "combo create requires at least one target. Pass --models and/or repeat --model ."
+ );
+ return 1;
+ }
+
try {
return await withRuntime(async ({ kind, api, db }) => {
if (kind === "http") {
const res = await api("/api/combos", {
method: "POST",
- body: { name, strategy, enabled: true, models: [], config: {} },
+ body: { name, strategy, enabled: true, models, config: {} },
retry: false,
acceptNotOk: true,
});
@@ -305,7 +335,7 @@ export async function runComboCreateCommand(name, strategy = "priority", opts =
console.error(`Combo '${name}' already exists. Delete it first.`);
return 1;
}
- await db.combos.createCombo({ name, strategy, enabled: true, models: [], config: {} });
+ await db.combos.createCombo({ name, strategy, enabled: true, models, config: {} });
}
console.log(t("combo.created", { name }));
diff --git a/bin/cli/commands/comboModels.mjs b/bin/cli/commands/comboModels.mjs
new file mode 100644
index 00000000000..fec9dc8470f
--- /dev/null
+++ b/bin/cli/commands/comboModels.mjs
@@ -0,0 +1,142 @@
+// Parses the `--models` / `--model` options for `omniroute combo create` (#10954).
+//
+// Root cause of #10954: `combo create` only ever registered `--strategy`; the
+// HTTP body (POST /api/combos) and the local-db fallback (db.combos.createCombo)
+// both hardcoded `models: []`, so every combo created via the CLI came out
+// empty regardless of what the operator intended to route to.
+//
+// Accepted shapes mirror the server-side Zod union in
+// `src/shared/validation/schemas/combo.ts` (`comboModelEntry` /
+// `createComboSchema.models`) so a CLI-built payload never gets rejected by
+// the API that ultimately validates it:
+// - a plain string ("provider/model" or a bare model id) — the server's
+// `normalizeComboModels` (src/lib/combos/steps.ts) already splits the
+// leading "provider/" segment off a plain string, so passing the raw
+// token through is sufficient for the common case;
+// - a structured `{ kind?: "model", model, providerId?, provider?, ... }`
+// object;
+// - a structured `{ kind: "combo-ref", comboName, ... }` object (nested
+// combo reference).
+//
+// The CLI (bin/cli/**) ships as plain `.mjs` with relative-only imports — no
+// `@/` path aliases and no TS transpilation at runtime — so importing the
+// real Zod schema from `src/shared/validation/schemas/combo.ts` is not
+// viable here. This module instead validates the same minimal shape by hand
+// and stays a thin, independently testable unit.
+
+/**
+ * Validates one already-parsed combo model entry against the shape accepted
+ * by `comboModelEntry` (string | model-step | combo-ref). Throws with a
+ * 1-based, human-readable position when the entry does not match.
+ *
+ * @param {unknown} entry
+ * @param {number} index
+ * @returns {string | Record}
+ */
+export function validateComboModelEntryShape(entry, index) {
+ const position = index + 1;
+
+ if (typeof entry === "string") {
+ const trimmed = entry.trim();
+ if (trimmed.length === 0) {
+ throw new Error(`--models entry #${position}: empty model string`);
+ }
+ if (trimmed.length > 300) {
+ throw new Error(`--models entry #${position}: model string exceeds 300 characters`);
+ }
+ return trimmed;
+ }
+
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
+ throw new Error(`--models entry #${position}: must be a string or a JSON object`);
+ }
+
+ const kind = entry.kind;
+
+ if (kind === "combo-ref") {
+ if (typeof entry.comboName !== "string" || entry.comboName.trim().length === 0) {
+ throw new Error(
+ `--models entry #${position}: kind "combo-ref" requires a non-empty "comboName"`
+ );
+ }
+ return entry;
+ }
+
+ if (kind !== undefined && kind !== "model") {
+ throw new Error(`--models entry #${position}: unknown "kind" value ${JSON.stringify(kind)}`);
+ }
+
+ if (typeof entry.model !== "string" || entry.model.trim().length === 0) {
+ throw new Error(`--models entry #${position}: requires a non-empty "model"`);
+ }
+ if (entry.providerId !== undefined && typeof entry.providerId !== "string") {
+ throw new Error(`--models entry #${position}: "providerId" must be a string`);
+ }
+ if (entry.provider !== undefined && typeof entry.provider !== "string") {
+ throw new Error(`--models entry #${position}: "provider" must be a string`);
+ }
+
+ return entry;
+}
+
+/**
+ * Parses one `--models` spec — either a JSON array (`--models '[{"model":"gpt-4o"}]'`)
+ * or a comma-separated list of provider/model tokens
+ * (`--models 'openai/gpt-4o,anthropic/claude-3-opus'`) — into an array of
+ * combo model entries.
+ *
+ * @param {string} spec
+ * @returns {Array>}
+ */
+export function parseModelsSpec(spec) {
+ const trimmed = String(spec ?? "").trim();
+ if (trimmed.length === 0) return [];
+
+ if (trimmed.startsWith("[")) {
+ let parsed;
+ try {
+ parsed = JSON.parse(trimmed);
+ } catch (err) {
+ throw new Error(`--models: invalid JSON array (${err.message})`);
+ }
+ if (!Array.isArray(parsed)) {
+ throw new Error("--models: JSON value must be an array");
+ }
+ return parsed.map((entry, i) => validateComboModelEntryShape(entry, i));
+ }
+
+ return trimmed
+ .split(",")
+ .map((token) => token.trim())
+ .filter((token) => token.length > 0)
+ .map((token, i) => validateComboModelEntryShape(token, i));
+}
+
+/**
+ * Resolves the final `models` array for `combo create` from Commander opts:
+ * `--models ` and/or repeatable `--model `.
+ *
+ * @param {{ models?: string, model?: string[] }} opts
+ * @returns {Array>}
+ */
+export function resolveComboModels(opts = {}) {
+ const result = [];
+
+ if (typeof opts.models === "string" && opts.models.trim().length > 0) {
+ result.push(...parseModelsSpec(opts.models));
+ }
+
+ if (Array.isArray(opts.model)) {
+ opts.model.forEach((token, i) => {
+ result.push(validateComboModelEntryShape(String(token).trim(), i));
+ });
+ }
+
+ return result;
+}
+
+/** Commander `collect`-style reducer for the repeatable `--model` option. */
+export function collectModel(value, previous) {
+ previous.push(value);
+ return previous;
+}
diff --git a/bin/cli/commands/oauth.mjs b/bin/cli/commands/oauth.mjs
index 8bf547b2c00..c9f8386d2b9 100644
--- a/bin/cli/commands/oauth.mjs
+++ b/bin/cli/commands/oauth.mjs
@@ -228,20 +228,38 @@ async function runSocialFlow(def, opts) {
async function runDeviceFlow(def, opts) {
const providerKey = resolveBackendKey(def.id);
- const startRes = await apiFetch(`/api/providers/${providerKey}/auth/start`, {
- ...targetApiOptions(opts),
- method: "POST",
- });
+ let startRes = await apiFetch(`/api/oauth/${providerKey}/device-code`, targetApiOptions(opts));
+ if (!startRes.ok) {
+ startRes = await apiFetch(`/api/providers/${providerKey}/auth/start`, {
+ ...targetApiOptions(opts),
+ method: "POST",
+ });
+ }
if (!startRes.ok) {
process.stderr.write(`Failed to start device flow: ${startRes.status}\n`);
process.exit(1);
}
const start = await startRes.json();
- process.stdout.write(
- `\nDevice code: ${start.userCode ?? start.user_code ?? ""}\nVisit: ${start.verificationUri ?? start.verification_uri}\n\n`
- );
- if (opts.browser !== false)
- await openBrowser(start.verificationUri ?? start.verification_uri ?? "");
+ const userCode = start.userCode ?? start.user_code ?? "";
+ const verificationUri =
+ start.verificationUriComplete ??
+ start.verification_uri_complete ??
+ start.verificationUri ??
+ start.verification_uri ??
+ start.authUrl ??
+ start.url ??
+ "";
+
+ if (userCode) {
+ process.stdout.write(`\nDevice code: ${userCode}\nVisit: ${verificationUri}\n\n`);
+ } else if (verificationUri) {
+ process.stdout.write(`\nVisit: ${verificationUri}\n\n`);
+ } else {
+ process.stdout.write(`\nAuthorization URL not available\n\n`);
+ }
+
+ if (opts.browser !== false && verificationUri)
+ await openBrowser(verificationUri);
process.stderr.write("Waiting for device authorization...\n");
const deadline = Date.now() + (opts.timeout ?? 300000);
const intervalMs = (start.intervalMs ?? start.interval ?? 5) * 1000;
diff --git a/bin/cli/commands/plugin.mjs b/bin/cli/commands/plugin.mjs
index fc433a88ea4..971c9ef231b 100644
--- a/bin/cli/commands/plugin.mjs
+++ b/bin/cli/commands/plugin.mjs
@@ -9,10 +9,13 @@ import { discoverPlugins } from "../plugins.mjs";
// (instead of string-interpolating into `execSync`) prevents a malicious plugin
// name like `foo; rm -rf ~` or `` foo`id` `` from being interpreted by the shell.
function runNpm(args) {
- const res = spawnSync("npm", args, { stdio: "inherit", shell: false });
+ const isBun = Boolean(process.versions.bun);
+ const pm = isBun ? "bun" : "npm";
+ const cmdArgs = isBun && args[0] === "install" ? ["add", ...args.slice(1)] : args;
+ const res = spawnSync(pm, cmdArgs, { stdio: "inherit", shell: false });
if (res.error) throw res.error;
if (typeof res.status === "number" && res.status !== 0) {
- throw new Error(`npm exited with code ${res.status}`);
+ throw new Error(`${pm} exited with code ${res.status}`);
}
}
diff --git a/bin/cli/runtime/nativeDeps.mjs b/bin/cli/runtime/nativeDeps.mjs
index 60e4d219531..ba5274f3c6a 100644
--- a/bin/cli/runtime/nativeDeps.mjs
+++ b/bin/cli/runtime/nativeDeps.mjs
@@ -114,30 +114,30 @@ export function isBetterSqliteBinaryValid() {
export function npmInstallRuntime(pkgs, opts = {}) {
const cwd = ensureRuntimeDir();
- // Persist to the runtime package.json (exact version) instead of --no-save so a later
- // install of a sibling runtime dep (e.g. systray2 from trayRuntime.ts, which writes to the
- // same runtime dir) does not prune this package as "extraneous" — that pruning otherwise
- // reproduces "No SQLite driver available" after a tray install removes better-sqlite3.
- // npm 12+ defaults `allowScripts` to off, silently skipping lifecycle/install
- // scripts (e.g. better-sqlite3's node-gyp/prebuild-install rebuild) unless the
- // package has a matching `allowScripts` entry — and still exits 0, masking the
- // failure (#10713). The runtime dir is a CLI-owned, non-user package.json, so
- // explicitly allowing scripts for the packages we are installing here is safe.
- const npmArgs = [
- "install",
- ...pkgs,
- "--no-audit",
- "--no-fund",
- "--prefer-online",
- "--save-exact",
- ...pkgs.map((pkg) => `--allow-scripts=${pkg}`),
- ];
- // On Windows .cmd files cannot be executed without a shell; use cmd.exe /c explicitly
- // so we never set shell:true (which would propagate env and enable injection).
const isWin = platform() === "win32";
- const [exe, args] = isWin ? ["cmd.exe", ["/c", "npm", ...npmArgs]] : ["npm", npmArgs];
+ const isBun = Boolean(process.versions.bun);
+
+ let exe, args, displayCmd;
+ if (isBun) {
+ const bunArgs = ["add", ...pkgs, "--trust"];
+ [exe, args] = isWin ? ["cmd.exe", ["/c", "bun", ...bunArgs]] : ["bun", bunArgs];
+ displayCmd = `bun ${bunArgs.join(" ")}`;
+ } else {
+ const npmArgs = [
+ "install",
+ ...pkgs,
+ "--no-audit",
+ "--no-fund",
+ "--prefer-online",
+ "--save-exact",
+ ...pkgs.map((pkg) => `--allow-scripts=${pkg}`),
+ ];
+ [exe, args] = isWin ? ["cmd.exe", ["/c", "npm", ...npmArgs]] : ["npm", npmArgs];
+ displayCmd = `npm ${npmArgs.join(" ")}`;
+ }
+
if (!opts.silent) {
- process.stdout.write(`[omniroute][runtime] npm ${npmArgs.join(" ")}\n`);
+ process.stdout.write(`[omniroute][runtime] ${displayCmd}\n`);
}
const res = spawnSync(exe, args, {
cwd,
diff --git a/bin/cli/sqlite.mjs b/bin/cli/sqlite.mjs
index ce14541480f..982fef3520a 100644
--- a/bin/cli/sqlite.mjs
+++ b/bin/cli/sqlite.mjs
@@ -5,10 +5,14 @@ import { ensureSettingsSchema, hashManagementPassword, updateSettings } from "./
async function loadSqlite() {
if (process.versions.bun) {
- return { Database: (await import("bun:sqlite")).Database };
+ try {
+ return { Database: (await import("bun:sqlite")).Database, driver: "bun:sqlite" };
+ } catch (bunError) {
+ // fall through to better-sqlite3 if bun:sqlite fails
+ }
}
try {
- return { Database: (await import("better-sqlite3")).default };
+ return { Database: (await import("better-sqlite3")).default, driver: "better-sqlite3" };
} catch (error) {
return { error };
}
@@ -86,12 +90,14 @@ export function normalizeBunSqliteParams(params) {
export function createSqliteNativeError(error) {
const message = error instanceof Error ? error.message : String(error);
+ const isBun = Boolean(process.versions.bun);
+ const rebuildCmd = isBun ? "bun add better-sqlite3 --trust" : "npm rebuild better-sqlite3";
if (message.includes("NODE_MODULE_VERSION") || message.includes("ERR_DLOPEN_FAILED")) {
return new Error(
- "better-sqlite3 native binding is incompatible with this Node.js runtime. " +
- "Run `npm rebuild better-sqlite3` in the OmniRoute project and try again. " +
- "Or run: omniroute runtime repair " +
- "(rebuilds into a user-writable runtime; works without a C++ toolchain)."
+ `better-sqlite3 native binding is incompatible with this runtime. ` +
+ `Run \`${rebuildCmd}\` in the OmniRoute project and try again. ` +
+ `Or run: omniroute runtime repair ` +
+ `(rebuilds into a user-writable runtime; works without a C++ toolchain).`
);
}
if (
@@ -100,10 +106,9 @@ export function createSqliteNativeError(error) {
message.includes("Cannot find module 'better-sqlite3'")
) {
return new Error(
- "better-sqlite3 native binding could not be found (no prebuilt addon for this platform). " +
- "This is common under `npx`, which runs a fresh, ephemeral install that never built the addon. " +
- "Run: omniroute runtime repair " +
- "(rebuilds into a user-writable runtime; works without a C++ toolchain)."
+ `better-sqlite3 native binding could not be found (no prebuilt addon for this platform). ` +
+ `Run: omniroute runtime repair ` +
+ `(rebuilds into a user-writable runtime; works without a C++ toolchain).`
);
}
return error;
@@ -111,7 +116,7 @@ export function createSqliteNativeError(error) {
async function openSqliteDatabase(dbPath, options = {}) {
const loaded = await loadSqlite();
- if (process.versions.bun) {
+ if (loaded.driver === "bun:sqlite" || (process.versions.bun && !loaded.Database)) {
if (options.fileMustExist && !fs.existsSync(dbPath)) {
throw new Error(`SQLite file does not exist: ${dbPath}`);
}
diff --git a/bin/cli/utils/ensureAndroidCacheDir.mjs b/bin/cli/utils/ensureAndroidCacheDir.mjs
index 30fe073f8b4..0e3f2d20ec4 100644
--- a/bin/cli/utils/ensureAndroidCacheDir.mjs
+++ b/bin/cli/utils/ensureAndroidCacheDir.mjs
@@ -94,10 +94,15 @@ export function ensureAndroidCacheDir(options = {}) {
*/
export function isFatalInstrumentationHookFailure(text) {
if (!text) return false;
- return (
- /Unsupported platform:\s*android/i.test(text) ||
- /error occurred while loading instrumentation hook/i.test(text)
- );
+ // Next.js wraps ANY throw inside instrumentation.register() with the generic
+ // "An error occurred while loading instrumentation hook:" prefix, on every
+ // platform (node_modules/next/dist/server/web/globals.js). That prefix alone
+ // therefore cannot identify the Android/Termux cache-probe failure — a bare
+ // generic instrumentation error on win32/desktop would be misreported as the
+ // Android bug and hide the real cause. Only match when the text actually
+ // carries the Android platform marker that Next's getCacheDirectory() emits.
+ // #10028
+ return /Unsupported platform:\s*android/i.test(text);
}
/**
diff --git a/bin/nodeRuntimeSupport.mjs b/bin/nodeRuntimeSupport.mjs
index 47905f0e4ff..8f8f88f6832 100644
--- a/bin/nodeRuntimeSupport.mjs
+++ b/bin/nodeRuntimeSupport.mjs
@@ -44,6 +44,18 @@ export function getSecureFloorForMajor(major) {
}
export function getNodeRuntimeSupport(version = process.versions.node) {
+ if (process.versions.bun) {
+ return {
+ nodeVersion: `bun-${process.versions.bun} (Node.js API ${version})`,
+ nodeCompatible: true,
+ reason: "supported-bun",
+ supportedRange: SUPPORTED_NODE_RANGE + " || Bun >=1.1.0",
+ supportedDisplay: SUPPORTED_NODE_DISPLAY + ", or Bun 1.1+",
+ recommendedVersion: `v${RECOMMENDED_NODE_VERSION}`,
+ minimumSecureVersion: null,
+ };
+ }
+
const parsed = parseNodeVersion(version);
const secureFloor = getSecureFloorForMajor(parsed.major);
const nodeCompatible = secureFloor ? compareNodeVersions(parsed, secureFloor) >= 0 : false;
diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs
index fb0a4555208..09b133df4f3 100755
--- a/bin/omniroute.mjs
+++ b/bin/omniroute.mjs
@@ -17,7 +17,12 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
-import updateNotifier from "update-notifier";
+let updateNotifier = null;
+try {
+ updateNotifier = (await import("update-notifier")).default;
+} catch {
+ // update-notifier is optional in pruned standalone environments
+}
import { isNativeBinaryCompatible } from "../scripts/build/native-binary-compat.mjs";
import { getNodeRuntimeSupport, getNodeRuntimeWarning } from "./nodeRuntimeSupport.mjs";
import { getDefaultDataDir } from "./cli/data-dir.mjs";
@@ -251,8 +256,9 @@ if (shouldProvisionStorageKey(process.argv)) {
// Register update notifier — checks npm once per 24h, notifies on exit via stderr.
const _pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
-const _notifier = updateNotifier({ pkg: _pkg, updateCheckInterval: 1000 * 60 * 60 * 24 });
+const _notifier = updateNotifier ? updateNotifier({ pkg: _pkg, updateCheckInterval: 1000 * 60 * 60 * 24 }) : null;
process.on("exit", () => {
+ if (!_notifier || !_notifier.update) return;
if (process.env.OMNIROUTE_NO_UPDATE_NOTIFIER) return;
if (process.env.CI) return;
if (process.argv.includes("--quiet") || process.argv.includes("-q")) return;
diff --git a/changelog.d/features/10926-rankings-usage-reliability.md b/changelog.d/features/10926-rankings-usage-reliability.md
new file mode 100644
index 00000000000..a79b92b4cf6
--- /dev/null
+++ b/changelog.d/features/10926-rankings-usage-reliability.md
@@ -0,0 +1 @@
+- **feat(rankings):** free provider rankings can now report what each provider actually served — `reliability.usage` (requests, successes, success rate over a window) behind the opt-in `withUsage`/`usageRange` query parameters, so a provider that answers every call with an error is no longer described as healthy ([#10926](https://github.com/diegosouzapw/OmniRoute/pull/10926))
diff --git a/changelog.d/features/11104-operator-error-rules.md b/changelog.d/features/11104-operator-error-rules.md
new file mode 100644
index 00000000000..f31e78c01f3
--- /dev/null
+++ b/changelog.d/features/11104-operator-error-rules.md
@@ -0,0 +1 @@
+- **feat(providers):** let operators declare per-provider error rules through `settings.providerErrorRules` instead of patching the catalog — an operator-supplied rule for a provider is consulted before the built-in `providerRuleRegistry`, receives the raw error text, and has its declared scope/cooldown/reason actually honored end to end, for any provider (declaring the rule is the opt-in — no extra allowlist entry needed). Matches are plain case-insensitive substrings (never RegExp) and bounded to 50 rules to keep the hot path safe ([#11104](https://github.com/diegosouzapw/OmniRoute/pull/11104))
diff --git a/changelog.d/features/11190-usage-command-json.md b/changelog.d/features/11190-usage-command-json.md
new file mode 100644
index 00000000000..d7655f04c51
--- /dev/null
+++ b/changelog.d/features/11190-usage-command-json.md
@@ -0,0 +1 @@
+- **feat(api):** `/api/usage/om-usage` gains a structured form — `?format=json` returns the key's own usage as `ApiKeyUsageLimitStatus` + `UsageSnapshot` instead of `text/plain`. This is the surface a UI (the OmniCopilot panel) consumes to show a key holder their daily/weekly spend and quota reset. The route is self-service (the caller's own key, gated by `allowUsageCommand`), not the management surface; refusals come back as a discriminated `{ "allowed": false, "error": … }` so a UI can tell "not allowed" apart from "allowed but nothing cached yet". The endpoint was previously undocumented in `API_REFERENCE.md`; it now has a section ([#11190](https://github.com/diegosouzapw/OmniRoute/pull/11190))
diff --git a/changelog.d/features/11192-usage-command-providers-array.md b/changelog.d/features/11192-usage-command-providers-array.md
new file mode 100644
index 00000000000..b7ef4211091
--- /dev/null
+++ b/changelog.d/features/11192-usage-command-providers-array.md
@@ -0,0 +1 @@
+- **feat(api):** `/api/usage/om-usage?format=json` now returns `providers[]` — every connection's quota snapshot, not just the single selected one — so a panel can render Codex / Claude / OpenCode side by side. The collector already gathered all of them; the single-pick `provider` field (kept) is a terminal presentation choice. Closes the per-connection gap from OmniCopilot #8 ([#11192](https://github.com/diegosouzapw/OmniRoute/pull/11192))
diff --git a/changelog.d/features/m365-copilot-tool-calls.md b/changelog.d/features/m365-copilot-tool-calls.md
new file mode 100644
index 00000000000..bfafe08033e
--- /dev/null
+++ b/changelog.d/features/m365-copilot-tool-calls.md
@@ -0,0 +1 @@
+- **feat(providers):** copilot-m365-web now supports OpenAI tool calling — a router planning turn asks the substrate model (as a tool-selection assistant emitting `CALL_TOOL: name({...})` / `NO_TOOL_NEEDED` text, which bypasses its plugin-registry refusal) and validated decisions surface as `tool_calls` with `finish_reason: "tool_calls"` in both stream and non-stream modes; also flattens the full message history (assistant `tool_calls` + compacted tool results) so multi-turn agent loops keep context, replies to SignalR `type:6` keepalives, surfaces `type:3` error frames instead of a silent empty `stop`, and suppresses `writeAtCursor` text from tool-progress frames
diff --git a/changelog.d/fixes/10028-windows-instrumentation-hook.md b/changelog.d/fixes/10028-windows-instrumentation-hook.md
new file mode 100644
index 00000000000..9879e2f3f30
--- /dev/null
+++ b/changelog.d/fixes/10028-windows-instrumentation-hook.md
@@ -0,0 +1 @@
+- fix(cli): stop diagnosing every Next.js instrumentation-hook failure as the Android/Termux cache bug — only the Android "Unsupported platform: android" signal now triggers the Android hint, so a win32/desktop instrumentation error surfaces its real cause instead of a useless `mkdir -p ~/.cache` (#10028)
\ No newline at end of file
diff --git a/changelog.d/fixes/10265-command-code-provider-api.md b/changelog.d/fixes/10265-command-code-provider-api.md
new file mode 100644
index 00000000000..b38e4e9d2a5
--- /dev/null
+++ b/changelog.d/fixes/10265-command-code-provider-api.md
@@ -0,0 +1 @@
+- fix(command-code): route chat to the documented /provider/v1/chat/completions endpoint instead of the CLI-only /alpha/generate, which Command Code gates/blocks for external callers (#10265)
\ No newline at end of file
diff --git a/changelog.d/fixes/10523-servicesupervisor-port-flake.md b/changelog.d/fixes/10523-servicesupervisor-port-flake.md
new file mode 100644
index 00000000000..1a98ea7fa28
--- /dev/null
+++ b/changelog.d/fixes/10523-servicesupervisor-port-flake.md
@@ -0,0 +1 @@
+- fix(services): isolate probeBeforeSpawn adoption tests on distinct ports to stop the order-dependent flake (#10523)
\ No newline at end of file
diff --git a/changelog.d/fixes/10727-meta-ai-ws-timeout-diagnostics.md b/changelog.d/fixes/10727-meta-ai-ws-timeout-diagnostics.md
new file mode 100644
index 00000000000..f204684baa2
--- /dev/null
+++ b/changelog.d/fixes/10727-meta-ai-ws-timeout-diagnostics.md
@@ -0,0 +1 @@
+- **fix(executors):** the Meta AI (muse-spark-web) WebSocket send-message timeout now reports the socket's `readyState` at the moment it fires, so a "Meta AI WS timed out" failure can be told apart as either the connection never opening (`readyState=0`) or opening successfully and then going silent (`readyState=1`) — the exact ambiguity that made #10727 undiagnosable from logs alone (#10727).
diff --git a/changelog.d/fixes/10736-corrupt-rotate-fence.md b/changelog.d/fixes/10736-corrupt-rotate-fence.md
new file mode 100644
index 00000000000..dd2abc4fc46
--- /dev/null
+++ b/changelog.d/fixes/10736-corrupt-rotate-fence.md
@@ -0,0 +1 @@
+- **fix(db):** pause call-log rotation and record SQLITE_CORRUPT on `/api/db/health` instead of retrying writes against a malformed pager ([#10736](https://github.com/diegosouzapw/OmniRoute/issues/10736))
diff --git a/changelog.d/fixes/10850-readyz-alias.md b/changelog.d/fixes/10850-readyz-alias.md
new file mode 100644
index 00000000000..94e62739bab
--- /dev/null
+++ b/changelog.d/fixes/10850-readyz-alias.md
@@ -0,0 +1 @@
+- **fix(api):** alias `GET`/`HEAD` `/readyz` to `/healthz` so Kubernetes readiness probes do not 404 ([#10850](https://github.com/diegosouzapw/OmniRoute/issues/10850))
diff --git a/changelog.d/fixes/10940-opencode-limit-output.md b/changelog.d/fixes/10940-opencode-limit-output.md
new file mode 100644
index 00000000000..9af54a20468
--- /dev/null
+++ b/changelog.d/fixes/10940-opencode-limit-output.md
@@ -0,0 +1 @@
+- fix(cli): always emit limit.output in generated OpenCode config so schema validation passes for metadata-less models (#10940)
diff --git a/changelog.d/fixes/10945-least-used-rotation.md b/changelog.d/fixes/10945-least-used-rotation.md
new file mode 100644
index 00000000000..36b23951b25
--- /dev/null
+++ b/changelog.d/fixes/10945-least-used-rotation.md
@@ -0,0 +1 @@
+- **Account rotation:** make `fallbackStrategy: "least-used"` actually rotate. The strategy sorts on `lastUsedAt` but never wrote it — only the round-robin branch committed — so on a pool where every `last_used_at` was still `NULL` the tie-break fell through to `priority` and returned the same connection on every dispatch ([#10945](https://github.com/diegosouzapw/OmniRoute/issues/10945)).
diff --git a/changelog.d/fixes/10947-windows-updater-artifact-name.md b/changelog.d/fixes/10947-windows-updater-artifact-name.md
new file mode 100644
index 00000000000..10c90a216da
--- /dev/null
+++ b/changelog.d/fixes/10947-windows-updater-artifact-name.md
@@ -0,0 +1 @@
+- **Desktop auto-update (Windows):** stop the in-app updater 404ing on every release. NSIS used electron-builder's default artifact name, whose spaces GitHub rewrites to `.` on upload while `latest.yml` keeps `-`, so the manifest pointed at `OmniRoute-Setup-X.Y.Z.exe` while the published asset was `OmniRoute.Setup.X.Y.Z.exe`. The name is now set explicitly to the dot form the asset already has, so nothing published changes name ([#10947](https://github.com/diegosouzapw/OmniRoute/issues/10947)).
diff --git a/changelog.d/fixes/10953-preserve-provider-effort-tiers.md b/changelog.d/fixes/10953-preserve-provider-effort-tiers.md
new file mode 100644
index 00000000000..d509aac61b1
--- /dev/null
+++ b/changelog.d/fixes/10953-preserve-provider-effort-tiers.md
@@ -0,0 +1 @@
+- **fix(catalog):** preserve provider-declared reasoning effort tiers instead of replacing them with generic defaults ([#10953](https://github.com/diegosouzapw/OmniRoute/pull/10953)) — thanks @xz-dev
diff --git a/changelog.d/fixes/10954-combo-create-models.md b/changelog.d/fixes/10954-combo-create-models.md
new file mode 100644
index 00000000000..0a0638bceac
--- /dev/null
+++ b/changelog.d/fixes/10954-combo-create-models.md
@@ -0,0 +1 @@
+- fix(cli): combo create accepts --models and no longer creates empty combos (#10954)
diff --git a/changelog.d/fixes/10955-cli-ref-params.md b/changelog.d/fixes/10955-cli-ref-params.md
new file mode 100644
index 00000000000..9497b4129e9
--- /dev/null
+++ b/changelog.d/fixes/10955-cli-ref-params.md
@@ -0,0 +1 @@
+- fix(cli): resolve $ref path params and add PATCH combos requestBody in generated API commands (#10955)
diff --git a/changelog.d/fixes/10967-10966-combo-diag-recovery.md b/changelog.d/fixes/10967-10966-combo-diag-recovery.md
new file mode 100644
index 00000000000..e962b149815
--- /dev/null
+++ b/changelog.d/fixes/10967-10966-combo-diag-recovery.md
@@ -0,0 +1,2 @@
+- fix(sse): combo diagnostics no longer truncate `exhausted_connection` entries to a hardcoded `provider: "unknown"` with the provider prefix eaten by an 8-char slice — the real provider id is preserved and only the connection id is truncated (#10967)
+- fix(sse): combo terminal failures caused entirely by quota/account-balance exhaustion (including a durable HTTP 403 `insufficient_quota` / `AUTHZ_INSUFFICIENT_BALANCE`) now stamp a stable `quota_exhausted` diagnostics reason with a `switch-combo` recovery hint instead of the misleading default `retry` action (#10966)
diff --git a/changelog.d/fixes/10976-skip-default-searxng.md b/changelog.d/fixes/10976-skip-default-searxng.md
new file mode 100644
index 00000000000..a317979b4a1
--- /dev/null
+++ b/changelog.d/fixes/10976-skip-default-searxng.md
@@ -0,0 +1 @@
+- **fix(search):** skip catalog-default SearXNG `http://localhost:8888/search` so Docker/K8s search does not ECONNREFUSED then 502 into the next provider ([#10976](https://github.com/diegosouzapw/OmniRoute/issues/10976))
diff --git a/changelog.d/fixes/10986-reasoning-only-content.md b/changelog.d/fixes/10986-reasoning-only-content.md
new file mode 100644
index 00000000000..0d293482bd3
--- /dev/null
+++ b/changelog.d/fixes/10986-reasoning-only-content.md
@@ -0,0 +1 @@
+- fix(command-code): surface reasoning-only output as content when a model emits no text-delta (#10986)
\ No newline at end of file
diff --git a/changelog.d/fixes/10988-release-v3850-quality-gates.md b/changelog.d/fixes/10988-release-v3850-quality-gates.md
new file mode 100644
index 00000000000..283b30b8362
--- /dev/null
+++ b/changelog.d/fixes/10988-release-v3850-quality-gates.md
@@ -0,0 +1 @@
+- **fix(ci):** clear inherited `release/v3.8.50` quality-gate reds on the X Search PR: drop the stale `copilot-m365-web.ts:330` public-creds allowlist, document six missing env vars, register four covering Stryker tap tests, prune leftover ESLint suppressions, replace the phantom `@/lib/db/connections` Utilization import with `getProviderConnectionById`, and fix open-sse/dashboard typecheck regressions in freebuff, browser-backed chat, auth, health matrix, and Monaco ([#10988](https://github.com/diegosouzapw/OmniRoute/pull/10988)).
diff --git a/changelog.d/fixes/10988-release-v3850-unit-shards.md b/changelog.d/fixes/10988-release-v3850-unit-shards.md
new file mode 100644
index 00000000000..139266c990d
--- /dev/null
+++ b/changelog.d/fixes/10988-release-v3850-unit-shards.md
@@ -0,0 +1 @@
+- **fix(ci):** clear remaining `release/v3.8.50` unit-shard reds on the X Search PR: pin `onnxruntime-node` to the transformers 1.24.3 copy, rebaseline OpenAPI coverage, sync goldens/i18n, honor eye-hidden no-auth models across provider aliases, await rejected-request call-log writes, absorb catalog event-loop shard contention in #9147, and align inherited tests with advisory context estimates, #10501 combo terminal-status aggregation, and current catalog/auth behavior ([#10988](https://github.com/diegosouzapw/OmniRoute/pull/10988)).
diff --git a/changelog.d/fixes/10990-v0-vercel-web-static-catalog.md b/changelog.d/fixes/10990-v0-vercel-web-static-catalog.md
new file mode 100644
index 00000000000..9d567212080
--- /dev/null
+++ b/changelog.d/fixes/10990-v0-vercel-web-static-catalog.md
@@ -0,0 +1 @@
+- **Static model catalog for v0-vercel-web:** seed a static catalog for the v0-vercel-web web-cookie provider (v0-1.0-md, v0-1.5-lg, v0-1.5-md) so its dashboard "Available Models" / "Import from /models" UI serves a usable list instead of falling through to the route's 400 "does not support models listing" ([#10990](https://github.com/diegosouzapw/OmniRoute/issues/10990)).
\ No newline at end of file
diff --git a/changelog.d/fixes/10997-blackbox-deprecation.md b/changelog.d/fixes/10997-blackbox-deprecation.md
new file mode 100644
index 00000000000..74ac1915267
--- /dev/null
+++ b/changelog.d/fixes/10997-blackbox-deprecation.md
@@ -0,0 +1 @@
+- fix(providers): mark the blackbox provider deprecated — api.blackbox.ai returns HTTP 404 on every path variant (sweep 2026-08-21), so the public inference surface is dead and the catalog entry now carries a deprecation notice. ([#10997](https://github.com/diegosouzapw/OmniRoute/issues/10997))
\ No newline at end of file
diff --git a/changelog.d/fixes/11002-dify-key-validation.md b/changelog.d/fixes/11002-dify-key-validation.md
new file mode 100644
index 00000000000..6574714c9be
--- /dev/null
+++ b/changelog.d/fixes/11002-dify-key-validation.md
@@ -0,0 +1 @@
+- fix(providers): validate Dify keys against its native /v1/chat-messages endpoint (#11002)
\ No newline at end of file
diff --git a/changelog.d/fixes/11008-account-rotation-eviction.md b/changelog.d/fixes/11008-account-rotation-eviction.md
new file mode 100644
index 00000000000..4855dde6f28
--- /dev/null
+++ b/changelog.d/fixes/11008-account-rotation-eviction.md
@@ -0,0 +1 @@
+- **fix(accounts):** `markCooldown` now carries the failure origin (`transient` vs `terminal`) — transient 429/network only cools down, repeated terminal failures evict and are skipped by `pickAccount` until a success or operator clear ([#11008](https://github.com/diegosouzapw/OmniRoute/pull/11008)) — thanks @maxmad64bis
diff --git a/changelog.d/fixes/11009-terminal-status-origin.md b/changelog.d/fixes/11009-terminal-status-origin.md
new file mode 100644
index 00000000000..f0ab24edaf7
--- /dev/null
+++ b/changelog.d/fixes/11009-terminal-status-origin.md
@@ -0,0 +1 @@
+- **fix(providers):** route terminal `testStatus` writes (`banned`, `deactivated`, `credits_exhausted`) through a single origin-aware passage — probe failures are recorded but never deactivate the connection ([#11009](https://github.com/diegosouzapw/OmniRoute/pull/11009)) — thanks @maxmad64bis
diff --git a/changelog.d/fixes/11014-codex-drop-default-on.md b/changelog.d/fixes/11014-codex-drop-default-on.md
new file mode 100644
index 00000000000..0e5a8f11293
--- /dev/null
+++ b/changelog.d/fixes/11014-codex-drop-default-on.md
@@ -0,0 +1 @@
+- **fix(codex):** drop non-standard `codex.*` SSE events by default so OpenAI SDK / Codex CLI `/v1/responses` clients are not 502'd by `event: codex.rate_limits` ([#11014](https://github.com/diegosouzapw/OmniRoute/issues/11014)) — thanks @RaviTharuma
diff --git a/changelog.d/fixes/11015-shutdown-track-sse.md b/changelog.d/fixes/11015-shutdown-track-sse.md
new file mode 100644
index 00000000000..1ed99b3669d
--- /dev/null
+++ b/changelog.d/fixes/11015-shutdown-track-sse.md
@@ -0,0 +1 @@
+- **fix(resilience):** count heavyweight `/v1` admission leases in the SIGTERM drain and send `Retry-After` on shutdown 503s so Recreate no longer looks like an empty 502 ([#11015](https://github.com/diegosouzapw/OmniRoute/issues/11015)) — thanks @RaviTharuma
diff --git a/changelog.d/fixes/11016-cred-health-disable-log.md b/changelog.d/fixes/11016-cred-health-disable-log.md
new file mode 100644
index 00000000000..37a9715f41c
--- /dev/null
+++ b/changelog.d/fixes/11016-cred-health-disable-log.md
@@ -0,0 +1 @@
+- **fix(startup):** log `Credential health scheduler disabled` when `OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK` is set instead of lying with `started` ([#11016](https://github.com/diegosouzapw/OmniRoute/issues/11016)) — thanks @RaviTharuma
diff --git a/changelog.d/fixes/11017-rate-limit-docs.md b/changelog.d/fixes/11017-rate-limit-docs.md
new file mode 100644
index 00000000000..fc92469bd6b
--- /dev/null
+++ b/changelog.d/fixes/11017-rate-limit-docs.md
@@ -0,0 +1 @@
+- **docs(api-keys):** document that unset `DEFAULT_RATE_LIMIT_PER_DAY` is unlimited (#2289), not a hidden 1000/day cap ([#11017](https://github.com/diegosouzapw/OmniRoute/issues/11017)) — thanks @RaviTharuma
diff --git a/changelog.d/fixes/11050-remove-ghost-webhook-events.md b/changelog.d/fixes/11050-remove-ghost-webhook-events.md
new file mode 100644
index 00000000000..6278ee6c0a9
--- /dev/null
+++ b/changelog.d/fixes/11050-remove-ghost-webhook-events.md
@@ -0,0 +1 @@
+- **fix(webhooks):** remove 3 declared-but-never-emitted events (`provider.error`, `provider.recovered`, `combo.switched`) from `WebhookEvent` — catalog now `request.completed | request.failed | quota.exceeded | test.ping`; `POST /api/webhooks` and `PUT /api/webhooks/[id]` reject ghost values with 400; OpenAPI webhook description updated across 43 locales ([11050](https://github.com/diegosouzapw/OmniRoute/pull/11050))
diff --git a/changelog.d/fixes/11060-perplexity-filter.md b/changelog.d/fixes/11060-perplexity-filter.md
new file mode 100644
index 00000000000..c221d3ccaba
--- /dev/null
+++ b/changelog.d/fixes/11060-perplexity-filter.md
@@ -0,0 +1 @@
+- fix(providers): filter Perplexity model import to the Sonar family so Agent-API catalog ids stop surfacing as routable chat models (#11060)
diff --git a/changelog.d/fixes/11085-claude-code-tool-name-casing.md b/changelog.d/fixes/11085-claude-code-tool-name-casing.md
new file mode 100644
index 00000000000..5ad424c1418
--- /dev/null
+++ b/changelog.d/fixes/11085-claude-code-tool-name-casing.md
@@ -0,0 +1 @@
+- **fix(claude):** restore canonical tool names (`bash` → `Bash`, `croncreate` → `CronCreate`) on non-streaming OpenAI→Claude conversion and through identity-echo alias maps, so Claude Code stops rejecting tool calls with "No such tool available" ([#11085](https://github.com/diegosouzapw/OmniRoute/pull/11085)) — thanks @linhdmn
diff --git a/changelog.d/fixes/11095-termux-onnx.md b/changelog.d/fixes/11095-termux-onnx.md
new file mode 100644
index 00000000000..8c268037104
--- /dev/null
+++ b/changelog.d/fixes/11095-termux-onnx.md
@@ -0,0 +1 @@
+- fix(install): make the ONNX dependency chain optional so Termux/Android installs succeed again (#11095)
diff --git a/changelog.d/fixes/11101-reject-silent-validation.md b/changelog.d/fixes/11101-reject-silent-validation.md
new file mode 100644
index 00000000000..04b2a67d5a0
--- /dev/null
+++ b/changelog.d/fixes/11101-reject-silent-validation.md
@@ -0,0 +1 @@
+- **fix(providers):** Reject silent validation degradation on provider connection patch — unknown `rateLimitOverrides` keys (e.g. a typo'd `tpm`) and empty/non-numeric values now return `400` with the rejected key list instead of being silently dropped ([#11101](https://github.com/diegosouzapw/OmniRoute/pull/11101))
diff --git a/changelog.d/fixes/11102-combo-suggestion-count.md b/changelog.d/fixes/11102-combo-suggestion-count.md
new file mode 100644
index 00000000000..3cbf6f11d3d
--- /dev/null
+++ b/changelog.d/fixes/11102-combo-suggestion-count.md
@@ -0,0 +1 @@
+- **Autopilot suggestion counter:** the combo health autopilot summary now reports `suggestionCount` (the real number of suggested actions across all issues) instead of conflating it with link counts, while keeping `actionableCount` as a deprecated alias for backward compatibility. The `run_combo_test` action now links to the dashboard with the combo id (`/dashboard/combos?test=`) rather than the read-only API route, so operators can actually trigger a test from the UI ([#11102](https://github.com/diegosouzapw/OmniRoute/pull/11102)).
diff --git a/changelog.d/fixes/11103-persist-config-audit-log.md b/changelog.d/fixes/11103-persist-config-audit-log.md
new file mode 100644
index 00000000000..aeb53b17814
--- /dev/null
+++ b/changelog.d/fixes/11103-persist-config-audit-log.md
@@ -0,0 +1 @@
+- **Config audit persistence:** persist the configuration audit trail to SQLite (`config_audit_log`) instead of an in-memory buffer capped at 1000 volatile entries, and bound its growth with `cleanupConfigAudit()` driven by the `retention.configAudit` setting (default 30 days), wired into `runAutoCleanup` ([#11103](https://github.com/diegosouzapw/OmniRoute/pull/11103)).
diff --git a/changelog.d/fixes/11109-stream-recovery-toolcall.md b/changelog.d/fixes/11109-stream-recovery-toolcall.md
new file mode 100644
index 00000000000..04a43382e42
--- /dev/null
+++ b/changelog.d/fixes/11109-stream-recovery-toolcall.md
@@ -0,0 +1 @@
+- fix(sse): resume mid-stream recovery after a _completed_ tool call — `finish_reason: "tool_calls"` is now tracked per-call instead of as a general terminal marker, so truncation of trailing prose after a fully-delivered tool call is recoverable while in-flight calls stay blocked ([#11109](https://github.com/diegosouzapw/OmniRoute/pull/11109))
diff --git a/changelog.d/fixes/11116-reasoning-effort-capability-discovery.md b/changelog.d/fixes/11116-reasoning-effort-capability-discovery.md
new file mode 100644
index 00000000000..fbc4dfe694e
--- /dev/null
+++ b/changelog.d/fixes/11116-reasoning-effort-capability-discovery.md
@@ -0,0 +1 @@
+- **fix(providers):** `reasoning_effort` now learns the accepted values from a provider's own 400/422 response and clamps to the highest one instead of forwarding an unsupported `xhigh`/`max` (or a hardcoded `"high"` fallback) — fixes custom OpenAI-compatible connections and registered providers with no reasoning metadata ([#11116](https://github.com/diegosouzapw/OmniRoute/pull/11116)) — thanks @maxmad64bis
diff --git a/changelog.d/fixes/11144-responses-parallel-tool-calls-index.md b/changelog.d/fixes/11144-responses-parallel-tool-calls-index.md
new file mode 100644
index 00000000000..35ba19b2796
--- /dev/null
+++ b/changelog.d/fixes/11144-responses-parallel-tool-calls-index.md
@@ -0,0 +1 @@
+- **fix(sse):** parallel `function_call` items in a Responses API stream (e.g. several tool calls dispatched in the same turn) now each get a stable, distinct `index`/`id` when translated to Chat Completions streaming deltas, instead of colliding on index 0 and tripping strict stream parsers with `Expected 'id' to be a string.` ([#11144](https://github.com/diegosouzapw/OmniRoute/pull/11144))
diff --git a/changelog.d/fixes/11154-provider-registry-node-net-bundle.md b/changelog.d/fixes/11154-provider-registry-node-net-bundle.md
new file mode 100644
index 00000000000..b30d9e13926
--- /dev/null
+++ b/changelog.d/fixes/11154-provider-registry-node-net-bundle.md
@@ -0,0 +1 @@
+- fix(dashboard): keep `open-sse/config/providerRegistry.ts` free of `node:net` so the provider detail client bundle builds again — the host classification moved to a platform-free `src/shared/network/privateHost.ts` with a pure-JS `isIP` equivalent, leaving the #11122 routing behaviour unchanged (#11154)
diff --git a/changelog.d/fixes/11162-combo-create-requires-model.md b/changelog.d/fixes/11162-combo-create-requires-model.md
new file mode 100644
index 00000000000..228e6a9b20f
--- /dev/null
+++ b/changelog.d/fixes/11162-combo-create-requires-model.md
@@ -0,0 +1 @@
+- **Combo create:** creating a routing combo without any model is now refused (`400`) — the CLI requires `--models`/`--model` on `combo create`, matching the dashboard which already rejected empty combos.
diff --git a/changelog.d/fixes/11165-shared-registry-passthrough-model-lockout.md b/changelog.d/fixes/11165-shared-registry-passthrough-model-lockout.md
new file mode 100644
index 00000000000..eabe67cb09c
--- /dev/null
+++ b/changelog.d/fixes/11165-shared-registry-passthrough-model-lockout.md
@@ -0,0 +1 @@
+- **fix(resilience):** a missing-model `404` on a provider that declares `passthroughModels: true` in the shared registry (novita, uncloseai, orcarouter and 37 others) now locks out only that model instead of cooling the entire connection — `hasPerModelQuota()` previously read only the open-sse registry and the local/self-hosted families ([#11165](https://github.com/diegosouzapw/OmniRoute/pull/11165)) — thanks @yourspraveen
diff --git a/changelog.d/fixes/7346-electron-hollow-nested-package-repair.md b/changelog.d/fixes/7346-electron-hollow-nested-package-repair.md
new file mode 100644
index 00000000000..fd7e61988ba
--- /dev/null
+++ b/changelog.d/fixes/7346-electron-hollow-nested-package-repair.md
@@ -0,0 +1 @@
+- fix(cli): repair hollow externalized package dirs in the nested `/node_modules` bundle location too, not just the top-level one, fixing macOS/Linux Electron `ERR_MODULE_NOT_FOUND` on Turbopack-externalized packages (#7346)
diff --git a/changelog.d/fixes/7592-electron-cold-restart-native-driver-check.md b/changelog.d/fixes/7592-electron-cold-restart-native-driver-check.md
new file mode 100644
index 00000000000..e6458879cf0
--- /dev/null
+++ b/changelog.d/fixes/7592-electron-cold-restart-native-driver-check.md
@@ -0,0 +1 @@
+- **Electron packaged smoke test:** add a cold-restart mode (`ELECTRON_SMOKE_COLD_RESTART=1`, wired blocking on the Linux release leg) that relaunches the packaged app against its own persisted `DATA_DIR` and asserts a native SQLite driver was selected instead of the sql.js WASM fallback, closing the regression-test gap flagged in the stale-ABI `better-sqlite3` investigation ([#7592](https://github.com/diegosouzapw/OmniRoute/issues/7592)).
diff --git a/changelog.d/fixes/8864-uncloseai-noauth.md b/changelog.d/fixes/8864-uncloseai-noauth.md
new file mode 100644
index 00000000000..8a38e6b8363
--- /dev/null
+++ b/changelog.d/fixes/8864-uncloseai-noauth.md
@@ -0,0 +1 @@
+- fix(dashboard): treat UncloseAI as a no-auth provider so the connect form no longer forces a fake API key (#8864)
diff --git a/changelog.d/fixes/9123-search-provider-local-flag-guard-mismatch.md b/changelog.d/fixes/9123-search-provider-local-flag-guard-mismatch.md
new file mode 100644
index 00000000000..bc51e121036
--- /dev/null
+++ b/changelog.d/fixes/9123-search-provider-local-flag-guard-mismatch.md
@@ -0,0 +1 @@
+- fix(ssrf): make `getProviderOutboundGuard()` (used for search-provider connection validation, image generation and remote image fetch) honor the local-first default `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` the same way the chat validation guard already does, so a LAN-hosted SearXNG/Brave search provider works with only the LOCAL flag set instead of silently requiring `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` ([#9123](https://github.com/diegosouzapw/OmniRoute/issues/9123)).
\ No newline at end of file
diff --git a/changelog.d/fixes/command-code-effort-capabilities.md b/changelog.d/fixes/command-code-effort-capabilities.md
new file mode 100644
index 00000000000..38057107ef5
--- /dev/null
+++ b/changelog.d/fixes/command-code-effort-capabilities.md
@@ -0,0 +1 @@
+- fix(combo): resolve effort-suffixed command-code variants (e.g. `deepseek-v4-flash-max`) to their base model for capability lookups, so tool-bearing combo requests keep the declared priority order instead of reordering behind models with confirmed capabilities
diff --git a/changelog.d/fixes/opencode-merge-provider-guard.md b/changelog.d/fixes/opencode-merge-provider-guard.md
new file mode 100644
index 00000000000..aa1f02e63bb
--- /dev/null
+++ b/changelog.d/fixes/opencode-merge-provider-guard.md
@@ -0,0 +1 @@
+- **OpenCode config merge:** stop `mergeOpenCodeConfig` splaying a malformed `provider` block into index keys. The root was already guarded against a non-object; the `provider` branch it spreads one level down was not, so an existing `"provider": ["a", "b"]` merged to `{"0": "a", "1": "b", …}`. Its sibling `mergeOpenCodeConfigText` already refuses the same input.
diff --git a/changelog.d/fixes/openrouter-synced-model-context-window-and-default-effort.md b/changelog.d/fixes/openrouter-synced-model-context-window-and-default-effort.md
new file mode 100644
index 00000000000..b26e1c2086d
--- /dev/null
+++ b/changelog.d/fixes/openrouter-synced-model-context-window-and-default-effort.md
@@ -0,0 +1 @@
+- **fix(models):** a model synced from a provider's own `/models` discovery is now enforced at its real context window immediately, instead of waiting up to 24h for the Feature 5004 reconciler's next tick. The request-time token-limit chain resolves the window from `auto:discovery` overrides, which previously were only written at startup and on a 24h interval — so any model synced mid-cycle (models.dev not indexing it yet, no static registry entry) fell through to the provider's static `defaultContextLength` (128K for OpenRouter) while `/v1/models` simultaneously advertised the real window from the same discovery data. Measured: `openrouter/stealth/ox-alpha` advertised `context_length: 1048576` but rejected requests over 128K with `context_length_exceeded` for a full day after its sync. The reconcile now also runs opportunistically (debounced, fire-and-forget) right after a synced catalog write changes. Companion fix: discovery now captures the vendor-declared `reasoning.default_effort` (e.g. OpenRouter `stealth/ox-alpha` declares `max`, normalized to `xhigh`) as `defaultThinkingEffort`, and the OpenAI dispatch path injects it when a request carries no reasoning field of any shape — the lowest-priority default behind a `-{effort}` suffix alias and a static `ModelSpec.defaultReasoningEffort` — so a reasoning model that returns an empty response without an explicit effort gets the vendor default instead of `upstream_empty_response`.
diff --git a/changelog.d/fixes/pending-opencode-empty-rejection-rotation.md b/changelog.d/fixes/pending-opencode-empty-rejection-rotation.md
new file mode 100644
index 00000000000..82a88c5905a
--- /dev/null
+++ b/changelog.d/fixes/pending-opencode-empty-rejection-rotation.md
@@ -0,0 +1 @@
+- **fix(executors):** OpencodeExecutor rotates (or retries once on a single-account direct path) on upstream 400 empty-body rejections — malformed completion envelopes with no error field were propagated as success and killed client sessions. Bounded +1 attempt per request; body reads are conditioned on status 400 so successful/streaming responses are never buffered. 400s carrying an error field keep propagating immediately.
diff --git a/changelog.d/fixes/release-v3850-basereds-tests-i18n.md b/changelog.d/fixes/release-v3850-basereds-tests-i18n.md
new file mode 100644
index 00000000000..3a6dee61b9c
--- /dev/null
+++ b/changelog.d/fixes/release-v3850-basereds-tests-i18n.md
@@ -0,0 +1 @@
+- fix(i18n): complete Vietnamese translations for recently added UI strings (#9985)
diff --git a/changelog.d/fixes/release-v3850-basereds.md b/changelog.d/fixes/release-v3850-basereds.md
new file mode 100644
index 00000000000..30444ba7064
--- /dev/null
+++ b/changelog.d/fixes/release-v3850-basereds.md
@@ -0,0 +1,3 @@
+- fix(api): repair broken `@/lib/db/connections` import in the usage utilization route that failed the production build (#10939 follow-up)
+- chore(docs): regenerate PROVIDER_REFERENCE and refresh README diagram SVGs to the real provider count (347)
+- chore(lint): prune ESLint suppressions orphaned on the release branch
diff --git a/changelog.d/maintenance/10778-grokbuild-suppression-fix.md b/changelog.d/maintenance/10778-grokbuild-suppression-fix.md
new file mode 100644
index 00000000000..15c5df7ef72
--- /dev/null
+++ b/changelog.d/maintenance/10778-grokbuild-suppression-fix.md
@@ -0,0 +1 @@
+- fix(quality): register GrokBuildToolCard.tsx react-hooks/set-state-in-effect suppression (dropped in #10778's uncommitted fix)
diff --git a/changelog.d/maintenance/10906-critical-db-state-assertions.md b/changelog.d/maintenance/10906-critical-db-state-assertions.md
new file mode 100644
index 00000000000..c63f5f0039a
--- /dev/null
+++ b/changelog.d/maintenance/10906-critical-db-state-assertions.md
@@ -0,0 +1 @@
+- **test(db):** replace three empty `test.skip` placeholders in the critical DB-state suite with real assertions — `resetDbInstance` must swap the singleton while the on-disk row survives, the on-disk DB must open in WAL journal mode, and `db_meta` must hold the seeded `schema_version` — so a regression in any of those invariants can no longer pass as silently green ([#10906](https://github.com/diegosouzapw/OmniRoute/pull/10906))
diff --git a/changelog.d/maintenance/10982-runtime-ram-coding-agents.md b/changelog.d/maintenance/10982-runtime-ram-coding-agents.md
new file mode 100644
index 00000000000..3c0985c68f6
--- /dev/null
+++ b/changelog.d/maintenance/10982-runtime-ram-coding-agents.md
@@ -0,0 +1 @@
+- **docs(docker):** document runtime RAM for coding-agent `/v1/responses` (image default 1 GiB heap is dashboard-only; 8–12 GiB heap for agents) ([#10982](https://github.com/diegosouzapw/OmniRoute/issues/10982))
diff --git a/changelog.d/maintenance/11024-n-instance-scale-out.md b/changelog.d/maintenance/11024-n-instance-scale-out.md
new file mode 100644
index 00000000000..82adafe4801
--- /dev/null
+++ b/changelog.d/maintenance/11024-n-instance-scale-out.md
@@ -0,0 +1 @@
+- **docs(docker):** document N independent `DATA_DIR`s as the supported large `/v1/responses` scale-out (one V8 heap ≠ host RAM; do not `replicas>1` on one SQLite file) ([#11024](https://github.com/diegosouzapw/OmniRoute/issues/11024)) — thanks @RaviTharuma
diff --git a/changelog.d/maintenance/11038-filesize-baseline-fix.md b/changelog.d/maintenance/11038-filesize-baseline-fix.md
new file mode 100644
index 00000000000..aebc2b9e234
--- /dev/null
+++ b/changelog.d/maintenance/11038-filesize-baseline-fix.md
@@ -0,0 +1 @@
+- fix(quality): rebaseline file-size for modelCapabilities.ts (1016->1072) drift from merged tip fixes (#11034 et al)
diff --git a/changelog.d/maintenance/11053-stryker-oauth-autoimport-registration.md b/changelog.d/maintenance/11053-stryker-oauth-autoimport-registration.md
new file mode 100644
index 00000000000..b2e21419009
--- /dev/null
+++ b/changelog.d/maintenance/11053-stryker-oauth-autoimport-registration.md
@@ -0,0 +1 @@
+- fix(quality): register `tests/unit/authz/oauth-autoimport-local-only.test.ts` in stryker `tap.testFiles` (residual of #11053)
diff --git a/changelog.d/maintenance/11160-drain-v3850-basereds-docs-counts-orphan-test.md b/changelog.d/maintenance/11160-drain-v3850-basereds-docs-counts-orphan-test.md
new file mode 100644
index 00000000000..e695a2b8fc0
--- /dev/null
+++ b/changelog.d/maintenance/11160-drain-v3850-basereds-docs-counts-orphan-test.md
@@ -0,0 +1 @@
+- chore(quality): drain two `release/v3.8.50` base-reds — refresh the drifted doc counts (159 migrations, 56 free-forever providers, 40 free-tier pools, incl. the 42 `llm.txt` locale mirrors) and move `uncloseai-noauth.test.ts` to a collected path so the UncloseAI no-auth regression guard actually runs (#11160)
diff --git a/changelog.d/maintenance/release-v3850-basereds-eslint-deadcode-vitest-20260819.md b/changelog.d/maintenance/release-v3850-basereds-eslint-deadcode-vitest-20260819.md
new file mode 100644
index 00000000000..905d338575c
--- /dev/null
+++ b/changelog.d/maintenance/release-v3850-basereds-eslint-deadcode-vitest-20260819.md
@@ -0,0 +1,20 @@
+- **fix(ci):** drain three more base-reds on `release/v3.8.50` (#9985). ESLint was reporting
+ 219 errors locally (vs. 25 in the last CI run) — all from `react-hooks/set-state-in-effect`,
+ `react-hooks/preserve-manual-memoization`, `react-hooks/immutability`,
+ `react-hooks/static-components`, `react-hooks/refs` and `react-hooks/purity`, six React
+ Compiler lint rules that `eslint-plugin-react-hooks` v7 turns on by default and that were
+ never frozen in `config/quality/eslint-suppressions.json` after the dependency bump. Froze
+ the pre-existing violations for those six rules via ESLint's native
+ `--suppress-rule`/`--suppressions-location` mechanism (the same pattern already used for
+ `@next/next/no-location-assign-relative-destination`) — no application code changed, no rule
+ disabled, only genuinely-new violations stay blocking. `check:dead-code` was at 418 against a
+ 415 baseline: removed the unused `src/lib/quota/providerCapabilities.ts` file and the unused
+ `ProviderQuotaMonitor` interface in `providerQuotaTelemetry.ts` (both dead since PR #10148,
+ 2026-08-18, confirmed via `grep`/knip cross-reference), landing at 416; the residual +1 could
+ not be attributed to a single recent commit after checking every dead-list entry touched
+ since the 2026-08-14 baseline measurement, so it is rebaselined with the investigation
+ recorded in `quality-baseline.json`. `tests/unit/autoCombo/tieredRotation.test.ts`'s
+ "rotates across all 43 Cerebras connection IDs" case was hitting vitest's 5000ms default
+ timeout on a 200-iteration synchronous `selectProvider()` loop under shared-devbox
+ contention (load average 40-60+ observed) — widened its explicit timeout to 20000ms; the
+ assertion itself is unchanged.
diff --git a/changelog.d/maintenance/vi-harimport-parity.md b/changelog.d/maintenance/vi-harimport-parity.md
new file mode 100644
index 00000000000..b08b8dc92f7
--- /dev/null
+++ b/changelog.d/maintenance/vi-harimport-parity.md
@@ -0,0 +1 @@
+- fix(i18n): translate the 14 `providers.harImport*` keys into Vietnamese (parity gap left by #11069)
diff --git a/config/quality/dashboard-typecheck-baseline.json b/config/quality/dashboard-typecheck-baseline.json
index b97762d325e..b596060a8ef 100644
--- a/config/quality/dashboard-typecheck-baseline.json
+++ b/config/quality/dashboard-typecheck-baseline.json
@@ -1,9 +1,6 @@
{
- "open-sse/services/payloadRules.ts": {
- "TS2677": 1
- },
"src/app/(dashboard)/dashboard/HomePageClient.tsx": {
- "TS2339": 16
+ "TS2339": 10
},
"src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient.tsx": {
"TS2503": 3
@@ -120,10 +117,6 @@
"src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx": {
"TS2741": 1
},
- "src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx": {
- "TS2345": 3,
- "TS2322": 1
- },
"src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsListPanel.tsx": {
"TS2322": 2
},
@@ -141,12 +134,6 @@
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPlaygroundPanel.tsx": {
"TS2503": 1
},
- "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": {
- "TS2322": 1
- },
- "src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts": {
- "TS2339": 1
- },
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts": {
"TS2339": 15
},
@@ -190,9 +177,6 @@
"src/lib/combos/builderDraft.ts": {
"TS2741": 1
},
- "src/lib/providers/codexFastTier.ts": {
- "TS2367": 1
- },
"src/lib/services/htmlRewriter.ts": {
"TS2322": 2,
"TS2345": 2
@@ -219,14 +203,7 @@
"src/shared/hooks/useElectron.ts": {
"TS2339": 19
},
- "src/shared/providers/webSessionCredentials.ts": {
- "TS2353": 1,
- "TS2322": 1
- },
"src/shared/schemas/cliCatalog.ts": {
"TS2554": 2
- },
- "src/shared/services/opencodeConfig.ts": {
- "TS2345": 1
}
}
diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json
index 9abe7bbf1da..3dae8591dd7 100644
--- a/config/quality/eslint-suppressions.json
+++ b/config/quality/eslint-suppressions.json
@@ -808,7 +808,6 @@
"count": 1
}
},
-
"src/app/api/settings/route.ts": {
"no-restricted-imports": {
"count": 1
@@ -1257,11 +1256,6 @@
"count": 1
}
},
- "src/shared/components/CursorAuthModal.tsx": {
- "react-hooks/exhaustive-deps": {
- "count": 1
- }
- },
"src/shared/components/LanguageSelector.tsx": {
"@next/next/no-img-element": {
"count": 1
@@ -1607,7 +1601,6 @@
"count": 11
}
},
-
"tests/unit/auth-ollama-cloud-per-model-403-3027.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 11
@@ -2486,7 +2479,6 @@
"count": 12
}
},
-
"tests/unit/management-password.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 4
diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json
index 2e7fa58dacd..2eb468e16c6 100644
--- a/config/quality/file-size-baseline.json
+++ b/config/quality/file-size-baseline.json
@@ -1,5 +1,6 @@
{
"_rebaseline_2026_08_20_10531_freebuff_provider": "PR #10531 (adrianaryaputra, feat/freebuff-provider-support, closes #6793) own growth: src/shared/constants/providers/apikey/gateways.ts 1283->1298 (+15, the freebuff APIKEY_PROVIDERS_GATEWAYS catalog entry, additive data at the existing registry chokepoint, same god-file no-split rationale as prior gateways.ts rebaselines) and src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx 1062->1067 (+5, freebuff credential placeholder/hint at the existing per-provider switch chokepoint). Covered by tests/unit/freebuff-provider.test.ts (9/9 passing).",
+ "_rebaseline_2026_08_21_10987_logfare_provider": "PR #10987 (jonlwheat2-gif, feat/10644-logfare-provider, closes #10644) own growth: src/shared/constants/providers/apikey/gateways.ts 1298->1321 (+23, the logfare APIKEY_PROVIDERS_GATEWAYS catalog entry with Free badge/freeNote/apiHint documenting the request-logging policy, additive data at the existing registry chokepoint, same god-file no-split rationale as the prior gateways.ts rebaselines: #10531 freebuff, merge-storm 2026-08-11). Covered by tests/unit/logfare-registry.test.ts (1/1 passing).",
"_rebaseline_2026_08_20_10574_reasoning_transport_fallback": "PR #10574 (jackjinke, fix/responses-reasoning-transport, fixes #10550) own growth: src/sse/handlers/chatHelpers.ts 1017->1019 (+2 = the new reasoningTransportFallback option threaded through executeChatWithBreaker's options destructure and its downstream handleSingleModel call, at the existing per-attempt options-passthrough chokepoint; not extractable without splitting the option-forwarding call itself). Covered by the PR's own reasoning-policy test suite (tests/unit/chatcore-translation-paths.test.ts, tests/unit/combo-attempt-body-isolation-7847.test.ts, tests/unit/reasoning-cache.test.ts, tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts among others), 446/446 focused tests passing.",
"_rebaseline_2026_08_18_10517_zed_hosted_oauth_callback_port": "PR #10517 (phatchau036, fix/zed-hosted-oauth-callback-port) own growth: src/shared/components/OAuthModal.tsx 1131->1148 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 1134->1149, +15/+18, crosses the frozen 1134 cap). Wires the zed-hosted native-app callback auto-complete: forceManual gating on isTrueLocalhost for zed-hosted, the loopback-redirect-URI comment block, and the exchangeToken full-URL-as-code branch, all at the existing provider-switch chokepoints this modal already carries growth for (seventh bump: 969->989->993->998->1030->1056->1100->1149; structural shrink tracked in #3501). The actual port-derivation logic lives in src/lib/oauth/providers/zed-hosted.ts (not frozen here) and was hardened during pre-merge review to use the server's own getRuntimePorts() instead of a browser-guessed scheme/port, covered by the new tests/unit/zed-hosted-loopback-port-derivation.test.ts (8/8 passing).",
"_rebaseline_2026_08_13_10243_codex_fingerprint_merge": "PR #10243 (xz-dev, Codex OAuth fingerprint convergence) merge into release/v3.8.50: src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts crossed the 1000-line new-file cap for the first time (974 on base, 997 on the PR's own branch, 1013 after merging + prettier reflow) purely from combining two independent, already-legitimate feature additions that landed on the same shared UI-helper file — this PR's own Codex fingerprint-mode select/toggle wiring (CODEX_FINGERPRINT_MODE_VALUES, getCodexFingerprintModeLabel, CodexFingerprintModeValue) plus #8949's unrelated Codex account-service-tier helpers merged concurrently on release/v3.8.50. Neither addition alone crosses the cap; git's line-level auto-merge does not detect a threshold crossing. Not modularized as part of this conflict-resolution merge commit (out of scope — this is a merge, not a feature change). Covered by the PR's own tests/unit/codex-fingerprint-convergence.test.ts, tests/unit/executor-codex.test.ts, tests/unit/provider-specific-data-schema.test.ts (all passing post-merge).",
@@ -388,6 +389,10 @@
"open-sse/services/claudeCodeCompatible.ts": 1563,
"open-sse/services/combo.ts": 4742,
"open-sse/services/compression/strategySelector.ts": 1379,
+ "open-sse/services/compression/engines/ccr/index.ts": 1024,
+ "_rebaseline_2026_08_22_11084_ccr_caller_gate": "PR #11084 (HouMinXi) own growth: open-sse/services/compression/engines/ccr/index.ts 1000->1024 (first listing — the engine was unlisted and drifted just over the 1000 cap; +24 are the callerSupportsCcrRetrieve gate that skips replacement entirely for callers without the retrieve tool, closing the stranded-prompt incident measured in production). Covered by tests/unit/compression/ccr-non-mcp-full-prompt-loss-7746.test.ts. Owner pre-authorized baseline bumps 2026-08-22.",
+ "open-sse/services/contextManager.ts": 1001,
+ "_rebaseline_2026_08_22_11113_purify_system_first": "PR #11113 (ggdayup) own growth: open-sse/services/contextManager.ts 1000->1001 (+1, purifyHistory merges the compression notice into the leading system message instead of splicing a second one mid-array — live-confirmed TokenRouter 400s; the +1 is the merge-into-leading branch, not extractable). Covered by tests/unit/context-manager-purify-system-first.test.ts. Owner pre-authorized baseline bumps 2026-08-22.",
"open-sse/services/rateLimitManager.ts": 1517,
"open-sse/translator/response/openai-responses.ts": 1652,
"open-sse/utils/cursorAgentProtobuf.ts": 1956,
@@ -443,21 +448,26 @@
"src/shared/components/ModelSelectModal.tsx": 1138,
"src/shared/constants/providers/apikey/gateways.ts": 1250
},
- "src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1067,
+ "src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1082,
+ "_rebaseline_2026_08_22_11156_enter_check_disabled": "PR #11156 (rqzbeh) own growth: AddApiKeyModal.tsx 1080->1082 (+2, Enter keydown handler now mirrors the isCheckDisabled condition — owner-requested post-merge polish from #11056; the rest of the diff is Prettier reflow). Covered by tests/unit/ui/add-api-key-modal-enter-key.test.tsx (jsdom render test, Enter dispatch assertions).",
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 1051,
"src/shared/components/ModelSelectModal.tsx": 1138,
- "src/shared/constants/providers/apikey/gateways.ts": 1298,
+ "src/shared/constants/providers/apikey/gateways.ts": 1321,
"open-sse/vendor/codex-chatgpt-web/bridge.ts": 1387,
"_rebaseline_2026_08_11_v3850_merge_storm_provider_registry": "DRIFT do merge-storm 2026-08-11 (99 PRs mergeados no release/v3.8.50). AddApiKeyModal.tsx (PR #8949 ChatGPT Web provider) e useProviderConnections.ts/ModelSelectModal.tsx (PRs #9011 combo test-all, #9499 image combos) = UI nova legitima acima do cap; gateways.ts = god-file de catalogo de providers que cresceu com PRs #9009/#9421/#9468/#9594 (qualquer split arriscaria corromper o merge de novo — o proprio PR #9421 quebrou o arquivo); bridge.ts (PR #8949) = ponte Chromium vendored; proxyFetch.ts 1207->1220 = drift herdado de merges. Owner autorizou rebaseline com anotacao (2026-08-11).",
- "src/lib/modelCapabilities.ts": 1016,
+ "src/lib/modelCapabilities.ts": 1072,
+ "_rebaseline_2026_08_21_11034_effort_variants": "DRIFT do tip (base-red #9985): modelCapabilities.ts 1016->1072 (+56) acumulado por PRs ja mergeadas no release/v3.8.50 — principalmente #11034 (resolve effort-variant capabilities a partir do modelo base), alem de #10963/#11040/#10987 growth dos catalogos. Tip puro ficou vermelho neste gate; rebaseline no tip por push direto (owner pre-autorizou crescimento legitimo). Nao tocou no arquivo da #11038.",
"src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1014,
"open-sse/config/imageRegistry.ts": 1034,
"src/sse/handlers/chatHelpers.ts": 1019,
- "src/shared/middleware/chatBodyAdmission.ts": 1005,
+ "src/shared/middleware/chatBodyAdmission.ts": 1009,
+ "_rebaseline_2026_08_22_11020_sigterm_drain": "PR #11020 (RaviTharuma) own growth: chatBodyAdmission.ts 1005->1009 (+4, heavyweight admission leases now increment the SIGTERM drain counter and releaseChatAdmissionWhenDone holds it for the SSE lifetime — closes #11015; +4 are the lease/drain wiring lines at the existing admission chokepoint). Covered by tests/unit/chat-body-admission.test.ts heavyweight-lease cases. Owner pre-authorized baseline bumps 2026-08-22.",
"_rebaseline_2026_08_20_10668_tabitoken_gateway": "#10668 (yawar-aquil) own catalog growth: src/shared/constants/providers/apikey/gateways.ts 1268->1283 (+15, entirely this PR diff -- one new tabitoken gateway entry, data lines only; base moved from 1255 to 1268 via other merges since the PR forked). Not combination drift: reproducible on the PR branch alone, so the WS5.5 release-captain rule does not apply. Extraction is not available -- the file is pure data (own header: \"Pure data; merged by apikey/index.ts via spread\") and already split into 6 family files under apikey/. Same precedent as _rebaseline_2026_08_14_imagetotext_servicekinds (#10275/#10291, gateways.ts 1250->1255, data lines only) and _rebaseline_2026_08_11_v3850_merge_storm_provider_registry (owner-authorized for this same file).",
- "open-sse/executors/commandCode.ts": 1038,
+ "open-sse/executors/commandCode.ts": 1059,
"_rebaseline_2026_08_21_10859_vision_bridge_catalog": "#10859 own growth (Vision Bridge fixes #10808/#10809): src/lib/modelCapabilities.ts 1006->1016 (+10, cmd/gpt-5.3-codex* text-only capability resolution) and open-sse/executors/commandCode.ts 988->1023 (+35, Command Code wire-model normalization for bare ids + reasoning field fallback for opencode-routed gateways). Cohesive bug fixes at the existing capability-resolution / executor chokepoints; not extractable mid-fix. Covered by tests/unit/model-capabilities-command-code-codex-textonly-10703.test.ts, tests/unit/command-code-vision.test.ts, tests/unit/opencode-mimo-reasoning-details-nonstream.test.ts. Pushed directly to release (own-session miss: the original rebaseline was made in a throwaway validation worktree and never landed on the PR branch or the release before merge).",
- "_rebaseline_2026_08_21_10907_sticky_pin_clear": "#10907 own growth: open-sse/executors/commandCode.ts 1023->1038 (+15, effort-suffix sanitization threading for the sticky-pin-clear fix). Cohesive change at the existing executor chokepoint. Covered by tests/unit/command-code-executor.test.ts."
+ "_rebaseline_2026_08_21_10907_sticky_pin_clear": "#10907 own growth: open-sse/executors/commandCode.ts 1023->1038 (+15, effort-suffix sanitization threading for the sticky-pin-clear fix). Cohesive change at the existing executor chokepoint. Covered by tests/unit/command-code-executor.test.ts.",
+ "_rebaseline_2026_08_21_10986_reasoning_only_content": "#10986 own growth: open-sse/executors/commandCode.ts 1038->1059 (+21, reasoning-only content fallback — when upstream emits only reasoning-delta events and never a text-delta, surface the reasoning text as message.content in createJsonResponse and emit a synthetic content delta in createStreamResponse). Cohesive bug fix at the existing executor chokepoint (mirrors precedent style of #10907/#10859). Covered by tests/unit/command-code-executor.test.ts (2 new cases: non-stream + streaming).",
+ "_rebaseline_2026_08_21_11069_m365_har_import": "#11069 own growth: AddApiKeyModal.tsx 1073->1080 (+7 = Import .har file button for the copilot-m365-web credential modal — M365 is the only provider whose credential (access_token+chathubPath) must be extracted from a DevTools HAR WebSocket URL, added as a new modal affordance). Cohesive UI at the existing modal chokepoint; not extractable. Covered by tests/unit/m365-har-import*.test.ts."
},
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",
"_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).",
diff --git a/config/quality/open-sse-typecheck-baseline.json b/config/quality/open-sse-typecheck-baseline.json
index c6de98b4184..753e9286c43 100644
--- a/config/quality/open-sse-typecheck-baseline.json
+++ b/config/quality/open-sse-typecheck-baseline.json
@@ -1,11 +1,4 @@
{
- "open-sse/handlers/chatCore/clientUsageBuffer.ts": {
- "TS2345": 2
- },
- "open-sse/utils/stream.ts": {
- "TS2345": 2,
- "TS2322": 2
- },
"src/lib/guardrails/videoBridgeHelpers.ts": {
"TS2488": 1,
"TS2365": 2,
diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json
index 3bfef6d099c..604a5a8e19d 100644
--- a/config/quality/quality-baseline.json
+++ b/config/quality/quality-baseline.json
@@ -82,9 +82,10 @@
"tightenSlack": 10
},
"openapiCoverage.pct": {
- "value": 39.2,
+ "value": 38.4,
"direction": "up",
"eps": 0.5,
+ "_rebaseline_2026_08_21_v3850_cycle_drift": "39.2 -> 38.4. Measured locally and in CI collect-metrics on release/v3.8.50 (260/677 implemented routes documented). Cycle added internal/dashboard routes faster than docs/openapi.yaml; documenting LOCAL_ONLY catch-all and service-management paths in the public spec would be gaming (same class as v3.8.34/v3.8.39/v3.8.47). This PR (#10988) adds 0 API routes.",
"_tighten_2026_08_06_v3850_sweepreds": "38.0 -> 39.2 (aperto EXIGIDO pelo step 'Require-tighten (blocking)', que estava vermelho em ~60 PRs abertas de release/v3.8.50 — base-red herdado, nao defeito das PRs). A cobertura melhorou no ciclo porque as rotas novas entraram documentadas. 39.2 = valor medido pelo CI Quality Ratchet no run 31088889488; o tip puro 2ddbbc61a6 mede 39.3 localmente (npm run check:openapi-coverage: 247/628 rotas), entao 39.2 e o valor conservador dos dois. Aperto = gate mais ESTRITO, nunca mascaramento.",
"_tighten_2026_07_04_v3844_release": "36.9 -> 39.3 (aperto exigido pelo --require-tighten no PR de release #5925). A cobertura OpenAPI melhorou no ciclo (9 rotas documentadas em 8fb020676 + as rotas novas de #5939/#5817/#6034/#5998 documentadas junto das features). 39.3 = valor medido pelo CI Quality Ratchet no run 28708141003 (tip 00c55afcb).",
"_rebaseline_2026_06_28_v3839_release": "37.8 -> 36.9 (-0.9, beyond the 0.5 eps). v3.8.39 cycle drift surfaced ONLY on the release PR (the openapi-coverage ratchet does NOT run on PR->release fast-gates). The cycle added API/internal routes (antigravity paste-credentials onboarding, CCR ranged/grep/stats retrieve params, mcp 404 session handling) faster than docs/openapi.yaml coverage; documenting LOCAL_ONLY/internal onboarding routes in the PUBLIC spec would be gaming (same precedent as _rebaseline_2026_06_18_v3828_cycle_close). Measured by CI collect-metrics (run 28317145160) = 36.9. My release-finalize tree touches no routes (only the openapi.yaml version bump). Raising coverage by documenting public routes is tracked as follow-up doc debt.",
@@ -102,8 +103,9 @@
"_rebaseline_2026_07_28_v3849_release": "75.5 -> 99 (+23.5). Aperto EXIGIDO pelo modo --require-tighten do ratchet: a métrica melhorou de verdade no ciclo v3.8.49. A causa é o workflow assíncrono de tradução, que finalmente alcançou o denominador em EN — as rebaselines anteriores (v3.8.39/.44/.47) foram todas afrouxamentos registrando o atraso das traduções, e agora ele foi pago. O coletor SUBTRAI os placeholders (present - placeholder em scripts/quality/collect-metrics.mjs), então os 317 marcadores __MISSING__ que esta release introduziu para o drift de valor já estão descontados dos 99 — o número é honesto, não inflado por placeholder. Medido pelo collect-metrics do CI no run 30404226939."
},
"deadExports": {
- "value": 418,
+ "value": 416,
"direction": "down",
+ "_rebaseline_2026_08_19_v3850_basereds_9985": "415 -> 416. Measured on release/v3.8.50 tip 14a480453 during the #9985 base-red drain. Removed the 2 genuinely-dead symbols traced to a specific recent change (PR #10148, 2026-08-18): the unused src/lib/quota/providerCapabilities.ts file and the unused ProviderQuotaMonitor interface in providerQuotaTelemetry.ts (418 -> 416). The remaining +1 could not be attributed to a single recent commit after checking every dead-list entry touched since the 2026-08-14 baseline measurement (most are pre-existing debt on files edited for unrelated reasons); rebaselining the residual 1 rather than guessing at removals. Structural cleanup stays tracked in #3501.",
"_rebaseline_2026_08_09_v3850_post_sweep": "227 -> 230. Measured by npm run check:dead-code on the unmodified release/v3.8.50 tip 382449d593 during the mandatory --full-ci pre-flight. The +3 is inherited cycle drift from the authorized merge sweep; this repair adds no production exports. Rebaseline records the actual tip so ci.yml quality-gate can run, while structural cleanup remains separate debt.",
"_rebaseline_2026_07_01_v3843_release": "225->227 (+2). v3.8.43 cycle drift, surfaced in the Quality Ratchet job after eslintWarnings was rebaselined (check:dead-code runs there). 227 = measured by check:dead-code (knip) on the release tip 4635076eb. The 5 CI fixes add 0 dead exports: safeHttpHref in linkify.ts is module-local AND used (called by linkifyText); no new exports; test files are not scanned. Tighten via --update next cycle.",
"dedicatedGate": true,
@@ -195,10 +197,11 @@
"_rebaseline_2026_08_09_v3850_release_close": "7666 -> 8045 (+379 gzip bytes, +4.9%). Release v3.8.50 close reconciliation measured twice with the real size-limit + @size-limit/file path on tip e0ce95c592. Per-entry measurements remain below their absolute budgets: omniroute.mjs 4380/15000, mcp-server.mjs 1195/5000, nodeRuntimeSupport.mjs 887/8000, reset-password.mjs 1583/6000. The growth accumulated through legitimate CLI/runtime work in this cycle, including global-install ESM alias resolution, Termux cache preparation, and MCP stdio startup hardening; no entrypoint is near its absolute ceiling. The direction:down ratchet stays blocking from this exact measured tip."
},
"openapiBreaking": {
- "value": 0,
+ "value": 4,
"direction": "down",
"dedicatedGate": true,
- "_note": "oasdiff breaking-change gate (Fase 9 Onda 0). Blocks any breaking change vs base spec."
+ "_note": "oasdiff breaking-change gate (Fase 9 Onda 0). Blocks any breaking change vs base spec.",
+ "_rebaseline_2026_08_22_combo_create_min1": "0 -> 4, split 3 own + 1 inherited. Docs-only alignment of components.schemas.ComboCreate with the request contract already enforced by the API since 638fc5fbd (combo create refuses an empty model list) and d5034ea52: `model`/`nodes` were phantom properties the server never accepted, and `models` (array, minItems 1) is the real required field. OWN findings (3, caused by this commit): removed `model`, removed `nodes`, added required `models` on POST /api/combos — spec-vs-server drift, not client-facing breakage, no working client could have relied on the removed shapes. INHERITED finding (1, NOT caused by this PR's code changes — pre-existing drift already present at parent d5034ea52): PATCH /api/combos/{id} request-body-added-required; that route's patch operation declares its own inline requestBody (required: true, bare object schema, docs/openapi.yaml ~2107-2118) and does not reference ComboCreate, so this finding exists independently of the ComboCreate alignment (same own-growth vs inherited-drift convention as _rebaseline_2026_07_20_aliasresolver_hook_split_7808). No code change in this PR; follow-up tracking = this change's PR description."
},
"mutationScore.src/sse/services/auth.ts": {
"value": 52.57,
diff --git a/contrib/vps/.env.example b/contrib/vps/.env.example
new file mode 100644
index 00000000000..31b5a38feec
--- /dev/null
+++ b/contrib/vps/.env.example
@@ -0,0 +1,21 @@
+# Build this local image from the exact release checkout as documented below,
+# or replace it with an immutable published image digest.
+OMNIROUTE_IMAGE=omniroute:3.8.50-vps
+
+# The dashboard is loopback-only by default. Keep this value unless a trusted
+# reverse proxy or private overlay network is configured on the same host.
+OMNIROUTE_BIND_HOST=127.0.0.1
+OMNIROUTE_PORT=20128
+
+# Generate unique values before the first start. Do not commit the resulting .env.
+JWT_SECRET=
+API_KEY_SECRET=
+OMNIROUTE_WS_BRIDGE_SECRET=
+INITIAL_PASSWORD=
+REQUIRE_API_KEY=true
+
+# Conservative defaults for a small VPS. Adjust after observing real usage.
+OMNIROUTE_MEMORY_LIMIT=1536m
+OMNIROUTE_CPUS=1.0
+OMNIROUTE_PIDS_LIMIT=256
+APP_LOG_LEVEL=info
diff --git a/contrib/vps/README.md b/contrib/vps/README.md
new file mode 100644
index 00000000000..521f468f95d
--- /dev/null
+++ b/contrib/vps/README.md
@@ -0,0 +1,151 @@
+# Headless Linux VPS deployment
+
+This bundle runs the published OmniRoute server image on a Linux VPS without
+the Electron desktop shell. It keeps the dashboard on loopback by default,
+does not publish Redis, persists application data, and adds conservative
+resource and log limits.
+
+Use this bundle when the VPS only needs the API and web dashboard. The existing
+root-level Compose profiles remain the right choice for local development,
+building from source, bundled provider CLIs, or the Playwright/Chromium image.
+
+## Prerequisites
+
+- A supported Linux distribution with Docker Engine and Docker Compose v2.
+- At least 2 GiB of available RAM for the default limits. The host needs more
+ headroom if other workloads run beside OmniRoute.
+- SSH access for the loopback dashboard tunnel.
+
+## Install
+
+Build the headless server image from the exact release checkout. Building it
+locally avoids assuming that a matching version tag has already been published
+to a container registry:
+
+```bash
+git switch --detach release/v3.8.50
+test "$(node -p "require('./package.json').version")" = "3.8.50"
+docker build --target runner-base --tag omniroute:3.8.50-vps .
+```
+
+Then initialize the deployment from the repository root:
+
+```bash
+cd contrib/vps
+cp .env.example .env
+chmod 600 .env
+```
+
+Generate separate values for every secret, then paste them into `.env`:
+
+```bash
+openssl rand -base64 48 # JWT_SECRET
+openssl rand -hex 32 # API_KEY_SECRET
+openssl rand -base64 48 # OMNIROUTE_WS_BRIDGE_SECRET
+openssl rand -base64 24 # INITIAL_PASSWORD
+```
+
+Do not reuse these values across installations. Keep `REQUIRE_API_KEY=true`.
+Keep `OMNIROUTE_IMAGE` on the locally built version tag, or replace it with an
+immutable registry digest; do not use the floating `latest` or `next` tags for
+unattended production.
+
+Validate and start the stack:
+
+```bash
+docker compose config --quiet
+docker compose up -d
+docker compose ps
+```
+
+The dashboard is intentionally bound to `127.0.0.1`. Reach it through SSH:
+
+```bash
+ssh -L 20128:127.0.0.1:20128 user@your-vps
+```
+
+Then open `http://127.0.0.1:20128` locally. For a public hostname, put a trusted
+reverse proxy on the same host in front of the loopback port and terminate TLS
+there. Do not change `OMNIROUTE_BIND_HOST` to `0.0.0.0` merely to make the
+dashboard reachable.
+
+## Verify
+
+```bash
+docker compose ps
+curl --fail --silent http://127.0.0.1:20128/healthz
+docker compose logs --tail=100 omniroute
+```
+
+`/healthz` is a lifecycle probe. Use the authenticated monitoring/API routes
+for deeper provider validation after the first login.
+
+## Web-session providers on a VPS
+
+Consumer web-session providers can enforce IP reputation, TLS fingerprint, or
+browser-session binding. A cookie copied on a workstation may therefore fail
+from a datacenter VPS even when the Linux container is healthy. In particular,
+Grok clearance cookies can be tied to the browser IP, User-Agent, and TLS
+fingerprint. Prefer official API credentials for unattended workloads. When a
+web-session provider is required, use only credentials from an account you own
+and follow that provider's guide; do not weaken TLS verification or bypass an
+access challenge.
+
+## Backup
+
+Stop writes before copying SQLite data, then archive the named volume:
+
+```bash
+docker compose stop omniroute
+mkdir -p backups
+docker run --rm \
+ -v omniroute-vps_omniroute-data:/data:ro \
+ -v "$PWD/backups:/backup" \
+ docker.io/library/alpine:3.23 \
+ tar -C /data -czf /backup/omniroute-data.tar.gz .
+docker compose start omniroute
+```
+
+Verify the archive before relying on it:
+
+```bash
+tar -tzf backups/omniroute-data.tar.gz >/dev/null
+```
+
+Store a timestamped copy outside the VPS. The fixed filename above is kept
+simple for copy/paste; rename it after each verified backup.
+
+## Update and rollback
+
+Before updating, record the currently running immutable digest and take a
+verified backup:
+
+```bash
+docker image inspect "$(docker compose images -q omniroute)" \
+ --format '{{index .RepoDigests 0}}'
+```
+
+Build the new local version tag first, or set `OMNIROUTE_IMAGE` in `.env` to a
+new immutable registry digest. Pull only when the selected image is remote,
+then recreate the application container:
+
+```bash
+# Registry images only: docker compose pull omniroute
+docker compose up -d --no-deps omniroute
+docker compose ps
+curl --fail --silent http://127.0.0.1:20128/healthz
+```
+
+To roll back the application image, restore the previous value of
+`OMNIROUTE_IMAGE` and repeat the applicable `pull` and `up` commands. Restore the data
+archive only when a migration changed the persisted data and image rollback
+alone is insufficient. Keep the stack stopped while restoring the volume.
+
+## Remove the stack
+
+```bash
+docker compose down
+```
+
+This preserves both named volumes. `docker compose down -v` deletes persistent
+data and is intentionally not part of the normal uninstall path.
diff --git a/contrib/vps/compose.yaml b/contrib/vps/compose.yaml
new file mode 100644
index 00000000000..4e7c43ebd64
--- /dev/null
+++ b/contrib/vps/compose.yaml
@@ -0,0 +1,69 @@
+name: omniroute-vps
+
+services:
+ redis:
+ image: docker.io/library/redis:8.6.5-alpine
+ restart: unless-stopped
+ command: ["redis-server", "--save", "60", "1", "--appendonly", "yes", "--loglevel", "warning"]
+ volumes:
+ - redis-data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 3
+ logging:
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ omniroute:
+ image: ${OMNIROUTE_IMAGE:?Set OMNIROUTE_IMAGE to a versioned tag or digest}
+ restart: unless-stopped
+ stop_grace_period: 40s
+ depends_on:
+ redis:
+ condition: service_healthy
+ env_file:
+ - .env
+ environment:
+ NODE_ENV: production
+ PORT: "20128"
+ DASHBOARD_PORT: "20128"
+ HOSTNAME: 0.0.0.0
+ DATA_DIR: /app/data
+ REDIS_URL: redis://redis:6379
+ REQUIRE_API_KEY: ${REQUIRE_API_KEY:-true}
+ JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in .env}
+ API_KEY_SECRET: ${API_KEY_SECRET:?Set API_KEY_SECRET in .env}
+ INITIAL_PASSWORD: ${INITIAL_PASSWORD:?Set INITIAL_PASSWORD in .env}
+ OMNIROUTE_WS_BRIDGE_SECRET: ${OMNIROUTE_WS_BRIDGE_SECRET:?Set OMNIROUTE_WS_BRIDGE_SECRET in .env}
+ ports:
+ - "${OMNIROUTE_BIND_HOST:-127.0.0.1}:${OMNIROUTE_PORT:-20128}:20128"
+ volumes:
+ - omniroute-data:/app/data
+ tmpfs:
+ - /tmp:size=256m,mode=1777
+ security_opt:
+ - no-new-privileges:true
+ cap_drop:
+ - ALL
+ pids_limit: ${OMNIROUTE_PIDS_LIMIT:-256}
+ mem_limit: ${OMNIROUTE_MEMORY_LIMIT:-1536m}
+ cpus: ${OMNIROUTE_CPUS:-1.0}
+ healthcheck:
+ test: ["CMD", "node", "healthcheck.mjs"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 20s
+ logging:
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+volumes:
+ omniroute-data:
+ redis-data:
diff --git a/docs/README.md b/docs/README.md
index 185e0b915b2..2fe8a425cd7 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -34,7 +34,7 @@ Simple guides for using OmniRoute — no technical background needed.
- [USAGE_QUOTA_GUIDE.md](guides/USAGE_QUOTA_GUIDE.md) — usage, quota & spend tracking.
- [COST_TRACKING.md](guides/COST_TRACKING.md) — cost and spend tracking.
- [FREE_PROVIDER_RANKINGS.md](guides/FREE_PROVIDER_RANKINGS.md) — free provider rankings (Arena ELO).
-- [DOCKER_GUIDE.md](guides/DOCKER_GUIDE.md) — running OmniRoute under Docker.
+- [DOCKER_GUIDE.md](guides/DOCKER_GUIDE.md) — running OmniRoute under Docker, including runtime RAM for coding agents.
- [ELECTRON_GUIDE.md](guides/ELECTRON_GUIDE.md) — desktop (Electron) builds.
- [TERMUX_GUIDE.md](guides/TERMUX_GUIDE.md) — running on Android via Termux.
- [PWA_GUIDE.md](guides/PWA_GUIDE.md) — installing the dashboard as a PWA.
diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md
index b78d433fee8..6c9d4007825 100644
--- a/docs/architecture/ARCHITECTURE.md
+++ b/docs/architecture/ARCHITECTURE.md
@@ -1131,7 +1131,6 @@ Environment variables actively used by code:
- App/auth: `JWT_SECRET`, `INITIAL_PASSWORD`
- Storage: `DATA_DIR`
-- Compatible node behavior: `ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE`
- Optional storage base override (Linux/macOS when `DATA_DIR` unset): `XDG_CONFIG_HOME`
- Security hashing: `API_KEY_SECRET`, `MACHINE_ID_SALT`
- Logging: `APP_LOG_TO_FILE`, `APP_LOG_RETENTION_DAYS`, `CALL_LOG_RETENTION_DAYS`
diff --git a/docs/architecture/RESILIENCE_GUIDE.md b/docs/architecture/RESILIENCE_GUIDE.md
index 030f65dd414..0048761e609 100644
--- a/docs/architecture/RESILIENCE_GUIDE.md
+++ b/docs/architecture/RESILIENCE_GUIDE.md
@@ -448,14 +448,14 @@ classification rules pick the fallback `reason` and lock `scope`
Classification rules only see full error **text** (needed to match body
markers like `额度不足`) for providers listed in the `FULL_TEXT_RULE_PROVIDERS`
allowlist in `providerErrorRules.ts` — currently only `"agentrouter"`. For
-every other provider, `checkFallbackError` hands `getProviderErrorRuleMatch`
-only the structured error (`{code, type}`), which is enough for
-header/status/code-based rules but blind to body-text markers. The helper
-`resolveRuleMatchBody()` performs this selection: full error text for
-allowlisted providers, the structured error otherwise. Adding a provider to
-`FULL_TEXT_RULE_PROVIDERS` is an explicit per-provider opt-in — it exists so
-that the default path for every provider not on the list stays
-byte-for-byte unchanged.
+every other **built-in catalog** provider, `checkFallbackError` hands
+`getProviderErrorRuleMatch` only the structured error (`{code, type}`), which
+is enough for header/status/code-based rules but blind to body-text markers.
+The helper `resolveRuleMatchBody()` performs this selection: full error text
+for allowlisted providers, the structured error otherwise. Adding a
+**built-in** provider to `FULL_TEXT_RULE_PROVIDERS` is an explicit per-provider
+opt-in — it exists so that the default path for every provider not on the
+list stays byte-for-byte unchanged.
A rule's `scope` (`model` / `provider` / `connection`) is a separate opt-in
from `FULL_TEXT_RULE_PROVIDERS`: `checkFallbackError` only surfaces it as
@@ -466,6 +466,31 @@ honorsRuleLockScope()` — today only `"agentrouter"`). See "Restated quota
errors" above for what a `scope: "connection"` match actually does once a
provider is on that allowlist.
+**#11104 — operator-declared rules bypass both allowlists.** An operator can
+declare a per-provider rule at runtime via `settings.providerErrorRules`
+(`open-sse/config/providerErrorRules.ts::setOperatorProviderErrorRules`)
+without editing this file. Gating an operator rule behind
+`FULL_TEXT_RULE_PROVIDERS`/`HONORS_RULE_LOCK_SCOPE_PROVIDERS` — allowlists
+meant to protect the **default** behavior of built-in catalog rules — would
+make the settings mechanism inert for every provider except the ones already
+listed there, since declaring the rule is already the operator's explicit
+opt-in. `resolveRuleMatchBody()` and `honorsRuleLockScope()` both check
+`hasOperatorRuleForProvider()` first: a provider with an operator rule gets
+the raw error text and has its declared `scope` honored, regardless of
+whether it also appears in either allowlist.
+
+**Known gap — `providerRuleRegistry` is never consulted for HTTP 400.**
+`checkFallbackError`'s `BAD_REQUEST` branch classifies status 400 entirely
+through its own pattern arrays (`MODEL_ACCESS_DENIED_PATTERNS`,
+`CONTEXT_OVERFLOW_PATTERNS`, etc. in `accountFallback.ts`) and returns before
+the `configuredRule`/`getProviderErrorRuleMatch` branch above it is reached.
+A built-in catalog rule (or an operator rule) with `status: 400` is
+syntactically valid but will never fire. No existing rule targets 400 today,
+so nothing in production is affected — but a future 400 rule needs this
+branch touched first, which is a larger change than adding a rule (it
+reclassifies 400 for every provider already relying on the pattern-array
+behavior) and is out of scope for a single-provider rule addition.
+
### Adding a new quota-misstating gateway
1. Register one rule array in `statusRestatementRegistry`
diff --git a/docs/changelog/fragments/10962.md b/docs/changelog/fragments/10962.md
new file mode 100644
index 00000000000..5170a415e3f
--- /dev/null
+++ b/docs/changelog/fragments/10962.md
@@ -0,0 +1 @@
+fix(catalog): expose only provider-routable GLM reasoning-effort tiers and remove unroutable ZCode aliases
diff --git a/docs/diagrams/cli-terminal.svg b/docs/diagrams/cli-terminal.svg
index 4fd6887859f..e2ad57c8b13 100644
--- a/docs/diagrams/cli-terminal.svg
+++ b/docs/diagrams/cli-terminal.svg
@@ -1,6 +1,6 @@
-