Compare commits

..

2 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza
6afa6846c8 docs(changelog): link catalog responsiveness PR 2026-08-24 05:24:26 -03:00
Diego Rodrigues de Sa e Souza
acb15fefba fix(catalog): keep large builds event-loop responsive 2026-08-24 04:41:52 -03:00
13 changed files with 63 additions and 170 deletions

View File

@@ -180,7 +180,6 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e
### 🐛 Bug Fixes
- **fix(build):** every route no longer answers HTTP 500 on artifacts built from the release tip ([#11343](https://github.com/diegosouzapw/OmniRoute/issues/11343)) — `next.config.mjs` aliased `better-sqlite3` to its build-time stub **unconditionally**, on the premise that `serverExternalPackages` still won at runtime. It does not: a Turbopack `resolveAlias` rewrites the request *before* the externals check, so the request stopped matching the `better-sqlite3` external entry and the stub was baked into the shipped bundle. The sync driver then failed with `r(...) is not a constructor`, fell through `node:sqlite` and sql.js, and the instrumentation hook aborted at boot. Same failure shape as [#6344](https://github.com/diegosouzapw/OmniRoute/issues/6344), so it gets the same treatment: the alias is opt-in via `OMNIROUTE_BETTER_SQLITE3_STUB=1` through the shared `scripts/build/better-sqlite3-stub-flag.mjs` helper — set it only on a build host that actually hits the SIGABRT build-worker teardown ([#10060](https://github.com/diegosouzapw/OmniRoute/issues/10060)); default builds externalize the real native addon. Regression guards: `tests/unit/better-sqlite3-stub-alias-11343.test.mjs` (5) and the env matrix in `tests/unit/next-config.test.ts`.
- **security(search)**: block SSRF via `/v1/search` `provider_options.baseUrl` for the Firecrawl search provider — the client-controlled override is now validated as a public URL before it is used to build the server-side fetch target, so a caller with a valid API key can no longer redirect search requests at loopback, RFC1918, or cloud-metadata hosts — thanks @zmf963
- **providers**: honor `PATCH /api/providers/[id]` so `omniroute providers rotate` stops 405ing (the OpenAPI spec and CLI already use PATCH) (PR #10366)
- **cli**: route provider test commands through configured connection test endpoints (#10570)

View File

@@ -0,0 +1 @@
- **fix(catalog):** keep large `/v1/models` builds responsive by reusing the build-local capability snapshot throughout enrichment and Auto-Combo preparation, yielding cooperatively while constructing virtual candidate pools, and avoiding unrelated synchronous database diagnostics on the cache-TTL read path ([#11367](https://github.com/diegosouzapw/OmniRoute/pull/11367))

View File

@@ -2,7 +2,6 @@ import createNextIntlPlugin from "next-intl/plugin";
import { createMDX } from "fumadocs-mdx/next";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { betterSqlite3AliasFor } from "./scripts/build/better-sqlite3-stub-flag.mjs";
import { mitmManagerAliasFor } from "./scripts/build/mitm-stub-flag.mjs";
import { normalizeBasePath } from "./scripts/build/normalizeBasePath.mjs";
import {
@@ -139,14 +138,10 @@ const nextConfig = {
// the stub to every npm/Electron/VPS artifact and broke Agent Bridge
// start for all non-Docker users (#6344). See scripts/build/mitm-stub-flag.mjs.
...mitmManagerAliasFor(process.env),
// better-sqlite3 → build-time stub ONLY where the build worker actually
// aborts while tracing the native addon (SIGABRT at worker teardown,
// #10060); opt in with OMNIROUTE_BETTER_SQLITE3_STUB=1. The alias used to
// be unconditional on the premise that serverExternalPackages still won
// at runtime — it does not: resolveAlias rewrites the request before the
// externals check, so the stub was bundled and EVERY route answered 500
// (#11343). See scripts/build/better-sqlite3-stub-flag.mjs.
...betterSqlite3AliasFor(process.env),
// Build-time stub so the bundler never traces the native better-sqlite3
// addon into a build worker (SIGABRT at worker teardown). Runtime still
// uses the real package via serverExternalPackages. (#10060)
"better-sqlite3": "./src/lib/db/better-sqlite3.stub.js",
...minimalBuildAliases,
},
// src/lib/agentSkills/generator.ts builds its fs base path from a runtime

View File

@@ -1,3 +1,5 @@
import type { ModelCapabilityResolutionSnapshot } from "@/lib/modelCapabilities";
import type { AutoVariant } from "./autoPrefix";
import { VALID_VARIANTS } from "./autoPrefix";
import type { PreparedVirtualAutoComboInputs } from "./virtualFactory";
@@ -119,8 +121,7 @@ export function isPaidTierAutoId(autoId: string): boolean {
* a candidate filter so the virtual combo only scores vision-capable models.
*/
export type BuiltinAutoSpec =
| { variant: AutoVariant | undefined }
| { category: AutoCategory; tier?: AutoTier };
{ variant: AutoVariant | undefined } | { category: AutoCategory; tier?: AutoTier };
/**
* Vision-flavored flat ids that MUST resolve to the `vision` category (candidate
@@ -159,9 +160,14 @@ export function resolveBuiltinAutoSpec(modelStr: string, suffix: string): Builti
return { variant: undefined };
}
export async function prepareBuiltinAutoComboInputs(): Promise<PreparedVirtualAutoComboInputs> {
export async function prepareBuiltinAutoComboInputs(
resolutionSnapshot?: ModelCapabilityResolutionSnapshot
): Promise<PreparedVirtualAutoComboInputs> {
const { prepareVirtualAutoComboInputs } = await import("./virtualFactory.ts");
return prepareVirtualAutoComboInputs({ includeResolvedCapabilities: true });
return prepareVirtualAutoComboInputs({
includeResolvedCapabilities: true,
resolutionSnapshot,
});
}
export async function createBuiltinAutoCombo(

View File

@@ -404,7 +404,9 @@ export function computeAdvertisedLimits(candidates: AdvertisedLimitCandidate[]):
return { contextLength, maxOutputTokens };
}
const PREPARED_CAPABILITY_YIELD_INTERVAL = 16;
// Catalog-scale pools can contain hundreds of models. Keep both candidate construction
// and capability preparation cooperative instead of monopolising one event-loop turn.
const VIRTUAL_AUTO_PREPARATION_YIELD_INTERVAL = 4;
type PreparedCapabilityValues = {
resolvedContextLength: number | null;
@@ -468,7 +470,7 @@ async function attachPreparedCapabilityValues(
};
byModel.set(candidate.model, values);
state.resolvedSinceYield++;
if (state.resolvedSinceYield >= PREPARED_CAPABILITY_YIELD_INTERVAL) {
if (state.resolvedSinceYield >= VIRTUAL_AUTO_PREPARATION_YIELD_INTERVAL) {
state.resolvedSinceYield = 0;
await yieldVirtualAutoPreparationTurn();
}
@@ -479,7 +481,10 @@ async function attachPreparedCapabilityValues(
}
export async function prepareVirtualAutoComboInputs(
options: { includeResolvedCapabilities?: boolean } = {}
options: {
includeResolvedCapabilities?: boolean;
resolutionSnapshot?: ModelCapabilityResolutionSnapshot;
} = {}
): Promise<PreparedVirtualAutoComboInputs> {
const [connections, disabledNoAuthConnections, settings] = await Promise.all([
getCachedProviderConnections({ isActive: true }) as Promise<VirtualFactoryConn[]>,
@@ -524,6 +529,7 @@ export async function prepareVirtualAutoComboInputs(
// Build one logical candidate per provider/model and keep account fallback as an
// allowlist on that candidate. This avoids both the old "first registry model per
// connection" blind spot and a connections × models Cartesian candidate pool.
let candidateModelsSinceYield = 0;
for (const [providerId, providerConnections] of connectionsByProvider) {
const providerInfo = registry[providerId];
const registryModelIds = Array.isArray(providerInfo?.models)
@@ -557,6 +563,11 @@ export async function prepareVirtualAutoComboInputs(
: Array.from(new Set([...registryModelIds, ...defaultModelIds]));
for (const modelId of modelIds) {
candidateModelsSinceYield++;
if (candidateModelsSinceYield >= VIRTUAL_AUTO_PREPARATION_YIELD_INTERVAL) {
candidateModelsSinceYield = 0;
await yieldVirtualAutoPreparationTurn();
}
if (hiddenModels?.has(modelId)) continue;
const allowedConnectionIds = providerConnections
@@ -655,7 +666,7 @@ export async function prepareVirtualAutoComboInputs(
const capabilityState: PreparedCapabilityState = {
byTarget: new Map(),
resolvedSinceYield: 0,
resolutionSnapshot: createModelCapabilityResolutionSnapshot(),
resolutionSnapshot: options.resolutionSnapshot ?? createModelCapabilityResolutionSnapshot(),
};
return {
regularCandidates: await attachPreparedCapabilityValues(regularCandidates, capabilityState),

View File

@@ -1,36 +0,0 @@
/**
* Decide whether the Next.js build should alias `better-sqlite3` to the
* build-time stub (src/lib/db/better-sqlite3.stub.js).
*
* History (#11343): the alias was UNCONDITIONAL, added to keep the bundler from
* tracing the native addon into a Next.js build worker, whose thread teardown
* can abort with SIGABRT (assertion in node::RemoveEnvironmentCleanupHook) and
* leave the build without standalone output (#10060).
*
* The premise recorded next to that alias — "runtime still uses the real
* package via serverExternalPackages" — does not hold. A Turbopack
* `resolveAlias` rewrites the request BEFORE the externals check runs, so
* `better-sqlite3` becomes a relative path, no longer matches the
* `serverExternalPackages` entry, and the stub is baked into the bundle. Every
* artifact built from that config answered HTTP 500 on every route: the stub's
* default export is not a constructor, the sync driver chain fell through to
* `node:sqlite` and then sql.js, and the instrumentation hook aborted at boot.
*
* This is the same failure shape as #6344 (the @/mitm/manager stub shipping to
* every npm/Electron/VPS artifact), so it gets the same treatment: the alias is
* opt-in, and a default build gets the real, externalized native package.
*
* Set OMNIROUTE_BETTER_SQLITE3_STUB=1 ONLY on a build host that actually hits
* the SIGABRT worker teardown, and never for an artifact that will be run —
* the resulting bundle cannot open a database.
*/
export function shouldStubBetterSqlite3(env = process.env) {
return env.OMNIROUTE_BETTER_SQLITE3_STUB === "1";
}
/** Turbopack resolveAlias fragment for `better-sqlite3`, derived from the env. */
export function betterSqlite3AliasFor(env = process.env) {
return shouldStubBetterSqlite3(env)
? { "better-sqlite3": "./src/lib/db/better-sqlite3.stub.js" }
: {};
}

View File

@@ -7,9 +7,9 @@ import {
getSettings,
getCachedProviderNodes,
getModelAliases,
getDatabaseSettings,
getHiddenModelsByProvider,
} from "@/lib/localDb";
import { getUserDatabaseSettings } from "@/lib/db/databaseSettings";
import { createLazyConnectionView } from "@/lib/db/providers/lazyConnectionView";
import { extractAliasBackedModels } from "./aliasBackedModels";
import {
@@ -229,7 +229,10 @@ async function buildCatalogPayload(
// Falls back to the hardcoded default if not set or on error.
let cacheTTL = CATALOG_CACHE_TTL_MS_DEFAULT;
try {
const dbSettings = await getDatabaseSettings();
// Only the persisted cache section is needed here. The full database-settings
// view also calculates dbstat, WAL, schema and integrity diagnostics, which are
// synchronous and can pin the event loop after an otherwise cooperative build.
const dbSettings = getUserDatabaseSettings();
cacheTTL = dbSettings.cache?.modelCatalogCacheTtlMs ?? CATALOG_CACHE_TTL_MS_DEFAULT;
} catch {
// Swallow — use default TTL on DB error
@@ -249,7 +252,7 @@ async function buildUnifiedModelsResponseCore(
// event-loop yield, so a large deployment pins the single Node.js thread for the
// whole build (reporter: 183 connections / 2000+ models → 10.1s stall that blocks the
// dashboard WS heartbeat). Yield every `catYIELD_EVERY` items across the hot loops.
const catYIELD_EVERY = 20;
const catYIELD_EVERY = 5;
let catYieldCount = 0;
const maybeYieldCatalogBuild = async (): Promise<void> => {
catYieldCount++;
@@ -393,11 +396,10 @@ async function buildUnifiedModelsResponseCore(
): boolean => {
if (!providerKey || !modelId) return false;
const canonical = canonicalProviderId || resolveCanonicalProviderId(providerKey);
const alias =
providerIdToAlias[canonical] || providerIdToAlias[providerKey] || undefined;
const alias = providerIdToAlias[canonical] || providerIdToAlias[providerKey] || undefined;
const nodePrefix = providerIdToPrefix[providerKey] || providerIdToPrefix[canonical];
const keysToCheck = [providerKey, canonical, alias, nodePrefix].filter(
(k): k is string => Boolean(k)
const keysToCheck = [providerKey, canonical, alias, nodePrefix].filter((k): k is string =>
Boolean(k)
);
for (const key of keysToCheck) {
const hiddenSet = hiddenModelsByProvider.get(key);
@@ -830,7 +832,7 @@ async function buildUnifiedModelsResponseCore(
try {
const suffix = autoId.replace(/^auto\/?/, "");
if (!preparedAutoInputs) {
preparedAutoInputs = await prepareBuiltinAutoComboInputs();
preparedAutoInputs = await prepareBuiltinAutoComboInputs(capabilityResolutionSnapshot);
await yieldCatalogBuildTurn();
}
const virtualCombo = await createBuiltinAutoCombo(autoId, suffix, preparedAutoInputs);
@@ -1053,11 +1055,7 @@ async function buildUnifiedModelsResponseCore(
// `openai` provider page (codex runs on the openai-compatible connection)
// or via the `cx` alias — check all three so a hide from any of them
// suppresses the bare model id here.
if (
isModelHiddenBulk("codex", modelId) ||
isModelHiddenBulk("openai", modelId)
)
continue;
if (isModelHiddenBulk("codex", modelId) || isModelHiddenBulk("openai", modelId)) continue;
const alias = providerIdToAlias.codex || "cx";
const aliasId = `${alias}/${modelId}`;
@@ -1892,7 +1890,9 @@ async function buildUnifiedModelsResponseCore(
const modelId =
model.root || (typeof model.id === "string" ? model.id.split("/").pop() : undefined);
return modelId ? getTokenLimit(canonicalId, modelId) : getTokenLimit(canonicalId);
return modelId
? getTokenLimit(canonicalId, modelId, capabilityResolutionSnapshot)
: getTokenLimit(canonicalId, null, capabilityResolutionSnapshot);
};
let enrichmentSnapshot: CatalogEnrichmentSnapshot | undefined;
@@ -1905,7 +1905,7 @@ async function buildUnifiedModelsResponseCore(
}
enrichmentSnapshot = {
modelsDevPricing,
capabilityResolution: capabilityResolutionSnapshot,
capabilityResolutionSnapshot,
providerNodeIdsByPrefix: providerNodeIdByPrefix,
};
// The production profile identified pricing snapshot construction as the last

View File

@@ -227,7 +227,8 @@ export async function finalizeCatalogResponse(
// per-entry work is interleaved with other callers / the dashboard WS.
const yieldTurn = (): Promise<void> => new Promise((resolve) => setImmediate(resolve));
await yieldTurn();
const capabilityResolutionSnapshot = createModelCapabilityResolutionSnapshot();
const capabilityResolutionSnapshot =
enrichmentSnapshot?.capabilityResolutionSnapshot ?? createModelCapabilityResolutionSnapshot();
const enriched: Array<Record<string, unknown>> = [];
const catYIELD_EVERY = 5;
let catEnrichCount = 0;

View File

@@ -1,19 +1,13 @@
// Build-time stub for better-sqlite3 (#10060).
//
// OPT-IN ONLY — set OMNIROUTE_BETTER_SQLITE3_STUB=1 to alias it in, and only on
// a build host that actually hits the SIGABRT worker teardown: the native
// Statement destructor aborts when a Next.js build worker thread exits
// Aliased in for the Next.js production build (turbopack + webpack) so the
// bundler never pulls the real native addon into a build worker. The native
// Statement destructor aborts with SIGABRT when a build worker thread exits
// (assertion in node::RemoveEnvironmentCleanupHook, env == nullptr), which can
// leave the build with no standalone output.
//
// It is NOT a build-only stand-in. A Turbopack resolveAlias rewrites the
// request before the externals check, so aliasing `better-sqlite3` here also
// removes it from serverExternalPackages' reach and bakes THIS FILE into the
// shipped bundle. An artifact built with the flag on cannot open a database:
// the sync driver chain fails with "r(...) is not a constructor", falls through
// node:sqlite and sql.js, and the instrumentation hook aborts at boot, so every
// route answers HTTP 500. That is exactly what an unconditional alias shipped
// in #11343. See scripts/build/better-sqlite3-stub-flag.mjs.
// leave the build with no standalone output. At runtime the real package is
// used (it is listed in serverExternalPackages, so it is require()'d natively,
// not bundled); this stub only stands in during the build, where the DB is
// never actually queried.
class Database {
constructor() {}
prepare() {

View File

@@ -40,7 +40,6 @@ type JsonRecord = Record<string, unknown>;
export interface CatalogEnrichmentSnapshot {
modelsDevPricing: PricingByProvider | null;
capabilityResolution?: ModelCapabilityResolutionSnapshot;
providerNodeIdsByPrefix?: Readonly<Record<string, string>>;
/** #9147: build-local bulk load of synced capabilities + token/context overrides
* so per-entry enrichment never hits SQLite again (see catalogResponse.ts). */

View File

@@ -58,7 +58,7 @@ test.after(async () => {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("#9147 — catalog build at catalog-scale must not pin the event loop for a long stretch", async () => {
test("#9147 — catalog build at catalog-scale must not pin the event loop for a long stretch", async (t) => {
await seedCatalogScaleDataset();
const req = new Request("http://localhost/v1/models");
let settled = false;
@@ -79,6 +79,9 @@ test("#9147 — catalog build at catalog-scale must not pin the event loop for a
}
const res = await buildPromise;
assert.equal(res.status, 200);
t.diagnostic(
`maximum event-loop gap: ${maxGapMs.toFixed(1)}ms across ${ticks} interleaved ticks`
);
// 150ms is tight on GitHub-hosted unit shards (`--test-concurrency=4`):
// sibling tests share the event loop, so a healthy yielding builder still
// records 200260ms gaps. 400ms still fails a true pin (seconds) while
@@ -89,4 +92,9 @@ test("#9147 — catalog build at catalog-scale must not pin the event loop for a
`catalog for ${CONNECTION_COUNT} connections / ${CONNECTION_COUNT * MODELS_PER_CONNECTION} models ` +
`(${ticks} interleaved ticks observed) — the builder is not yielding to the event loop`
);
const body = (await res.json()) as { data?: Array<{ root?: string }> };
assert.ok(
body.data?.some((model) => model.root === "probe-model-59-11"),
"the responsiveness probe must still traverse and return the last seeded catalog model"
);
});

View File

@@ -1,59 +0,0 @@
// Regression test for #11343 — an unconditional Turbopack `resolveAlias` for
// better-sqlite3 shipped the build-time stub into the runtime bundle, so every
// artifact built from the release tip answered HTTP 500 on every route (the
// stub export is not a constructor, the sync driver chain fell through to
// node:sqlite and sql.js, and the instrumentation hook aborted at boot).
//
// The alias defeats `serverExternalPackages` because resolveAlias rewrites the
// request BEFORE the externals check runs. It must therefore be opt-in, and a
// default production build must externalize the REAL native package.
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const { shouldStubBetterSqlite3, betterSqlite3AliasFor } =
await import("../../scripts/build/better-sqlite3-stub-flag.mjs");
describe("better-sqlite3 stub alias (#11343)", () => {
it("default env does NOT stub better-sqlite3 (shipped artifacts get the real addon)", () => {
assert.equal(shouldStubBetterSqlite3({}), false);
assert.deepEqual(betterSqlite3AliasFor({}), {});
});
it("only the exact opt-in value enables the stub", () => {
for (const value of ["", "0", "true", "yes"]) {
assert.equal(
shouldStubBetterSqlite3({ OMNIROUTE_BETTER_SQLITE3_STUB: value }),
false,
`OMNIROUTE_BETTER_SQLITE3_STUB=${JSON.stringify(value)} must not enable the stub`
);
}
});
it("OMNIROUTE_BETTER_SQLITE3_STUB=1 opts into the stub (SIGABRT-prone build hosts, #10060)", () => {
assert.equal(shouldStubBetterSqlite3({ OMNIROUTE_BETTER_SQLITE3_STUB: "1" }), true);
assert.deepEqual(betterSqlite3AliasFor({ OMNIROUTE_BETTER_SQLITE3_STUB: "1" }), {
"better-sqlite3": "./src/lib/db/better-sqlite3.stub.js",
});
});
it("next.config.mjs derives the turbopack alias from the flag (no unconditional stub)", () => {
const config = readFileSync(new URL("../../next.config.mjs", import.meta.url), "utf8");
assert.match(
config,
/betterSqlite3AliasFor/,
"next.config.mjs must use betterSqlite3AliasFor()"
);
assert.doesNotMatch(
config,
/^\s*"better-sqlite3":\s*"\.\/src\/lib\/db\/better-sqlite3\.stub\.js",?\s*$/m,
"next.config.mjs must not hardcode the better-sqlite3 stub alias"
);
});
it("better-sqlite3 stays in serverExternalPackages so the default build externalizes it", () => {
const config = readFileSync(new URL("../../next.config.mjs", import.meta.url), "utf8");
const externals = config.slice(config.indexOf("serverExternalPackages:"));
assert.match(externals.slice(0, externals.indexOf("]")), /"better-sqlite3"/);
});
});

View File

@@ -83,10 +83,6 @@ test("next config declares Turbopack aliases, runtime assets and server external
// A default production build must NOT alias it, or the stub ships to npm/Electron/VPS
// artifacts and breaks Agent Bridge start. See the dedicated env-matrix test below.
assert.equal(nextConfig.turbopack.resolveAlias["@/mitm/manager"], undefined);
// #11343: same story for the better-sqlite3 build stub. resolveAlias is applied
// BEFORE the serverExternalPackages check, so an unconditional alias bundles the
// stub and every route answers 500 at runtime ("r(...) is not a constructor").
assert.equal(nextConfig.turbopack.resolveAlias["better-sqlite3"], undefined);
assert.equal(nextConfig.outputFileTracingRoot, process.cwd());
assert.ok(tracingIncludes.includes("./src/lib/db/migrations/**/*"));
assert.ok(
@@ -122,28 +118,6 @@ test("next config declares Turbopack aliases, runtime assets and server external
}
});
test("Turbopack aliases better-sqlite3 to the stub ONLY when OMNIROUTE_BETTER_SQLITE3_STUB=1 (#11343)", async () => {
const original = process.env.OMNIROUTE_BETTER_SQLITE3_STUB;
try {
delete process.env.OMNIROUTE_BETTER_SQLITE3_STUB;
const { default: def } = await loadNextConfig("bettersqlite-default");
assert.equal(def.turbopack.resolveAlias["better-sqlite3"], undefined);
// The default build must keep the real package reachable as an external, which
// is exactly what the alias silently defeated.
assert.ok(new Set(def.serverExternalPackages).has("better-sqlite3"));
process.env.OMNIROUTE_BETTER_SQLITE3_STUB = "1";
const { default: stubbed } = await loadNextConfig("bettersqlite-optin");
assert.equal(
stubbed.turbopack.resolveAlias["better-sqlite3"],
"./src/lib/db/better-sqlite3.stub.js"
);
} finally {
if (original === undefined) delete process.env.OMNIROUTE_BETTER_SQLITE3_STUB;
else process.env.OMNIROUTE_BETTER_SQLITE3_STUB = original;
}
});
test("Turbopack aliases @/mitm/manager to the stub ONLY when OMNIROUTE_MITM_STUB=1 (#6344)", async () => {
const original = process.env.OMNIROUTE_MITM_STUB;
try {