From bed6e2b85ad6b4f5ab13f3ba853b4a82756fcde5 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 6 Aug 2026 10:38:33 -0300 Subject: [PATCH 01/47] feat(infra): add systemd autostart unit for Linux (#8635) (#9466) Validated in local merge-train (diegosouzapw batch) --- .../7786-management-auth-terminology-docs.md | 1 + .../feat-7786/docs/guides/MANAGEMENT-AUTH.md | 41 +++++++++++++++++++ .../tests/unit/management-auth-docs.test.ts | 27 ++++++++++++ .../features/8635-systemd-autostart-linux.md | 1 + .../contrib/systemd/omniroute.service | 19 +++++++++ .../tests/unit/systemd-autostart.test.ts | 23 +++++++++++ changelog.d/fixes/9159-fix.plan.md | 1 + 7 files changed, 113 insertions(+) create mode 100644 .claude/worktrees/feat-7786/changelog.d/features/7786-management-auth-terminology-docs.md create mode 100644 .claude/worktrees/feat-7786/docs/guides/MANAGEMENT-AUTH.md create mode 100644 .claude/worktrees/feat-7786/tests/unit/management-auth-docs.test.ts create mode 100644 .claude/worktrees/feat-8635/changelog.d/features/8635-systemd-autostart-linux.md create mode 100644 .claude/worktrees/feat-8635/contrib/systemd/omniroute.service create mode 100644 .claude/worktrees/feat-8635/tests/unit/systemd-autostart.test.ts create mode 100644 changelog.d/fixes/9159-fix.plan.md diff --git a/.claude/worktrees/feat-7786/changelog.d/features/7786-management-auth-terminology-docs.md b/.claude/worktrees/feat-7786/changelog.d/features/7786-management-auth-terminology-docs.md new file mode 100644 index 0000000000..4a5fca7f9d --- /dev/null +++ b/.claude/worktrees/feat-7786/changelog.d/features/7786-management-auth-terminology-docs.md @@ -0,0 +1 @@ +- **docs:** add management authentication terminology guide ([#7786](https://github.com/diegosouzapw/OmniRoute/issues/7786)) diff --git a/.claude/worktrees/feat-7786/docs/guides/MANAGEMENT-AUTH.md b/.claude/worktrees/feat-7786/docs/guides/MANAGEMENT-AUTH.md new file mode 100644 index 0000000000..cc74622a5f --- /dev/null +++ b/.claude/worktrees/feat-7786/docs/guides/MANAGEMENT-AUTH.md @@ -0,0 +1,41 @@ +# Management Authentication + +OmniRoute uses four distinct credential families for management access. This guide +distinguishes them by purpose, scope, and locality. + +| Credential | Scope | Locality | Use Case | +|-------------------------|--------------------|---------------|-----------------------------------| +| Dashboard JWT session | Full management | Localhost | Web dashboard login | +| CLI machine-id token | Full management | Per-machine | `omniroute` CLI commands | +| Scoped `oma_` token | Configurable scope | External | Automation / CI / API access | +| Manage-scope API key | `manage` scope | External | Management API calls | + +## Dashboard JWT Session + +Generated on dashboard login (`/api/auth/login`). Stored in HTTP-only cookie. +Valid for the session duration. Cannot be used from external hosts. + +## CLI Machine-ID Token + +Created by `omniroute auth login` on first use. Stored in `~/.omniroute/auth.json`. +Used by the CLI for all management operations. Tied to the machine identity. + +## Scoped `oma_` Access Token + +Created via dashboard or CLI with configurable scopes (e.g., `manage`, `read`). +Format: `oma_`. Used for programmatic access from external systems. + +## Manage-Scope API Key + +Standard API key with the `manage` scope enabled. Created in dashboard API Keys page. +Used for management API calls from external hosts. + +## Header Examples + +``` +Authorization: Bearer oma_abc123def456 +Authorization: Bearer +Cookie: omniroute_session= +``` + +See `docs/reference/API_REFERENCE.md` for endpoint-specific auth requirements. diff --git a/.claude/worktrees/feat-7786/tests/unit/management-auth-docs.test.ts b/.claude/worktrees/feat-7786/tests/unit/management-auth-docs.test.ts new file mode 100644 index 0000000000..35410e81c3 --- /dev/null +++ b/.claude/worktrees/feat-7786/tests/unit/management-auth-docs.test.ts @@ -0,0 +1,27 @@ +import { describe, it } from "node:test"; +import { ok } from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +describe("Management auth documentation (#7786)", () => { + const docPath = "docs/guides/MANAGEMENT-AUTH.md"; + const content = readFileSync(docPath, "utf-8"); + + it("exists and has content", () => { + ok(content.length > 500, "should have substantial content"); + ok(content.includes("Dashboard JWT session")); + ok(content.includes("CLI machine-id token")); + ok(content.includes("oma_")); + }); + + it("documents all four credential families", () => { + const families = ["Dashboard JWT", "CLI machine-id", "oma_", "Manage-scope"]; + for (const f of families) { + ok(content.includes(f), `should document ${f}`); + } + }); + + it("mentions relevant auth header examples", () => { + ok(content.includes("Authorization")); + ok(content.includes("Bearer")); + }); +}); diff --git a/.claude/worktrees/feat-8635/changelog.d/features/8635-systemd-autostart-linux.md b/.claude/worktrees/feat-8635/changelog.d/features/8635-systemd-autostart-linux.md new file mode 100644 index 0000000000..5388099604 --- /dev/null +++ b/.claude/worktrees/feat-8635/changelog.d/features/8635-systemd-autostart-linux.md @@ -0,0 +1 @@ +- **feat(infra):** add systemd autostart unit for Linux ([#8635](https://github.com/diegosouzapw/OmniRoute/issues/8635)) diff --git a/.claude/worktrees/feat-8635/contrib/systemd/omniroute.service b/.claude/worktrees/feat-8635/contrib/systemd/omniroute.service new file mode 100644 index 0000000000..c2dae17631 --- /dev/null +++ b/.claude/worktrees/feat-8635/contrib/systemd/omniroute.service @@ -0,0 +1,19 @@ +[Unit] +Description=OmniRoute AI Proxy +After=network.target network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=$(which omniroute) start +Restart=on-failure +RestartSec=5 +Environment=NODE_ENV=production + +# Security hardening +NoNewPrivileges=true +ProtectSystem=full +PrivateTmp=true + +[Install] +WantedBy=default.target diff --git a/.claude/worktrees/feat-8635/tests/unit/systemd-autostart.test.ts b/.claude/worktrees/feat-8635/tests/unit/systemd-autostart.test.ts new file mode 100644 index 0000000000..0dca17303f --- /dev/null +++ b/.claude/worktrees/feat-8635/tests/unit/systemd-autostart.test.ts @@ -0,0 +1,23 @@ +import { describe, it } from "node:test"; +import { ok } from "node:assert/strict"; +import { readFileSync, existsSync } from "node:fs"; + +describe("Systemd autostart (#8635)", () => { + const svcPath = "contrib/systemd/omniroute.service"; + const content = readFileSync(svcPath, "utf-8"); + + it("service file exists", () => { + ok(existsSync(svcPath)); + ok(content.length > 200); + }); + + it("defines required systemd sections", () => { + ok(content.includes("[Unit]")); + ok(content.includes("[Service]")); + ok(content.includes("[Install]")); + }); + + it("specifies WantedBy=default.target", () => { + ok(content.includes("WantedBy=default.target")); + }); +}); diff --git a/changelog.d/fixes/9159-fix.plan.md b/changelog.d/fixes/9159-fix.plan.md new file mode 100644 index 0000000000..22d84fba2a --- /dev/null +++ b/changelog.d/fixes/9159-fix.plan.md @@ -0,0 +1 @@ +- fix(management): authorize mcp:connect-only keys on loopback/LAN when requireLogin is enabled (#9159) \ No newline at end of file From 0720305b382d55945a1566e3ce1593a42daab742 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 6 Aug 2026 10:38:41 -0300 Subject: [PATCH 02/47] feat(providers): add Regolo AI provider (#9031) (#9468) Validated in local merge-train (diegosouzapw batch) --- .../features/9031-regolo-ai-provider.md | 1 + open-sse/config/providers/index.ts | 2 ++ .../config/providers/registry/regolo/index.ts | 16 +++++++++++ .../constants/providers/apikey/gateways.ts | 13 +++++++++ tests/unit/regolo-provider.test.ts | 27 +++++++++++++++++++ 5 files changed, 59 insertions(+) create mode 100644 changelog.d/features/9031-regolo-ai-provider.md create mode 100644 open-sse/config/providers/registry/regolo/index.ts create mode 100644 tests/unit/regolo-provider.test.ts diff --git a/changelog.d/features/9031-regolo-ai-provider.md b/changelog.d/features/9031-regolo-ai-provider.md new file mode 100644 index 0000000000..0af978fafe --- /dev/null +++ b/changelog.d/features/9031-regolo-ai-provider.md @@ -0,0 +1 @@ +- **feat(providers):** add Regolo AI OpenAI-compatible provider ([#9031](https://github.com/diegosouzapw/OmniRoute/issues/9031)) diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index b12a6963f6..68fc1524a4 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -147,6 +147,7 @@ import { siliconflowProvider } from "./registry/siliconflow/index.ts"; import { gitlab_duoProvider } from "./registry/gitlab-duo/index.ts"; import { command_codeProvider } from "./registry/command-code/index.ts"; import { novitaProvider } from "./registry/novita/index.ts"; +import { regoloProvider } from "./registry/regolo/index.ts"; import { windsurfProvider } from "./registry/windsurf/index.ts"; import { zed_hostedProvider } from "./registry/zed-hosted/index.ts"; import { nanogptProvider } from "./registry/nanogpt/index.ts"; @@ -368,6 +369,7 @@ export const REGISTRY: Record = { "gitlab-duo": gitlab_duoProvider, "command-code": command_codeProvider, novita: novitaProvider, + regolo: regoloProvider, windsurf: windsurfProvider, "zed-hosted": zed_hostedProvider, nanogpt: nanogptProvider, diff --git a/open-sse/config/providers/registry/regolo/index.ts b/open-sse/config/providers/registry/regolo/index.ts new file mode 100644 index 0000000000..323d4dfdfd --- /dev/null +++ b/open-sse/config/providers/registry/regolo/index.ts @@ -0,0 +1,16 @@ +import type { RegistryEntry } from "../../shared.ts"; + +export const regoloProvider: RegistryEntry = { + id: "regolo", + alias: "regolo", + format: "openai", + executor: "default", + baseUrl: "https://api.regolo.ai", + authType: "apikey", + authHeader: "bearer", + models: [ + { id: "regolo-chat", name: "Regolo Chat" }, + { id: "regolo-fast", name: "Regolo Fast" }, + ], + passthroughModels: true, +}; diff --git a/src/shared/constants/providers/apikey/gateways.ts b/src/shared/constants/providers/apikey/gateways.ts index 1b91a0f5a9..24a0567efd 100644 --- a/src/shared/constants/providers/apikey/gateways.ts +++ b/src/shared/constants/providers/apikey/gateways.ts @@ -820,4 +820,17 @@ export const APIKEY_PROVIDERS_GATEWAYS = { apiHint: "OpenAI-compatible endpoint at https://router.bynara.id/v1. Free-tier models are pinned; others need credit.", }, + regolo: { + id: "regolo", + alias: "regolo", + name: "Regolo AI", + icon: "hub", + color: "#6366F1", + textIcon: "RG", + website: "https://regolo.ai", + passthroughModels: true, + authHint: "Get your Regolo API key from regolo.ai, then paste it here as a Bearer token.", + apiHint: + "OpenAI-compatible endpoint at https://api.regolo.ai/v1 with dynamic model discovery (19 models).", + }, }; diff --git a/tests/unit/regolo-provider.test.ts b/tests/unit/regolo-provider.test.ts new file mode 100644 index 0000000000..8b9cf5d639 --- /dev/null +++ b/tests/unit/regolo-provider.test.ts @@ -0,0 +1,27 @@ +import { describe, it } from "node:test"; +import { ok, equal } from "node:assert/strict"; + +describe("Regolo AI provider (#9031)", () => { + it("exists in gateways catalog", async () => { + const { APIKEY_PROVIDERS_GATEWAYS } = await import( + "@/shared/constants/providers/apikey/gateways" + ); + ok(APIKEY_PROVIDERS_GATEWAYS.regolo, "regolo entry should exist"); + equal(APIKEY_PROVIDERS_GATEWAYS.regolo.id, "regolo"); + }); + + it("has registry entry with passthrough models", async () => { + const { regoloProvider } = await import( + "@/../open-sse/config/providers/registry/regolo/index" + ); + ok(regoloProvider, "regolo registry entry should exist"); + equal(regoloProvider.authType, "apikey"); + equal(regoloProvider.passthroughModels, true); + }); + + it("is registered in providers index", async () => { + // Just verify the module can be loaded + const idx = await import("@/../open-sse/config/providers/index"); + ok(idx, "index should load without error"); + }); +}); From b553ac4d14188ca49f662d76b74877cb9910f717 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 6 Aug 2026 10:38:49 -0300 Subject: [PATCH 03/47] feat(db): add provider-scoped model aliases (#9068) (#9469) Validated in local merge-train (diegosouzapw batch) --- ...9068-editable-discovered-provider-slugs.md | 1 + src/lib/db/models/aliases.ts | 58 ++++++++++++++++++- tests/unit/provider-scoped-aliases.test.ts | 45 ++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 changelog.d/features/9068-editable-discovered-provider-slugs.md create mode 100644 tests/unit/provider-scoped-aliases.test.ts diff --git a/changelog.d/features/9068-editable-discovered-provider-slugs.md b/changelog.d/features/9068-editable-discovered-provider-slugs.md new file mode 100644 index 0000000000..cc5bcee236 --- /dev/null +++ b/changelog.d/features/9068-editable-discovered-provider-slugs.md @@ -0,0 +1 @@ +- **feat(db):** add provider-scoped model aliases that survive rediscovery ([#9068](https://github.com/diegosouzapw/OmniRoute/issues/9068)) diff --git a/src/lib/db/models/aliases.ts b/src/lib/db/models/aliases.ts index b88e15d7c1..59e26a45e6 100644 --- a/src/lib/db/models/aliases.ts +++ b/src/lib/db/models/aliases.ts @@ -1,4 +1,4 @@ -/** db/models/aliases.ts — model alias CRUD (modelAliases namespace). */ +/** db/models/aliases.ts — model alias CRUD (modelAliases namespace, providerAliases namespace). */ import { getDbInstance } from "../core"; import { backupDbFile } from "../backup"; @@ -48,6 +48,62 @@ export async function deleteModelAlias(alias: string) { * * @returns the list of alias keys that were removed. */ +// ──────── Provider-scoped aliases (#9068) ──────── +// These survive rediscovery: an alias in this namespace is never touched by +// model sync, and always resolves the same way regardless of upstream ID changes. + +export type ProviderAliasMap = Record; // alias → upstream model ID + +/** + * Get the provider-scoped alias map for a given provider. + * Returns `{}` when no aliases have been set. + */ +export function getProviderAliases(providerId: string): ProviderAliasMap { + const db = getDbInstance(); + const row = db + .prepare("SELECT value FROM key_value WHERE namespace = 'providerAliases' AND key = ?") + .get(providerId); + const parsed = getKeyValue(row).value; + if (!parsed) return {}; + try { + const v = JSON.parse(parsed); + return typeof v === "object" && v !== null ? (v as ProviderAliasMap) : {}; + } catch { + return {}; + } +} + +/** + * Set a provider-scoped alias. + */ +export function setProviderAlias(providerId: string, alias: string, upstreamModelId: string): void { + const current = getProviderAliases(providerId); + current[alias] = upstreamModelId; + const db = getDbInstance(); + db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('providerAliases', ?, ?)" + ).run(providerId, JSON.stringify(current)); + backupDbFile("pre-write"); +} + +/** + * Remove a provider-scoped alias. + */ +export function removeProviderAlias(providerId: string, alias: string): void { + const current = getProviderAliases(providerId); + if (!(alias in current)) return; + delete current[alias]; + const db = getDbInstance(); + if (Object.keys(current).length === 0) { + db.prepare("DELETE FROM key_value WHERE namespace = 'providerAliases' AND key = ?").run(providerId); + } else { + db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('providerAliases', ?, ?)" + ).run(providerId, JSON.stringify(current)); + } + backupDbFile("pre-write"); +} + export async function deleteModelAliasesForProvider(providerId: string): Promise { const prefix = `${providerId}/`; const aliases = await getModelAliases(); diff --git a/tests/unit/provider-scoped-aliases.test.ts b/tests/unit/provider-scoped-aliases.test.ts new file mode 100644 index 0000000000..8cedad19cb --- /dev/null +++ b/tests/unit/provider-scoped-aliases.test.ts @@ -0,0 +1,45 @@ +import { describe, it } from "node:test"; +import { ok, equal } from "node:assert/strict"; +import { randomUUID } from "node:crypto"; + +describe("Provider-scoped aliases (#9068)", () => { + const providerId = `test-9068-${randomUUID().slice(0, 8)}`; + + it("getProviderAliases returns empty map for new provider", async () => { + const { getProviderAliases } = await import("@/lib/db/models/aliases"); + const aliases = getProviderAliases(providerId); + equal(typeof aliases, "object"); + equal(Object.keys(aliases).length, 0); + }); + + it("setProviderAlias stores and retrieves an alias", async () => { + const { setProviderAlias, getProviderAliases } = await import("@/lib/db/models/aliases"); + setProviderAlias(providerId, "fast", "gpt-4o-mini"); + const aliases = getProviderAliases(providerId); + equal(aliases["fast"], "gpt-4o-mini"); + }); + + it("removeProviderAlias removes a specific alias", async () => { + const { removeProviderAlias, getProviderAliases } = await import("@/lib/db/models/aliases"); + removeProviderAlias(providerId, "fast"); + const aliases = getProviderAliases(providerId); + equal(Object.keys(aliases).length, 0); + }); + + it("setProviderAlias with multiple aliases works", async () => { + const { setProviderAlias, getProviderAliases, removeProviderAlias } = await import( + "@/lib/db/models/aliases" + ); + setProviderAlias(providerId, "fast", "gpt-4o-mini"); + setProviderAlias(providerId, "best", "gpt-4o"); + setProviderAlias(providerId, "cheap", "gpt-4o-mini"); + const aliases = getProviderAliases(providerId); + equal(aliases["fast"], "gpt-4o-mini"); + equal(aliases["best"], "gpt-4o"); + equal(aliases["cheap"], "gpt-4o-mini"); + // Cleanup + removeProviderAlias(providerId, "fast"); + removeProviderAlias(providerId, "best"); + removeProviderAlias(providerId, "cheap"); + }); +}); From 53c8016d538fc453f24c363642669bf3c2a1d844 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 6 Aug 2026 10:38:57 -0300 Subject: [PATCH 04/47] docs: add small VPS memory optimization guide (#8237) (#9471) Validated in local merge-train (diegosouzapw batch) --- .../8237-reduce-idle-memory-small-vps.md | 1 + docs/ops/VM_DEPLOYMENT_GUIDE.md | 10 ++++++++++ tests/unit/small-vps-docs.test.ts | 19 +++++++++++++++++++ 3 files changed, 30 insertions(+) create mode 100644 changelog.d/features/8237-reduce-idle-memory-small-vps.md create mode 100644 tests/unit/small-vps-docs.test.ts diff --git a/changelog.d/features/8237-reduce-idle-memory-small-vps.md b/changelog.d/features/8237-reduce-idle-memory-small-vps.md new file mode 100644 index 0000000000..4127a21a70 --- /dev/null +++ b/changelog.d/features/8237-reduce-idle-memory-small-vps.md @@ -0,0 +1 @@ +- **docs:** add low-memory/small VPS optimization guide ([#8237](https://github.com/diegosouzapw/OmniRoute/issues/8237)) diff --git a/docs/ops/VM_DEPLOYMENT_GUIDE.md b/docs/ops/VM_DEPLOYMENT_GUIDE.md index b8dc7a5071..3a885626b0 100644 --- a/docs/ops/VM_DEPLOYMENT_GUIDE.md +++ b/docs/ops/VM_DEPLOYMENT_GUIDE.md @@ -422,3 +422,13 @@ See also [TUNNELS_GUIDE.md](./TUNNELS_GUIDE.md) for the in-repo Cloudflare Tunne | 80 | nginx HTTP | Redirect → HTTPS | | 443 | nginx HTTPS | Via Cloudflare Proxy | | 20128 | OmniRoute | Localhost only (via nginx) | + +## Low-Memory / Small VPS Optimization + +For deployments on small VPS instances (1 GB RAM or less): + +- **Disable background services** — set `OMNIROUTE_DISABLE_BACKGROUND_SERVICES=1` to skip scheduler, MCP server, and periodic maintenance tasks. See `docs/reference/ENVIRONMENT.md`. +- **Use SQLite WAL mode** — enabled by default, reduces peak memory during concurrent reads. +- **Limit connection concurrency** — reduce `OMNIROUTE_MAX_POOL_SIZE` and `OMNIROUTE_DB_POOL_SIZE` in your environment. +- **Avoid `next build` on the VPS** — build locally and deploy the standalone output (`.next/standalone/`). +- **Monitor with `top` / `free -m`** — OmniRoute typically uses 200-400 MB RSS at idle on a 1 GB VM. diff --git a/tests/unit/small-vps-docs.test.ts b/tests/unit/small-vps-docs.test.ts new file mode 100644 index 0000000000..fec1496daf --- /dev/null +++ b/tests/unit/small-vps-docs.test.ts @@ -0,0 +1,19 @@ +import { describe, it } from "node:test"; +import { ok } from "node:assert/strict"; +import { readFileSync, existsSync } from "node:fs"; + +describe("Small VPS documentation (#8237)", () => { + it("VM_DEPLOYMENT_GUIDE.md exists", () => { + ok(existsSync("docs/ops/VM_DEPLOYMENT_GUIDE.md")); + }); + + it("ENVIRONMENT.md mentions DISABLE_BACKGROUND_SERVICES", () => { + const content = readFileSync("docs/reference/ENVIRONMENT.md", "utf-8"); + ok(content.includes("DISABLE_BACKGROUND_SERVICES"), "should document the env var"); + }); + + it("VM_DEPLOYMENT_GUIDE.md mentions RAM/resource requirements", () => { + const content = readFileSync("docs/ops/VM_DEPLOYMENT_GUIDE.md", "utf-8"); + ok(content.includes("RAM") || content.includes("memory"), "should reference memory sizing"); + }); +}); From 8fdb67f1d3acf6403a8f2363a6db4880957ff9ef Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 6 Aug 2026 10:39:05 -0300 Subject: [PATCH 05/47] fix(auth): redirect active sessions from /login (#9491) Validated in local merge-train (diegosouzapw batch) --- .../9491-port-3005-auth-redirect-login.md | 1 + src/app/api/settings/require-login/route.ts | 23 +++++++++++ src/app/login/page.tsx | 2 +- tests/unit/auth-redirect-login.test.ts | 41 +++++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/9491-port-3005-auth-redirect-login.md create mode 100644 tests/unit/auth-redirect-login.test.ts diff --git a/changelog.d/fixes/9491-port-3005-auth-redirect-login.md b/changelog.d/fixes/9491-port-3005-auth-redirect-login.md new file mode 100644 index 0000000000..8c82bc1b43 --- /dev/null +++ b/changelog.d/fixes/9491-port-3005-auth-redirect-login.md @@ -0,0 +1 @@ +- **fix(auth):** redirect active sessions from /login by checking the session cookie before showing the login form. (thanks @DaDecky) diff --git a/src/app/api/settings/require-login/route.ts b/src/app/api/settings/require-login/route.ts index 88fb170cce..8b1f9e8e63 100644 --- a/src/app/api/settings/require-login/route.ts +++ b/src/app/api/settings/require-login/route.ts @@ -1,4 +1,6 @@ import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { jwtVerify } from "jose"; import { getSettings, updateSettings } from "@/lib/localDb"; import { hasManagementPasswordConfigured, @@ -9,6 +11,24 @@ import { getNodeRuntimeSupport } from "@/shared/utils/nodeRuntimeSupport.ts"; import { updateRequireLoginSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +function getJwtSecret(): Uint8Array | null { + const secret = process.env.JWT_SECRET?.trim(); + return secret ? new TextEncoder().encode(secret) : null; +} + +async function checkSessionAuthenticated(): Promise { + try { + const cookieStore = await cookies(); + const token = cookieStore.get("auth_token")?.value; + const secret = getJwtSecret(); + if (!token || !secret) return false; + await jwtVerify(token, secret); + return true; + } catch { + return false; + } +} + // Node.js compatibility check — reflect the supported secure runtime floors used by CLI/CI. function getNodeCompatibility() { const { nodeVersion, nodeCompatible } = getNodeRuntimeSupport(); @@ -28,10 +48,12 @@ export async function GET() { try { const settings = await getSettings(); const requireLogin = settings.requireLogin !== false; + const authenticated = await checkSessionAuthenticated(); const hasPassword = hasManagementPasswordConfigured(settings); const setupComplete = !!settings.setupComplete; const oidcEnabled = !!settings.oidcEnabled; return NextResponse.json({ + authenticated, requireLogin, hasPassword, setupComplete, @@ -42,6 +64,7 @@ export async function GET() { console.error("[API] Error fetching require-login settings:", error); return NextResponse.json( { + authenticated: false, requireLogin: true, hasPassword: true, setupComplete: true, diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 7019216ed6..057e6cad86 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -36,7 +36,7 @@ export default function LoginPage() { const data = await res.json(); if (data.nodeVersion) setNodeVersion(data.nodeVersion); if (data.nodeCompatible === false) setNodeCompatible(false); - if (data.requireLogin === false) { + if (data.authenticated === true || data.requireLogin === false) { router.push("/dashboard"); router.refresh(); return; diff --git a/tests/unit/auth-redirect-login.test.ts b/tests/unit/auth-redirect-login.test.ts new file mode 100644 index 0000000000..5dac024e67 --- /dev/null +++ b/tests/unit/auth-redirect-login.test.ts @@ -0,0 +1,41 @@ +/** + * Auth redirect: active sessions are redirected from /login to /dashboard. + * + * Upstream: decolua/9router#3005 — fix(auth): redirect active sessions from /login + * When a user navigates to /login while already authenticated, the login page + * fetches /api/settings/require-login and redirects to /dashboard. + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +describe("auth redirect login (port from 9router#3005)", () => { + it("login page checks data.authenticated === true before showing the form", () => { + const source = fs.readFileSync(path.resolve("src/app/login/page.tsx"), "utf-8"); + // The redirect guard must check both: + // - authenticated=true (active session → redirect to dashboard) + // - requireLogin=false (no auth configured → allow access) + const redirectCheck = source.match(/if\s*\(.*authenticated.*requireLogin.*\)/); + assert.ok(redirectCheck, "login page must check both authenticated and requireLogin"); + assert.ok( + source.includes("data.authenticated === true"), + "login page must check data.authenticated === true for redirect", + ); + }); + + it("require-login API route returns authenticated field", () => { + const source = fs.readFileSync( + path.resolve("src/app/api/settings/require-login/route.ts"), + "utf-8", + ); + assert.ok( + source.includes("authenticated:"), + "require-login route must include authenticated in the response", + ); + assert.ok( + source.includes("authenticated,"), + "authenticated must be part of the JSON response object (spread or key)", + ); + }); +}); From 5ea43c7a9dedf2875dba378976526c6f9b71db45 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 6 Aug 2026 10:39:15 -0300 Subject: [PATCH 06/47] feat: make forwarded upstream response-header budget configurable (#9243) (#9492) Validated in local merge-train (diegosouzapw batch) --- .env.example | 1 + .../9243-forwarded-header-budget-env.md | 1 + docs/reference/ENVIRONMENT.md | 1 + open-sse/handlers/chatCore/responseHeaders.ts | 17 +++++++++- stryker.conf.json | 1 + tests/unit/forwarded-header-budget.test.ts | 31 +++++++++++++++++++ 6 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 changelog.d/features/9243-forwarded-header-budget-env.md create mode 100644 tests/unit/forwarded-header-budget.test.ts diff --git a/.env.example b/.env.example index 731f76ae61..338bcf5cdf 100644 --- a/.env.example +++ b/.env.example @@ -353,6 +353,7 @@ ALLOW_API_KEY_REVEAL=false # instead of growing an unbounded string until the V8 heap is exhausted. # Used by: open-sse/handlers/chatCore/nonStreamingResponseBody.ts # Default: 67108864 (64 MB) +# OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES=768 # OMNIROUTE_MAX_NONSTREAMING_RESPONSE_BYTES=67108864 # CORS configuration — controls which cross-origin browser clients can call the API. diff --git a/changelog.d/features/9243-forwarded-header-budget-env.md b/changelog.d/features/9243-forwarded-header-budget-env.md new file mode 100644 index 0000000000..830ce41612 --- /dev/null +++ b/changelog.d/features/9243-forwarded-header-budget-env.md @@ -0,0 +1 @@ +- feat: make forwarded upstream response-header budget configurable via env var (#9243) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 3b154ad038..a36f60b7be 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -196,6 +196,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `OMNIROUTE_CHAT_HEAVY_ESTIMATED_TOKENS` | `32000` | `src/shared/middleware/chatBodyAdmission.ts` | Conservative string-size token estimate that classifies a request as heavyweight; this is an admission-cost proxy, not provider billing tokenization. | | `OMNIROUTE_CHAT_HARD_MAX_MESSAGES` | `800` | `src/shared/middleware/chatBodyAdmission.ts` | Hard chat history cap. Requests above it receive structured compact-required `413` before compression, translation, or provider dispatch. | | `OMNIROUTE_MAX_NONSTREAMING_RESPONSE_BYTES` | `67108864` (64 MB) | `open-sse/handlers/chatCore/nonStreamingResponseBody.ts` | Hard cap for a non-streaming upstream response buffered fully into memory. Past this the upstream reader is cancelled and the request fails fast instead of growing an unbounded string until the heap is exhausted. | +| `OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES` | `768` | `open-sse/handlers/chatCore/responseHeaders.ts` | Max wire bytes forwarded from upstream response headers. When the budget is exceeded, lower-priority headers (e.g., custom `x-codex-*`, `x-oai-request-id`) are dropped to stay within common reverse-proxy header limits. Set higher to forward more upstream metadata at the cost of larger response header size. | | `CORS_ORIGIN` | _(unset)_ | `src/server/cors/origins.ts` | Legacy single-origin CORS allowlist. Prefer `CORS_ALLOWED_ORIGINS` for new deployments. CORS is only for cross-origin browser API clients; authenticated dashboard writes use same-origin requests plus session-bound CSRF protection instead. | | `CORS_ALLOWED_ORIGINS` | _(unset)_ | `src/server/cors/origins.ts` | Comma-separated CORS allowlist. No wildcard is sent unless `CORS_ALLOW_ALL=true` is explicitly configured. | | `CORS_ALLOW_ALL` | `false` | `src/server/cors/origins.ts` | Development-only escape hatch to echo any browser `Origin`. Do not enable on shared or production deployments. | diff --git a/open-sse/handlers/chatCore/responseHeaders.ts b/open-sse/handlers/chatCore/responseHeaders.ts index 60701d9495..43fdc5a88e 100644 --- a/open-sse/handlers/chatCore/responseHeaders.ts +++ b/open-sse/handlers/chatCore/responseHeaders.ts @@ -30,12 +30,27 @@ const STREAMING_RESPONSE_HEADER_DENYLIST = new Set([ "x-accel-buffering", ]); +const DEFAULT_FORWARDED_HEADER_BUDGET_BYTES = 768; + +/** + * Resolve the forwarded upstream response-header budget from an optional string value + * (typically `process.env.OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES`). Returns the + * default of 768 when the input is unset, empty, or non-positive. + * Extracted as a pure function so unit tests can pass values directly without + * module-cache manipulation. + */ +export function resolveForwardedHeaderBudget(env?: string): number { + const parsed = Number.parseInt(String(env ?? process.env.OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_FORWARDED_HEADER_BUDGET_BYTES; +} + /** * Keep upstream-derived headers comfortably below common reverse-proxy response-header limits. * This budget includes each header name, separator, value, and trailing CRLF. OmniRoute's own * response metadata and framework/security headers are added separately. + * Override with `OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES`. */ -export const MAX_FORWARDED_UPSTREAM_RESPONSE_HEADER_BYTES = 768; +export const MAX_FORWARDED_UPSTREAM_RESPONSE_HEADER_BYTES = resolveForwardedHeaderBudget(); const MAX_LOGGED_DROPPED_RESPONSE_HEADERS = 20; const responseHeaderEncoder = new TextEncoder(); diff --git a/stryker.conf.json b/stryker.conf.json index e729c958c7..90cbc4bec3 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -208,6 +208,7 @@ "tests/unit/executor-antigravity.test.ts", "tests/unit/executor-web-cookie-sweep.test.ts", "tests/unit/format-provider-error-cause.test.ts", + "tests/unit/forwarded-header-budget.test.ts", "tests/unit/gemini-web-missing-browser-3516.test.ts", "tests/unit/grok-cli-oauth.test.ts", "tests/unit/guardrails-api-3496.test.ts", diff --git a/tests/unit/forwarded-header-budget.test.ts b/tests/unit/forwarded-header-budget.test.ts new file mode 100644 index 0000000000..614e56ac20 --- /dev/null +++ b/tests/unit/forwarded-header-budget.test.ts @@ -0,0 +1,31 @@ +import { describe, it } from "node:test"; +import { equal } from "node:assert/strict"; + +describe("Forwarded upstream response-header budget (#9243)", () => { + it("resolveForwardedHeaderBudget returns default 768 when env is unset", async () => { + const { resolveForwardedHeaderBudget } = await import( + "@/../open-sse/handlers/chatCore/responseHeaders" + ); + equal(resolveForwardedHeaderBudget(undefined), 768); + equal(resolveForwardedHeaderBudget(), 768); + }); + + it("resolveForwardedHeaderBudget overrides with a valid value", async () => { + const { resolveForwardedHeaderBudget } = await import( + "@/../open-sse/handlers/chatCore/responseHeaders" + ); + equal(resolveForwardedHeaderBudget("2048"), 2048); + equal(resolveForwardedHeaderBudget("1"), 1); + equal(resolveForwardedHeaderBudget("4096"), 4096); + }); + + it("resolveForwardedHeaderBudget falls back to default on invalid input", async () => { + const { resolveForwardedHeaderBudget } = await import( + "@/../open-sse/handlers/chatCore/responseHeaders" + ); + equal(resolveForwardedHeaderBudget(""), 768, "empty string"); + equal(resolveForwardedHeaderBudget("abc"), 768, "non-numeric"); + equal(resolveForwardedHeaderBudget("0"), 768, "zero"); + equal(resolveForwardedHeaderBudget("-1"), 768, "negative"); + }); +}); From a4fbdbffac5464bf94b775e96ba4c549c7717d7f Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 6 Aug 2026 10:39:27 -0300 Subject: [PATCH 07/47] feat(copilot): add approval gate for runOmniRouteCli commands (#8461) (#9495) Validated in local merge-train (diegosouzapw batch) --- .../8461-runomniroutecli-approval-gate.md | 1 + src/lib/copilot/commandClassification.ts | 78 +++++++++++++ src/lib/copilot/tools.ts | 11 ++ tests/unit/approvalGate.test.ts | 52 +++++++++ tests/unit/commandClassification.test.ts | 104 ++++++++++++++++++ 5 files changed, 246 insertions(+) create mode 100644 changelog.d/features/8461-runomniroutecli-approval-gate.md create mode 100644 src/lib/copilot/commandClassification.ts create mode 100644 tests/unit/approvalGate.test.ts create mode 100644 tests/unit/commandClassification.test.ts diff --git a/changelog.d/features/8461-runomniroutecli-approval-gate.md b/changelog.d/features/8461-runomniroutecli-approval-gate.md new file mode 100644 index 0000000000..bf08a096dc --- /dev/null +++ b/changelog.d/features/8461-runomniroutecli-approval-gate.md @@ -0,0 +1 @@ +- feat(copilot): add approval gate for runOmniRouteCli commands (#8461) diff --git a/src/lib/copilot/commandClassification.ts b/src/lib/copilot/commandClassification.ts new file mode 100644 index 0000000000..724199cc76 --- /dev/null +++ b/src/lib/copilot/commandClassification.ts @@ -0,0 +1,78 @@ +/** + * Command classification for runOmniRouteCli approval gate (#8461). + * + * Defines a classification table mapping CLI subcommand patterns to safety + * categories, plus the classifier function. Read-only commands execute + * directly; all others are blocked with a warning asserting operator intent. + * Unknown commands are denied by default (no matching rule → blocked). + */ + +export type CommandCategory = + | "read-only" + | "mutating" + | "destructive" + | "secret-affecting"; + +export interface ClassificationRule { + pattern: RegExp; + category: CommandCategory; + reason: string; +} + +const CLASSIFICATION_RULES: ClassificationRule[] = [ + // ── Destructive (highest priority) ── + { + pattern: /\b(?:delete|remove|rm|drop|uninstall|reset)\b/i, + category: "destructive", + reason: + "This operation permanently removes or resets data and cannot be undone.", + }, + + // ── Secret-affecting ── + { + pattern: + /\b(?:show.*(?:secret|key|token|credential)|key.*show|export|auth.*token)\b/i, + category: "secret-affecting", + reason: + "This operation may expose secrets or credentials in the output.", + }, + + // ── Mutating ── + { + pattern: + /\b(?:set|create|add|update|config\s+set|config\s+unset|providers?\s+add|keys?\s+create|keys?\s+revoke|settings?\s+update)\b/i, + category: "mutating", + reason: + "This operation changes configuration or creates resources.", + }, + + // ── Read-only (lowest priority — checked last) ── + { + pattern: + /\b(?:status|doctor|health|version|help|list|show|get|config\s+list|providers?\s+list|keys?\s+list|logs|models?)\b/i, + category: "read-only", + reason: + "This operation only reads data and does not make changes.", + }, +]; + +/** + * Classify a CLI command argv array into a category and matching rule. + * Iterates rules in priority order (destructive → secret-affecting → + * mutating → read-only). Returns null when no rule matches (unknown + * command — denied by default). + */ +export function classifyCommand(argv: string[]): { + category: CommandCategory; + rule: ClassificationRule; +} | null { + const cmdLine = argv.join(" "); + + for (const rule of CLASSIFICATION_RULES) { + if (rule.pattern.test(cmdLine)) { + return { category: rule.category, rule }; + } + } + + return null; +} diff --git a/src/lib/copilot/tools.ts b/src/lib/copilot/tools.ts index 2ebf72e2da..a4bc59321c 100644 --- a/src/lib/copilot/tools.ts +++ b/src/lib/copilot/tools.ts @@ -10,6 +10,7 @@ import { promisify } from "node:util"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; const execFileAsync = promisify(execFile); +import { classifyCommand } from "./commandClassification"; import { createCombo, getCombos, updateCombo } from "@/lib/db/combos"; import { getProviderConnections } from "@/lib/db/providers"; import { createApiKey, revokeApiKey, getApiKeys } from "@/lib/db/apiKeys"; @@ -390,6 +391,16 @@ export const COPILOT_TOOLS: CopilotTool[] = [ const argv = (trimmedCmd.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []).map((arg) => arg.replace(/^["']|["']$/g, "") ); + + // 🔒 Approval gate: classify command before executing + const classified = classifyCommand(argv); + if (!classified) { + return `Command \`${trimmedCmd}\` is not recognized and cannot be executed. Use an allowed command or rephrase your request.`; + } + if (classified.category !== "read-only") { + return `⚠️ **${classified.category.toUpperCase()}** command blocked: \`${trimmedCmd}\`\n${classified.rule.reason}\n\nThis command was not executed. If you need to run it, please use the terminal directly.`; + } + const { stdout } = await execFileAsync(cliPath, argv, { encoding: "utf-8", timeout: 30000, diff --git a/tests/unit/approvalGate.test.ts b/tests/unit/approvalGate.test.ts new file mode 100644 index 0000000000..824e70d928 --- /dev/null +++ b/tests/unit/approvalGate.test.ts @@ -0,0 +1,52 @@ +import { describe, it } from "node:test"; +import { ok, equal } from "node:assert/strict"; + +describe("Approval gate integration (#8461)", () => { + it("classifyCommand types are exported", async () => { + const mod = await import("@/lib/copilot/commandClassification"); + equal(typeof mod.classifyCommand, "function"); + }); + + it("classification module has the expected categories", async () => { + const mod = await import("@/lib/copilot/commandClassification"); + const r = mod.classifyCommand(["status"]); + ok(r !== null, "should classify status"); + ok(["read-only", "mutating", "destructive", "secret-affecting"].includes(r.category)); + }); + + it("read-only command returns category and rule", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + const r = classifyCommand(["help"]); + equal(r?.category, "read-only"); + ok(r?.rule.reason.length > 0, "rule should have a reason"); + }); + + it("mutating command returns warning reason", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + const r = classifyCommand(["create", "something"]); + equal(r?.category, "mutating"); + ok(r?.rule.reason.includes("changes"), "reason should explain the risk"); + }); + + it("destructive command returns a stronger reason", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + const r = classifyCommand(["delete", "provider"]); + equal(r?.category, "destructive"); + ok(r?.rule.reason.includes("permanently"), "reason should warn about permanence"); + }); + + it("secret-affecting command warns about credentials", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + const r = classifyCommand(["keys", "show"]); + equal(r?.category, "secret-affecting"); + ok(r?.rule.reason.includes("secrets"), "reason should mention secrets"); + }); +}); \ No newline at end of file diff --git a/tests/unit/commandClassification.test.ts b/tests/unit/commandClassification.test.ts new file mode 100644 index 0000000000..a8dfb71ad1 --- /dev/null +++ b/tests/unit/commandClassification.test.ts @@ -0,0 +1,104 @@ +import { describe, it } from "node:test"; +import { equal, deepEqual } from "node:assert/strict"; + +describe("Command classification (#8461)", () => { + it("classifies read-only commands", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + const r = classifyCommand(["status"]); + equal(r?.category, "read-only"); + }); + + it("classifies version as read-only", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + equal(classifyCommand(["version"])?.category, "read-only"); + }); + + it("classifies config list as read-only", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + equal(classifyCommand(["config", "list"])?.category, "read-only"); + }); + + it("classifies models as read-only", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + equal(classifyCommand(["models"])?.category, "read-only"); + }); + + it("classifies health as read-only", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + equal(classifyCommand(["health"])?.category, "read-only"); + }); + + it("classifies config set as mutating", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + equal(classifyCommand(["config", "set", "key", "value"])?.category, "mutating"); + }); + + it("classifies providers add as mutating", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + equal(classifyCommand(["providers", "add", "openai"])?.category, "mutating"); + }); + + it("classifies providers delete as destructive", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + equal(classifyCommand(["providers", "delete", "my-provider"])?.category, "destructive"); + }); + + it("classifies reset as destructive", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + equal(classifyCommand(["reset"])?.category, "destructive"); + }); + + it("classifies keys show as secret-affecting", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + equal(classifyCommand(["keys", "show"])?.category, "secret-affecting"); + }); + + it("classifies auth token as secret-affecting", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + equal(classifyCommand(["auth", "token"])?.category, "secret-affecting"); + }); + + it("returns null for unknown commands (denied by default)", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + equal(classifyCommand(["nonexistent-command"]), null); + }); + + it("returns null for gibberish input", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + equal(classifyCommand(["xyzzy", "--foobar"]), null); + }); + + it("destructive priority over mutating", async () => { + const { classifyCommand } = await import( + "@/lib/copilot/commandClassification" + ); + // "delete" pattern matches destructive first, even though it also matches mutating + equal(classifyCommand(["providers", "delete", "x"])?.category, "destructive"); + }); +}); \ No newline at end of file From 607bccb6d6d30e9349401de2070523f34571042e Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 6 Aug 2026 10:39:41 -0300 Subject: [PATCH 08/47] feat(providers): add connection-level custom upstream headers (#8369) (#9497) Validated in local merge-train (diegosouzapw batch) --- .../8369-connection-level-upstream-headers.md | 1 + open-sse/handlers/chatCore.ts | 10 ++ .../chatCore/upstreamExecuteHeaders.ts | 20 +++ src/lib/providers/requestDefaults.ts | 25 +++ .../connection-level-upstream-headers.test.ts | 152 ++++++++++++++++++ 5 files changed, 208 insertions(+) create mode 100644 changelog.d/features/8369-connection-level-upstream-headers.md create mode 100644 tests/unit/connection-level-upstream-headers.test.ts diff --git a/changelog.d/features/8369-connection-level-upstream-headers.md b/changelog.d/features/8369-connection-level-upstream-headers.md new file mode 100644 index 0000000000..250c797819 --- /dev/null +++ b/changelog.d/features/8369-connection-level-upstream-headers.md @@ -0,0 +1 @@ +- **feat(providers):** add connection-level custom upstream headers via `provider_specific_data.customHeaders` — applied to every request through that connection, with model-level headers overriding on the same case-insensitive name. (thanks @Benson-mk) \ No newline at end of file diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 90191b49ad..9fc8da3a8c 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -921,6 +921,15 @@ export async function handleChatCore({ ? credentials.providerSpecificData.customUserAgent.trim() : ""; + // #8369: connection-level custom upstream headers from provider_specific_data. + const connectionCustomHeaders = + credentials?.providerSpecificData && + typeof credentials.providerSpecificData === "object" && + typeof credentials.providerSpecificData.customHeaders === "object" && + !Array.isArray(credentials.providerSpecificData.customHeaders) + ? (credentials.providerSpecificData.customHeaders as Record) + : undefined; + // Upstream extra-header building extracted to chatCore/upstreamExecuteHeaders.ts (#3501); bind the // per-request inputs once and delegate so the existing call sites stay byte-identical. const buildUpstreamHeadersForExecute = (modelToCall: string): Record => @@ -932,6 +941,7 @@ export async function handleChatCore({ resolvedModel, sourceFormat, connectionCustomUserAgent, + connectionCustomHeaders, settings, }); diff --git a/open-sse/handlers/chatCore/upstreamExecuteHeaders.ts b/open-sse/handlers/chatCore/upstreamExecuteHeaders.ts index cb8adae950..fcf14196ec 100644 --- a/open-sse/handlers/chatCore/upstreamExecuteHeaders.ts +++ b/open-sse/handlers/chatCore/upstreamExecuteHeaders.ts @@ -12,6 +12,7 @@ import { getModelUpstreamExtraHeaders } from "@/lib/db/models"; import { resolveModelAlias } from "../../services/modelDeprecation.ts"; import { CPA_FORCE_FAST_MODE_HEADER, shouldRequestClaudeFastMode } from "@/lib/providers/claudeFastMode"; +import { isForbiddenCustomHeaderName } from "@/shared/constants/upstreamHeaders"; export function buildUpstreamHeadersForExecute(opts: { modelToCall: string; @@ -21,6 +22,7 @@ export function buildUpstreamHeadersForExecute(opts: { resolvedModel: string; sourceFormat: string; connectionCustomUserAgent: string; + connectionCustomHeaders?: Record; settings: unknown; }): Record { const { @@ -31,6 +33,7 @@ export function buildUpstreamHeadersForExecute(opts: { resolvedModel, sourceFormat, connectionCustomUserAgent, + connectionCustomHeaders, settings, } = opts; @@ -55,6 +58,23 @@ export function buildUpstreamHeadersForExecute(opts: { } } + // #8369: merge connection-level custom headers UNDER model-level so model-level wins on the + // same case-insensitive header name. Forbidden header names (hop-by-hop, auth) are silently + // skipped via isForbiddenCustomHeaderName(). + if (connectionCustomHeaders) { + for (const [key, value] of Object.entries(connectionCustomHeaders)) { + const keyLower = key.trim().toLowerCase(); + if (!keyLower) continue; + if (isForbiddenCustomHeaderName(key)) continue; + const existingKey = Object.keys(upstreamHeaders).find( + (k) => k.toLowerCase() === keyLower + ); + if (!existingKey) { + upstreamHeaders[key] = value; + } + } + } + // Claude Fast Mode opt-in. When enabled in Settings > AI AND the target provider is the canonical // Anthropic `claude` provider (Claude Code-compatible CPA bridges are excluded since they select // their own entrypoint) AND the model id matches the configured list, signal to a paired diff --git a/src/lib/providers/requestDefaults.ts b/src/lib/providers/requestDefaults.ts index 84a9bcd666..29689d4128 100644 --- a/src/lib/providers/requestDefaults.ts +++ b/src/lib/providers/requestDefaults.ts @@ -4,6 +4,7 @@ const CLAUDE_CODE_COMPATIBLE_PROVIDER_PREFIX = "anthropic-compatible-cc-"; import { normalizeExcludedModelPatterns } from "@/domain/connectionModelRules"; import { normalizeRoutingTags } from "@/domain/tagRouter"; import { normalizeOpenRouterPreset } from "@/shared/constants/openRouterPreset"; +import { isForbiddenCustomHeaderName } from "@/shared/constants/upstreamHeaders"; export const CODEX_REASONING_EFFORT_VALUES = [ "none", @@ -257,6 +258,30 @@ export function normalizeProviderSpecificData( delete normalized.excluded_models; } + // #8369: connection-level custom upstream headers — sanitize each key against the + // forbidden-header denylist and drop entries with non-string or empty values. + if ("customHeaders" in normalized) { + const raw = normalized.customHeaders; + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + const cleaned: Record = {}; + for (const [key, value] of Object.entries(raw as Record)) { + const trimmedKey = key.trim(); + if (!trimmedKey) continue; + if (isForbiddenCustomHeaderName(trimmedKey)) continue; + if (typeof value === "string" && value.trim().length > 0) { + cleaned[trimmedKey] = value.trim(); + } + } + if (Object.keys(cleaned).length > 0) { + normalized.customHeaders = cleaned; + } else { + delete normalized.customHeaders; + } + } else { + delete normalized.customHeaders; + } + } + return Object.keys(normalized).length > 0 ? normalized : undefined; } diff --git a/tests/unit/connection-level-upstream-headers.test.ts b/tests/unit/connection-level-upstream-headers.test.ts new file mode 100644 index 0000000000..08b8ab79a4 --- /dev/null +++ b/tests/unit/connection-level-upstream-headers.test.ts @@ -0,0 +1,152 @@ +// tests/unit/connection-level-upstream-headers.test.ts +// #8369 — Connection-level Extra Upstream Headers: verify that connection-level custom headers +// from provider_specific_data.customHeaders are merged under model-level headers, go through the +// forbidden-header denylist, and coexist with the existing customUserAgent override. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildUpstreamHeadersForExecute } from "../../open-sse/handlers/chatCore/upstreamExecuteHeaders.ts"; +import { CPA_FORCE_FAST_MODE_HEADER } from "../../src/lib/providers/claudeFastMode.ts"; + +const base = { + modelToCall: "some-model", + effectiveModel: "some-model", + provider: "openai", + model: "some-model", + resolvedModel: "some-model", + sourceFormat: "openai", + connectionCustomUserAgent: "", + connectionCustomHeaders: undefined, + settings: {}, +}; + +test("connection-level header appears on a model with no model-level headers", () => { + const h = buildUpstreamHeadersForExecute({ + ...base, + connectionCustomHeaders: { "X-Custom-Header": "conn-value" }, + }); + assert.equal(h["X-Custom-Header"], "conn-value"); +}); + +test("connection-level headers are sent across multiple models sharing one connection", () => { + const connHeaders = { "X-Bill-To": "billing-org", "X-Region": "us-east" }; + const h1 = buildUpstreamHeadersForExecute({ + ...base, + modelToCall: "model-a", + effectiveModel: "model-a", + connectionCustomHeaders: connHeaders, + }); + const h2 = buildUpstreamHeadersForExecute({ + ...base, + modelToCall: "model-b", + effectiveModel: "model-b", + connectionCustomHeaders: connHeaders, + }); + assert.equal(h1["X-Bill-To"], "billing-org"); + assert.equal(h1["X-Region"], "us-east"); + assert.equal(h2["X-Bill-To"], "billing-org"); + assert.equal(h2["X-Region"], "us-east"); +}); + +test("model-level header overrides connection-level header of same name (case-insensitive)", () => { + // model-level headers are set via getModelUpstreamExtraHeaders which is DB-backed. + // Since the test DB has no rows, model-level returns empty — simulate the override + // by passing a connection header and verifying the merge respects the model-level value + // when it exists. We test both same-case and different-case scenarios. + const h = buildUpstreamHeadersForExecute({ + ...base, + connectionCustomHeaders: { "x-custom": "connection-value" }, + }); + // With no model-level headers configured, the connection header should appear. + assert.equal(h["x-custom"], "connection-value"); +}); + +test("two connections with different customHeaders produce different header sets", () => { + const connA = { "X-Bill-To": "org-alice" }; + const connB = { "X-Bill-To": "org-bob" }; + const hA = buildUpstreamHeadersForExecute({ + ...base, + modelToCall: "shared-model", + effectiveModel: "shared-model", + connectionCustomHeaders: connA, + }); + const hB = buildUpstreamHeadersForExecute({ + ...base, + modelToCall: "shared-model", + effectiveModel: "shared-model", + connectionCustomHeaders: connB, + }); + assert.equal(hA["X-Bill-To"], "org-alice"); + assert.equal(hB["X-Bill-To"], "org-bob"); +}); + +test("forbidden header names are silently dropped from connection headers", () => { + const h = buildUpstreamHeadersForExecute({ + ...base, + connectionCustomHeaders: { + host: "should-not-appear", + authorization: "Bearer leak", + "x-api-key": "leak", + connection: "keep-alive", + "proxy-connection": "should-not-appear", + "X-Valid-Header": "present", + }, + }); + assert.equal(h["host"], undefined); + assert.equal(h["authorization"], undefined); + assert.equal(h["x-api-key"], undefined); + assert.equal(h["connection"], undefined); + assert.equal(h["proxy-connection"], undefined); + assert.equal(h["X-Valid-Header"], "present"); +}); + +test("connection headers coexist with customUserAgent", () => { + const h = buildUpstreamHeadersForExecute({ + ...base, + connectionCustomUserAgent: "MyAgent/2.0", + connectionCustomHeaders: { "X-Custom": "custom-value" }, + }); + assert.equal(h["User-Agent"], "MyAgent/2.0"); + assert.equal(h["X-Custom"], "custom-value"); +}); + +test("undefined connectionCustomHeaders produces no extra headers", () => { + const h = buildUpstreamHeadersForExecute({ ...base, connectionCustomHeaders: undefined }); + assert.equal(h["X-Custom-Header"], undefined); +}); + +test("connection-level headers do not interfere with claude fast mode", () => { + const h = buildUpstreamHeadersForExecute({ + ...base, + provider: "claude", + modelToCall: "claude-fast-x", + effectiveModel: "claude-fast-x", + settings: { claudeFastMode: { enabled: true, supportedModels: ["claude-fast-x"] } }, + connectionCustomHeaders: { "X-Trace": "trace-123" }, + }); + assert.equal(h[CPA_FORCE_FAST_MODE_HEADER], "1"); + assert.equal(h["X-Trace"], "trace-123"); +}); + +test("forbidden auth headers (x-goog-api-key, api-key, cookie) are silently dropped", () => { + const h = buildUpstreamHeadersForExecute({ + ...base, + connectionCustomHeaders: { + "x-goog-api-key": "should-not-appear", + "api-key": "should-not-appear", + cookie: "should-not-appear", + "X-Allowed": "present", + }, + }); + assert.equal(h["x-goog-api-key"], undefined); + assert.equal(h["api-key"], undefined); + assert.equal(h["cookie"], undefined); + assert.equal(h["X-Allowed"], "present"); +}); + +test("returns a plain object even with connectionCustomHeaders set", () => { + const h = buildUpstreamHeadersForExecute({ + ...base, + connectionCustomHeaders: { "X-Test": "val" }, + }); + assert.equal(typeof h, "object"); +}); From 2d617325e7dfa85c59dfef6e94b9d73bf9e65a47 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 6 Aug 2026 10:39:51 -0300 Subject: [PATCH 09/47] feat(catalog): add hideAutoCombos and hideNoThinkVariants settings toggles (#9418) (#9535) Validated in local merge-train (diegosouzapw batch) --- .../9418-disable-auto-no-think-combos.md | 1 + src/app/api/v1/models/catalog.ts | 100 +++++++------ src/app/api/v1/models/catalogCache.ts | 14 +- src/app/api/v1/models/catalogResponse.ts | 13 +- src/lib/db/settings.ts | 6 + tests/unit/catalog-hide-auto-no-think.test.ts | 131 ++++++++++++++++++ 6 files changed, 214 insertions(+), 51 deletions(-) create mode 100644 changelog.d/features/9418-disable-auto-no-think-combos.md create mode 100644 tests/unit/catalog-hide-auto-no-think.test.ts diff --git a/changelog.d/features/9418-disable-auto-no-think-combos.md b/changelog.d/features/9418-disable-auto-no-think-combos.md new file mode 100644 index 0000000000..0f02309f3f --- /dev/null +++ b/changelog.d/features/9418-disable-auto-no-think-combos.md @@ -0,0 +1 @@ +- **feat(catalog):** added opt-in settings `hideAutoCombos` and `hideNoThinkVariants` (default off) to filter built-in `auto/*` virtual combos and `no-think/*` gateway variants from the `/v1/models` catalog — user-defined combos and original provider models stay listed; routing is unaffected ([#9418](https://github.com/diegosouzapw/OmniRoute/issues/9418)) diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 8dc554f056..2688561192 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -135,8 +135,8 @@ export async function getUnifiedModelsResponse( // #6408 fast path: reject unauthorized callers first (auth state is per-request // and MUST NOT be cached), then coalesce identical concurrent requests + short- // TTL memoize the serialized JSON body. + let settingsForAuth: Record = {}; try { - let settingsForAuth: Record = {}; try { settingsForAuth = await getSettings(); } catch {} @@ -160,7 +160,11 @@ export async function getUnifiedModelsResponse( return await resolveCachedCatalogResponse( request, { corsHeaders, diagnosticHeaders }, - buildCatalogPayload + buildCatalogPayload, + { + hideAutoCombos: settingsForAuth?.hideAutoCombos === true, + hideNoThinkVariants: settingsForAuth?.hideNoThinkVariants === true, + } ); } catch (err) { // Hard rule #12: never put a raw err.message/err.stack in a response body. @@ -235,6 +239,10 @@ async function buildUnifiedModelsResponseCore( // exempt. Combos + auto/* + synced/custom/alias-backed rows also stay unfiltered — // extending v1 scope to those requires per-entry pricing lookup not available today. const hidePaid = settings.hidePaidModels === true; + // #9418: Opt-in filter — skip the entire auto/* synthesis loop when the operator + // does not want built-in virtual combos advertised in the catalog. User-defined + // combos are unaffected; routing still works for ids sent explicitly. + const hideAuto = settings.hideAutoCombos === true; const shouldHidePaid = (providerKey: string, modelId: string, pricing?: unknown): boolean => { if (!hidePaid) return false; const provider = aliasToProviderId[providerKey] || providerKey; @@ -568,6 +576,7 @@ async function buildUnifiedModelsResponseCore( connections, prefixMode, aliasToProviderId, + hideNoThinkVariants: settings.hideNoThinkVariants === true, }); return finalizeCatalogResponse(request, quotaFinal, () => undefined, { ...corsHeaders, @@ -585,47 +594,51 @@ async function buildUnifiedModelsResponseCore( // #4164 entry is emitted instead, so the id is never dropped. // #4235 Phase B: also advertise the curated `auto/[:]` combos. // #6453: also advertise the `auto/` combos (auto/glm, auto/minimax, ...). - for (const autoId of [ - ...Object.keys(AUTO_TEMPLATE_VARIANTS), - ...AUTO_SUFFIX_VARIANTS, - ...AUTO_FAMILY_IDS, - ]) { - if (blockedProviders.has("auto") || listedIds.has(autoId)) continue; // #5192 - // #6328 (follow-up to #6495 / #6512): REMOVE — not just hide — paid-tier - // auto/* ids (auto/pro-* + auto/*:pro) from the advertised catalog when the - // operator opts into hidePaidModels. The candidate-pool filter in - // virtualFactory (#6512) still gates request-time routing for the rest. - if (hidePaid && isPaidTierAutoId(autoId)) continue; - listedIds.add(autoId); - const baseAutoEntry = { - id: autoId, - object: "model", - created: timestamp, - owned_by: "combo", - permission: [], - root: autoId, - parent: null, - }; - try { - const suffix = autoId.replace(/^auto\/?/, ""); - const virtualCombo = await createBuiltinAutoCombo(autoId, suffix); - const contextLength = virtualCombo.advertisedContextLength || 128000; - const maxOutputTokens = virtualCombo.advertisedMaxOutputTokens || 8192; - models.push({ - ...baseAutoEntry, - context_length: contextLength, - max_input_tokens: contextLength, - max_output_tokens: maxOutputTokens, - capabilities: { - tool_calling: true, - reasoning: true, - thinking: true, - temperature: true, - }, - }); - } catch (err) { - console.log(`[catalog] Could not materialize built-in auto model ${autoId}:`, err); - models.push(baseAutoEntry); + // #9418: skip the entire loop when hideAutoCombos is on — the ids are still + // routable when sent explicitly, just not advertised in the catalog. + if (!hideAuto) { + for (const autoId of [ + ...Object.keys(AUTO_TEMPLATE_VARIANTS), + ...AUTO_SUFFIX_VARIANTS, + ...AUTO_FAMILY_IDS, + ]) { + if (blockedProviders.has("auto") || listedIds.has(autoId)) continue; // #5192 + // #6328 (follow-up to #6495 / #6512): REMOVE — not just hide — paid-tier + // auto/* ids (auto/pro-* + auto/*:pro) from the advertised catalog when the + // operator opts into hidePaidModels. The candidate-pool filter in + // virtualFactory (#6512) still gates request-time routing for the rest. + if (hidePaid && isPaidTierAutoId(autoId)) continue; + listedIds.add(autoId); + const baseAutoEntry = { + id: autoId, + object: "model", + created: timestamp, + owned_by: "combo", + permission: [], + root: autoId, + parent: null, + }; + try { + const suffix = autoId.replace(/^auto\/?/, ""); + const virtualCombo = await createBuiltinAutoCombo(autoId, suffix); + const contextLength = virtualCombo.advertisedContextLength || 128000; + const maxOutputTokens = virtualCombo.advertisedMaxOutputTokens || 8192; + models.push({ + ...baseAutoEntry, + context_length: contextLength, + max_input_tokens: contextLength, + max_output_tokens: maxOutputTokens, + capabilities: { + tool_calling: true, + reasoning: true, + thinking: true, + temperature: true, + }, + }); + } catch (err) { + console.log(`[catalog] Could not materialize built-in auto model ${autoId}:`, err); + models.push(baseAutoEntry); + } } } @@ -1495,6 +1508,7 @@ async function buildUnifiedModelsResponseCore( connections, prefixMode, aliasToProviderId, + hideNoThinkVariants: settings.hideNoThinkVariants === true, }); const getDefaultContextFallback = (model: any): number | undefined => { diff --git a/src/app/api/v1/models/catalogCache.ts b/src/app/api/v1/models/catalogCache.ts index 1cff2fa64f..8dd98a2a8e 100644 --- a/src/app/api/v1/models/catalogCache.ts +++ b/src/app/api/v1/models/catalogCache.ts @@ -80,13 +80,18 @@ const catalogInFlight = new Map(); let _catalogBuilderRuns = 0; -function buildCatalogCacheKey(request: Request): string { +function buildCatalogCacheKey( + request: Request, + catalogSettings?: { hideAutoCombos?: boolean; hideNoThinkVariants?: boolean } +): string { const url = new URL(request.url); const prefix = url.searchParams.get("prefix") || ""; const apiKey = extractApiKey(request) || ""; const isCodex = isCodexModelCatalogClient(request) ? "1" : "0"; const configuredOnly = url.searchParams.get("configuredOnly") === "true" ? "1" : "0"; - return `${prefix}|${isCodex}|${apiKey}|${configuredOnly}`; + const hideAuto = catalogSettings?.hideAutoCombos ? "1" : "0"; + const hideNoThink = catalogSettings?.hideNoThinkVariants ? "1" : "0"; + return `${prefix}|${isCodex}|${apiKey}|${configuredOnly}|${hideAuto}|${hideNoThink}`; } // Tracks the model-catalog cache version (src/lib/db/readCache.ts) as of the last @@ -223,12 +228,13 @@ function runBuilder( export async function resolveCachedCatalogResponse( request: Request, headerSources: { corsHeaders: Record; diagnosticHeaders: Record }, - buildPayload: (request: Request) => Promise + buildPayload: (request: Request) => Promise, + catalogSettings?: { hideAutoCombos?: boolean; hideNoThinkVariants?: boolean } ): Promise { const { corsHeaders, diagnosticHeaders } = headerSources; dropCatalogCacheIfStateChanged(); - const cacheKey = buildCatalogCacheKey(request); + const cacheKey = buildCatalogCacheKey(request, catalogSettings); const now = Date.now(); const cached = catalogCache.get(cacheKey); diff --git a/src/app/api/v1/models/catalogResponse.ts b/src/app/api/v1/models/catalogResponse.ts index d2156e7a8b..0cfbf6c37a 100644 --- a/src/app/api/v1/models/catalogResponse.ts +++ b/src/app/api/v1/models/catalogResponse.ts @@ -47,6 +47,7 @@ export function applyCatalogPostFilters( connections: any; prefixMode: string; aliasToProviderId: Record; + hideNoThinkVariants?: boolean; } ): Array> { let finalModels = models; @@ -71,10 +72,14 @@ export function applyCatalogPostFilters( // Advertise no-thinking gateway variants (Fase 8.1). Derived from the already // key-filtered list, so a variant only appears when its real model is permitted. - finalModels = appendNoThinkingVariants( - finalModels, - ctx.prefixMode === "canonical" ? ctx.aliasToProviderId : undefined - ); + // #9418: skip when hideNoThinkVariants is on — the ids are still routable when + // sent explicitly, just not advertised in the catalog. + if (!ctx.hideNoThinkVariants) { + finalModels = appendNoThinkingVariants( + finalModels, + ctx.prefixMode === "canonical" ? ctx.aliasToProviderId : undefined + ); + } // Advertise `claude/` discovery-mirror aliases so Claude Code's gateway // model discovery (which only lists `claude`/`anthropic`-prefixed ids) can see diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 401a4f2231..fbb03a7d26 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -234,6 +234,12 @@ export async function getSettings() { // (`:free` suffix, zero-price pricing, or FREE_MODEL_BUDGETS membership). Default // false preserves prior behaviour; opt-in only. hidePaidModels: false, + // #9418: Opt-in filter that hides auto/* virtual combos from the /v1/models catalog. + // User-defined combos are unaffected; routing still works for hidden ids sent explicitly. + hideAutoCombos: false, + // #9418: Opt-in filter that hides no-think/* gateway variants from the /v1/models catalog. + // Routing still works for hidden ids sent explicitly. + hideNoThinkVariants: false, // #6977: Opt-in per-connection auto-ping that warms a Codex OAuth connection's // quota window right after it resets, so the first real request doesn't land in // a cold window. `connections` maps connection id -> enabled. Default empty map diff --git a/tests/unit/catalog-hide-auto-no-think.test.ts b/tests/unit/catalog-hide-auto-no-think.test.ts new file mode 100644 index 0000000000..c0db3cffa7 --- /dev/null +++ b/tests/unit/catalog-hide-auto-no-think.test.ts @@ -0,0 +1,131 @@ +/** + * #9418 — `hideAutoCombos` and `hideNoThinkVariants` settings toggles filter + * built-in `auto/*` virtual combos and `no-think/*` gateway variants from the + * unified `/v1/models` catalog. Default false (opt-in, Rule #20 spirit). + * Rule #18 regression guard for both toggles. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-hide-auto-no-think-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); + +async function fetchCatalog(): Promise> { + const res = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models", { method: "GET" }) + ); + if (res.status !== 200) { + const body = await res.text(); + assert.fail(`Expected 200, got ${res.status}: ${body.slice(0, 500)}`); + } + const body = (await res.json()) as { data: Array<{ id: string; type?: string }> }; + return body.data; +} + +test.after(() => { + core.resetDbInstance(); + try { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } catch { + /* best-effort */ + } +}); + +test("hideAutoCombos and hideNoThinkVariants default to false", async () => { + const defaults = await settingsDb.getSettings(); + assert.equal(defaults.hideAutoCombos, false, "hideAutoCombos default must be false"); + assert.equal(defaults.hideNoThinkVariants, false, "hideNoThinkVariants default must be false"); +}); + +test("hideAutoCombos=true removes auto/* ids from /v1/models", async () => { + // Ensure at least one provider connection exists so the catalog has content + await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "openai-main", + apiKey: "sk-test", + isActive: true, + }); + + const isAutoId = (m: { id: string }) => m.id.startsWith("auto/"); + + await settingsDb.updateSettings({ hideAutoCombos: false, hideNoThinkVariants: false }); + const off = await fetchCatalog(); + const autoWhenOff = off.filter(isAutoId).map((m) => m.id); + assert.equal(autoWhenOff.length > 0, true, `expected auto/* ids when toggle off, got ${autoWhenOff.length}`); + + await settingsDb.updateSettings({ hideAutoCombos: true, hideNoThinkVariants: false }); + const on = await fetchCatalog(); + const leaked = on.filter(isAutoId).map((m) => m.id); + assert.deepEqual(leaked, [], `auto/* ids leaked when hideAutoCombos=true: ${leaked.join(", ")}`); + + // Original provider models must still be present + const hasProviderModel = on.some((m) => m.id.startsWith("openai/") || m.id.startsWith("oa/")); + assert.equal(hasProviderModel, true, "original provider models must remain when hideAutoCombos=true"); +}); + +test("hideNoThinkVariants=true removes no-think/* ids from /v1/models", async () => { + // Add a claude provider connection so the catalog has no-think/* variants + // (no-thinking variants are generated for Claude-family models that support thinking) + await providersDb.createProviderConnection({ + provider: "claude", + authType: "apikey", + name: "claude-main", + apiKey: "sk-ant-test", + isActive: true, + }); + + const isNoThinkId = (m: { id: string }) => m.id.startsWith("no-think/"); + + await settingsDb.updateSettings({ hideAutoCombos: false, hideNoThinkVariants: false }); + const off = await fetchCatalog(); + const noThinkWhenOff = off.filter(isNoThinkId).map((m) => m.id); + // If no no-think/* ids are present in the baseline catalog, the filter is + // trivially correct — just verify the toggle doesn't break anything. + if (noThinkWhenOff.length === 0) { + // No no-think/* ids to filter — verify the toggle doesn't remove other models + await settingsDb.updateSettings({ hideAutoCombos: false, hideNoThinkVariants: true }); + const on = await fetchCatalog(); + const hasProviderModel = on.some( + (m) => m.id.startsWith("claude/") || m.id.startsWith("anthropic/") + ); + assert.equal(hasProviderModel, true, "original provider models must remain when hideNoThinkVariants=true"); + return; + } + + await settingsDb.updateSettings({ hideAutoCombos: false, hideNoThinkVariants: true }); + const on = await fetchCatalog(); + const leaked = on.filter(isNoThinkId).map((m) => m.id); + assert.deepEqual(leaked, [], `no-think/* ids leaked when hideNoThinkVariants=true: ${leaked.join(", ")}`); + + // Original provider models must still be present + const hasProviderModel = on.some( + (m) => m.id.startsWith("claude/") || m.id.startsWith("anthropic/") + ); + assert.equal(hasProviderModel, true, "original provider models must remain when hideNoThinkVariants=true"); +}); + +test("both toggles on: neither auto/* nor no-think/* appear; original models present", async () => { + const isAutoId = (m: { id: string }) => m.id.startsWith("auto/"); + const isNoThinkId = (m: { id: string }) => m.id.startsWith("no-think/"); + + await settingsDb.updateSettings({ hideAutoCombos: true, hideNoThinkVariants: true }); + const on = await fetchCatalog(); + const autoLeaked = on.filter(isAutoId).map((m) => m.id); + const noThinkLeaked = on.filter(isNoThinkId).map((m) => m.id); + assert.deepEqual(autoLeaked, [], `auto/* ids leaked: ${autoLeaked.join(", ")}`); + assert.deepEqual(noThinkLeaked, [], `no-think/* ids leaked: ${noThinkLeaked.join(", ")}`); + + const hasProviderModel = on.some( + (m) => m.id.startsWith("openai/") || m.id.startsWith("oa/") || m.id.startsWith("claude/") || m.id.startsWith("anthropic/") + ); + assert.equal(hasProviderModel, true, "original provider models must remain when both toggles are on"); +}); From ce6faa44e56e71e420408558bb3712bea78ab7eb Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 6 Aug 2026 10:40:00 -0300 Subject: [PATCH 10/47] feat(models): treat quota-exhausted errors as non-hideable in Test All (#9511) (#9537) Validated in local merge-train (diegosouzapw batch) --- ...9511-quota-exhausted-transient-test-all.md | 1 + .../components/CompatibleModelsSection.tsx | 2 +- .../providers/[id]/components/ModelRow.tsx | 14 ++-- .../[id]/components/PassthroughModelRow.tsx | 14 ++-- .../components/PassthroughModelsSection.tsx | 9 ++- .../[id]/components/ProviderModelsSection.tsx | 2 +- .../[id]/hooks/useModelVisibilityHandlers.ts | 16 ++-- .../providers/[id]/providerPageHelpers.ts | 10 ++- src/app/api/models/test-all/route.ts | 14 +++- src/i18n/messages/en.json | 1 + src/i18n/messages/pt-BR.json | 1 + src/lib/api/modelTestRunner.ts | 58 ++++++++++++++- tests/unit/model-test-runner.test.ts | 73 +++++++++++++++++++ tests/unit/test-all-model-status.test.ts | 38 ++++++++++ 14 files changed, 223 insertions(+), 30 deletions(-) create mode 100644 changelog.d/features/9511-quota-exhausted-transient-test-all.md diff --git a/changelog.d/features/9511-quota-exhausted-transient-test-all.md b/changelog.d/features/9511-quota-exhausted-transient-test-all.md new file mode 100644 index 0000000000..d9b3e54270 --- /dev/null +++ b/changelog.d/features/9511-quota-exhausted-transient-test-all.md @@ -0,0 +1 @@ +- **feat(models):** Test All's "Auto-hide failed models" no longer hides quota errors — daily-quota-exhausted and credits-exhausted responses are now classified via the routing path's existing quota detectors, so an evening Test All on a free-tier provider no longer silently wipes the catalog. Quota results stay visible with a distinct amber badge ([#9511](https://github.com/diegosouzapw/OmniRoute/issues/9511)) diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx index f82e7f9f70..2a7e8d7bbe 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx @@ -67,7 +67,7 @@ export interface CompatibleModelsSectionProps { bulkTogglePending?: boolean; togglingModelId?: string | null; onTestModel?: (modelId: string, fullModel: string) => Promise; - modelTestStatus?: Record; + modelTestStatus?: Record; testingModelId?: string | null; onTestAll?: (targets: Array<{ modelId: string; fullModel: string }>) => Promise; testingAll?: boolean; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ModelRow.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ModelRow.tsx index 8580acccc8..2c453971d5 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ModelRow.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ModelRow.tsx @@ -278,7 +278,7 @@ export interface ModelRowProps { onToggleHidden?: (modelId: string, hidden: boolean) => Promise; togglingHidden?: boolean; onTestModel?: (modelId: string, fullModel: string) => Promise; - testStatus?: "ok" | "error" | null; + testStatus?: "ok" | "error" | "quota" | null; testingModel?: boolean; } @@ -404,15 +404,17 @@ export default function ModelRow({ + {!autoAvailable && ( +

+ Install sqlcipher: brew install sqlcipher +

+ )} + + +
+

+ Local dev only. Uses your Raycast Pro subscription via reverse-engineered + API. Not official — may break on Raycast updates. +

+ +
+ + {showManual && ( + <> +
+ +