fix(models): resolve auto-combo target metadata once per catalog build

#12046 derives vision/modalities for the built-in auto/* combos by resolving
catalog metadata for every target of every combo. The ~40 auto combos share one
candidate pool and the loop neither memoized nor yielded, so the #9147 fixture
(60 connections, 720 synced models) went from a ~4s build with a 167ms longest
event-loop gap to ~11s and a 860-1070ms gap on an idle box — past the 800ms
contract and past #12628's 8s cold-build bound, which is why the test came back
500 catalog_build_timeout on every release-green run.

Metadata depends only on the target's provider/model/connection scope within a
build, so memoize it per build and yield between misses. Same fixture: 2.3-3.1s
build, 56-72ms longest gap. The 9147 test is unchanged.

Refs #12732
This commit is contained in:
diegosouzapw
2026-09-14 19:05:59 -03:00
parent 66229feaaa
commit 6647654bca
4 changed files with 97 additions and 1 deletions

View File

@@ -0,0 +1 @@
- **fix(models):** a cold `GET /v1/models` on a large deployment no longer blocks the event loop for about a second at a time or overruns the 8s cold-build bound: since #12046 the built-in `auto/*` combos resolved catalog metadata for every target of every combo without memoizing or yielding, and they all draw on the same candidate pool, so 720 synced models took the build from ~4s to ~18s. Each distinct target is now resolved once per build, with a yield between misses ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732))

View File

@@ -104,6 +104,7 @@ import {
mergeComboCapabilities,
getConnectionScopedEffortTiers,
type ConnectionScopedReasoningCatalog,
memoizeTargetMetadata,
} from "./catalogHelpers";
import {
qualifyOpenRouterModelId,
@@ -848,6 +849,7 @@ async function buildUnifiedModelsResponseCore(
// catalog build. Runtime auto routing still prepares fresh request-scoped inputs.
let preparedAutoInputs: Awaited<ReturnType<typeof prepareBuiltinAutoComboInputs>> | undefined;
let materializedAutoCount = 0;
const autoMeta = memoizeTargetMetadata(getComboTargetCatalogMetadata, maybeYieldCatalogBuild);
for (const autoId of [
...Object.keys(AUTO_TEMPLATE_VARIANTS),
...AUTO_SUFFIX_VARIANTS,
@@ -890,7 +892,7 @@ async function buildUnifiedModelsResponseCore(
connectionId: m.connectionId,
...(m.allowedConnectionIds ? { allowedConnectionIds: m.allowedConnectionIds } : {}),
}));
const autoTargetMetadata = autoTargets.map((t) => getComboTargetCatalogMetadata(t));
const autoTargetMetadata = await autoMeta(autoTargets); // #9147: once per build
const knownAutoMeta = autoTargetMetadata.filter(
(m): m is ComboTargetCatalogMetadata => m !== null
);

View File

@@ -222,3 +222,36 @@ export function mergeComboCapabilities(
}
return capabilities;
}
/**
* Memoize per-target catalog metadata for one catalog build, yielding between misses.
* #12046 resolves metadata for every target of every built-in `auto/*` combo, and those
* ~40 combos draw on the same candidate pool: unmemoized, the build repeated the same
* lookups tens of thousands of times without yielding (#9147 — 720 synced models took the
* cold build from ~4s to ~18s, past the 8s cold-build bound). Metadata depends only on
* the target fields in the key, so each distinct target is resolved once per build.
*/
export function memoizeTargetMetadata<T>(
resolve: (target: ComboCatalogTarget) => T | null,
afterMiss: () => Promise<void>
): (targets: ComboCatalogTarget[]) => Promise<Array<T | null>> {
const byKey = new Map<string, T | null>();
return async (targets) => {
const resolved: Array<T | null> = [];
for (const target of targets) {
const key = JSON.stringify([
target.providerId ?? null,
target.provider ?? null,
target.modelStr ?? null,
target.connectionId ?? null,
target.allowedConnectionIds ?? null,
]);
if (!byKey.has(key)) {
byKey.set(key, resolve(target));
await afterMiss();
}
resolved.push(byKey.get(key) ?? null);
}
return resolved;
};
}

View File

@@ -0,0 +1,60 @@
import test from "node:test";
import assert from "node:assert/strict";
import { memoizeTargetMetadata } from "../../src/app/api/v1/models/catalogHelpers.ts";
// #9147 / #12046: the built-in auto/* combos all resolve metadata for the same candidate
// pool, so the catalog build must resolve each distinct target once and yield between misses.
test("memoizeTargetMetadata resolves each distinct target once across calls", async () => {
const resolved: string[] = [];
let yields = 0;
const resolveTargets = memoizeTargetMetadata(
(target) => {
resolved.push(`${target.providerId}/${target.modelStr}`);
return target.modelStr === "missing" ? null : { id: target.modelStr };
},
async () => {
yields++;
}
);
const pool = [
{ providerId: "openai", modelStr: "gpt-a" },
{ providerId: "openai", modelStr: "missing" },
{ providerId: "claude", modelStr: "gpt-a" },
];
const first = await resolveTargets(pool);
const second = await resolveTargets([...pool].reverse());
assert.deepEqual(first, [{ id: "gpt-a" }, null, { id: "gpt-a" }]);
assert.deepEqual(second, [{ id: "gpt-a" }, null, { id: "gpt-a" }]);
assert.deepEqual(resolved, ["openai/gpt-a", "openai/missing", "claude/gpt-a"]);
assert.equal(yields, 3, "one yield per cache miss, none on hits");
});
test("memoizeTargetMetadata keys on connection scope, not just provider/model", async () => {
let calls = 0;
const resolveTargets = memoizeTargetMetadata(
(target) => {
calls++;
return { scope: target.connectionId ?? target.allowedConnectionIds?.join(",") ?? "any" };
},
async () => {}
);
const result = await resolveTargets([
{ providerId: "openai", modelStr: "gpt-a" },
{ providerId: "openai", modelStr: "gpt-a", connectionId: "conn-1" },
{ providerId: "openai", modelStr: "gpt-a", allowedConnectionIds: ["conn-1", "conn-2"] },
{ providerId: "openai", modelStr: "gpt-a", connectionId: "conn-1" },
]);
assert.deepEqual(result, [
{ scope: "any" },
{ scope: "conn-1" },
{ scope: "conn-1,conn-2" },
{ scope: "conn-1" },
]);
assert.equal(calls, 3);
});