Files
OmniRoute/src/lib/db/models/activeSyncedCatalog.ts
diegosouzapw ed2bf5dfe9 fix(nvidia): fail open when a synced model catalog goes stale (#12849)
A connection's synced model catalog (populated via Import Models, or opt-in
autoFetchModels/autoSync) was treated as authoritative forever once populated.
lookupModelMeta (src/sse/services/model.ts) rejects any model absent from an
authoritative synced catalog, and nothing ever refreshed it automatically
(modelSyncScheduler only re-syncs autoSync:true connections, off by default).
A NVIDIA connection synced once therefore had routing permanently pinned to
that moment's catalog: live upstream models added afterwards (even ones
present in the current static registry, e.g. moonshotai/kimi-k3) were
rejected with 'not available in the active live catalog' indefinitely.

Add a per-connection synced_models_at timestamp (provider_connections,
migration 176), stamped by replaceSyncedAvailableModelsForConnection on every
sync. getActiveSyncedCatalog now only treats a provider's synced catalog as
authoritative while at least one active connection was synced within
OMNIROUTE_SYNCED_CATALOG_STALE_AFTER_MS (default 30 days); once every
connection is stale — or was never synced, e.g. pre-migration rows — it fails
open the same way an unsynced provider already does, instead of gating on a
frozen point-in-time snapshot forever.

Root cause confirmed via a TDD repro proven RED against src/lib/db/models.ts,
src/lib/db/models/activeSyncedCatalog.ts and src/lib/db/providers.ts as they
stood on release/v3.8.51 (all 5 new assertions failed — the DB did not even
have the synced_models_at column yet), then GREEN after the fix
(tests/unit/nvidia-stale-synced-catalog-12849.test.ts).

The narrower reporter-blamed cause (a stale hand-maintained
open-sse/config/nvidiaHostedModels.snapshot.json allowlist) was already fixed
by #12538 and is not read by any runtime routing path.

Refs #12849
2026-09-10 14:26:31 -03:00

392 lines
13 KiB
TypeScript

import { providerUsesAuthoritativeLiveCatalog } from "@omniroute/open-sse/config/providerRegistry";
import { PROVIDER_ID_TO_ALIAS } from "@omniroute/open-sse/config/providerModels.ts";
import { ensureCursorAutoCatalogEntry } from "@/lib/providerModels/cursorAutoCatalog";
import {
getCustomModels,
getSyncedAvailableModels,
getSyncedAvailableModelsByConnection,
type SyncedAvailableModel,
} from "../models";
import { normalizeSyncedAvailableModels } from "./synced";
import { getRawProviderConnections } from "../providers";
export type ActiveSyncedCatalog = {
authoritative: boolean;
models: SyncedAvailableModel[];
};
/**
* Fail-open membership check for explicit combo members against a live catalog.
* `null` means no authoritative catalog is synced yet (unchanged behavior).
*/
export function catalogContainsModel(
catalog: ActiveSyncedCatalog,
modelId: string
): boolean | null {
if (!catalog.authoritative) return null;
const trimmed = modelId.trim();
if (!trimmed) return false;
const ids = new Set(catalog.models.map((model) => model.id));
if (ids.has(trimmed)) return true;
const slash = trimmed.indexOf("/");
if (slash > 0 && ids.has(trimmed.slice(slash + 1))) return true;
return false;
}
export type ProviderCatalogReconciliation = {
providers: string[];
excludedProviders: string[];
};
type ProviderConnectionRef = {
id: string;
provider: string;
syncedModelsAt: string | null;
};
// #12849: a connection synced once and never refreshed must not pin routing to
// that point-in-time snapshot forever — a live model the provider has since
// added would be rejected as "unavailable" indefinitely. Once the synced
// catalog exceeds this age (or was never timestamped — pre-migration rows),
// getActiveSyncedCatalog stops treating it as authoritative and fails open,
// matching the existing no-sync-yet behavior. Overridable for ops/testing.
const DEFAULT_SYNCED_CATALOG_STALE_AFTER_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
function getSyncedCatalogStaleAfterMs(): number {
const raw = process.env.OMNIROUTE_SYNCED_CATALOG_STALE_AFTER_MS;
const parsed = raw !== undefined ? Number(raw) : NaN;
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_SYNCED_CATALOG_STALE_AFTER_MS;
}
function isSyncedAtFresh(syncedModelsAt: string | null): boolean {
if (!syncedModelsAt) return false;
const syncedAtMs = Date.parse(syncedModelsAt);
if (Number.isNaN(syncedAtMs)) return false;
return Date.now() - syncedAtMs <= getSyncedCatalogStaleAfterMs();
}
function resolveStoredProviderId(aliasOrId: string): string {
const normalized = aliasOrId.trim();
if (!normalized) return "";
if (Object.prototype.hasOwnProperty.call(PROVIDER_ID_TO_ALIAS, normalized)) {
return normalized;
}
for (const [providerId, alias] of Object.entries(PROVIDER_ID_TO_ALIAS)) {
if (alias === normalized) return providerId;
}
return normalized;
}
/**
* Distinct stored provider ids that share an account family.
* Credential lookup already pairs these in PROVIDER_SEARCH_PAIRS (#8779);
* live catalogs are keyed `provider:connectionId`, so the same pair must
* union here. parseModel folds `agy/` → `antigravity`, but CLI-card rows
* persist catalogs under `agy:` and the IDE card under `antigravity:`.
*/
const CATALOG_SIBLING_IDS: Record<string, string[]> = {
antigravity: ["agy"],
agy: ["antigravity"],
};
function catalogLookupIds(storedProviderId: string): string[] {
const siblings = CATALOG_SIBLING_IDS[storedProviderId] || [];
return [storedProviderId, ...siblings.filter((id) => id !== storedProviderId)];
}
function unionModels(groups: SyncedAvailableModel[][]): SyncedAvailableModel[] {
const models = new Map<string, SyncedAvailableModel>();
for (const group of groups) {
for (const model of group) {
if (!model?.id || models.has(model.id)) continue;
models.set(model.id, model);
}
}
return Array.from(models.values());
}
function readConnectionRef(connection: unknown): ProviderConnectionRef | null {
if (!connection || typeof connection !== "object") return null;
const record = connection as {
id?: unknown;
provider?: unknown;
syncedModelsAt?: unknown;
};
if (
typeof record.id !== "string" ||
record.id.length === 0 ||
typeof record.provider !== "string" ||
record.provider.length === 0
) {
return null;
}
return {
id: record.id,
provider: record.provider,
syncedModelsAt: typeof record.syncedModelsAt === "string" ? record.syncedModelsAt : null,
};
}
function collectModelsForConnections(
modelsByConnection: Record<string, SyncedAvailableModel[]>,
connectionIds: Iterable<string>
): SyncedAvailableModel[] {
const models = new Map<string, SyncedAvailableModel>();
for (const connectionId of connectionIds) {
for (const model of modelsByConnection[connectionId] || []) {
if (!model?.id || models.has(model.id)) continue;
models.set(model.id, model);
}
}
return Array.from(models.values());
}
function enrichCursorCatalog(
providerId: string,
models: SyncedAvailableModel[]
): SyncedAvailableModel[] {
// An empty sync means discovery has not completed (or failed). Do not let the
// synthetic Cursor auto-router rows turn that empty state into an authoritative
// catalog, otherwise every built-in model is incorrectly marked unavailable.
if (models.length === 0) return models;
return providerId === "cursor" || providerId === "cursor-api"
? ensureCursorAutoCatalogEntry(models)
: models;
}
/**
* #12597: picker-added `customModels` are already merged on GET /api/providers/{id}/models.
* Dispatch-time live catalog must union the same rows or combo / bare inference 400.
* Same-id custom metadata overlays the synced row (name, vision, …).
*/
async function unionCustomModels(
providerId: string,
models: SyncedAvailableModel[]
): Promise<SyncedAvailableModel[]> {
let customRows: SyncedAvailableModel[] = [];
try {
customRows = normalizeSyncedAvailableModels(await getCustomModels(providerId), providerId);
} catch {
// Fail open: a customModels read/parse miss must not empty the synced catalog.
return models;
}
if (customRows.length === 0) return models;
const merged = new Map<string, SyncedAvailableModel>();
for (const model of models) {
if (model?.id) merged.set(model.id, model);
}
for (const model of customRows) {
if (!model?.id) continue;
const existing = merged.get(model.id);
if (!existing) {
merged.set(model.id, model);
continue;
}
const overlay = Object.fromEntries(
Object.entries(model).filter(([, value]) => value !== undefined)
) as Partial<SyncedAvailableModel>;
merged.set(model.id, { ...existing, ...overlay, id: model.id });
}
return Array.from(merged.values());
}
/**
* Return the unioned synced catalog belonging only to active connections.
*
* A provider is authoritative only when at least one active connection has a
* non-empty usable catalog that was synced recently enough (#12849). Missing,
* empty, malformed, stale, or unavailable state fails open to the static
* registry instead of gating on a frozen point-in-time snapshot forever.
*/
type ConnectionCatalog = {
models: SyncedAvailableModel[];
hasFreshConnection: boolean;
};
async function loadConnectionCatalog(storedProviderId: string): Promise<ConnectionCatalog> {
const [connections, modelsByConnection] = await Promise.all([
getRawProviderConnections({ provider: storedProviderId, isActive: true }, undefined, undefined, [
"id",
"provider",
"synced_models_at",
]),
getSyncedAvailableModelsByConnection(storedProviderId),
]);
const activeConnections = connections
.map(readConnectionRef)
.filter((connection): connection is ProviderConnectionRef => connection !== null);
return {
models: collectModelsForConnections(
modelsByConnection,
activeConnections.map((connection) => connection.id)
),
hasFreshConnection: activeConnections.some((connection) =>
isSyncedAtFresh(connection.syncedModelsAt)
),
};
}
export async function getActiveSyncedCatalog(providerId: string): Promise<ActiveSyncedCatalog> {
const storedProviderId = resolveStoredProviderId(providerId);
if (!storedProviderId) {
return { authoritative: false, models: [] };
}
try {
const lookupIds = catalogLookupIds(storedProviderId);
const siblingCatalogs = await Promise.all(lookupIds.map(loadConnectionCatalog));
// #12866 unions the agy/antigravity sibling catalogs; #12934 then overlays the
// picker-added customModels so dispatch admits the same rows the picker REST shows.
const models = enrichCursorCatalog(
storedProviderId,
await unionCustomModels(
storedProviderId,
unionModels(siblingCatalogs.map((catalog) => catalog.models))
)
);
if (models.length > 0) {
// #12849: only gate on this catalog while at least one sibling connection
// was synced recently — otherwise a one-time historical sync would keep
// rejecting live models forever with no way to self-recover.
const hasFreshConnection = siblingCatalogs.some((catalog) => catalog.hasFreshConnection);
return {
authoritative: providerUsesAuthoritativeLiveCatalog(providerId) && hasFreshConnection,
models,
};
}
// No ACTIVE CONNECTION carries a catalog for this provider — but a provider
// NODE can: nodes live in `provider_nodes`, never in `provider_connections`,
// so filtering by active connection ids drops their synced catalog entirely.
// Before #9294 this path read the provider-wide key_value set, and losing it
// took every node's runtime metadata with it (supportedThinkingEfforts, so
// `-high`/`-low` effort suffixes stopped resolving, plus contextWindow /
// maxInputTokens used by the combo context-window filter).
//
// Fall back to that provider-wide set, and deliberately keep it
// NON-authoritative: #9294's live-catalog gating is about what an active
// connection actually serves, so a node-backed catalog must inform metadata
// without ever being used to reject a model as unavailable.
return {
authoritative: false,
models: enrichCursorCatalog(
storedProviderId,
await unionCustomModels(storedProviderId, await getSyncedAvailableModels(storedProviderId))
),
};
} catch {
return { authoritative: false, models: [] };
}
}
/**
* Return non-empty synced catalogs grouped by provider, restricted to active
* connections. This is the authoritative live source for /v1/models.
*/
export async function getAllActiveSyncedModels(): Promise<Record<string, SyncedAvailableModel[]>> {
try {
const connections = await getRawProviderConnections({ isActive: true }, undefined, undefined, [
"id",
"provider",
]);
const connectionIdsByProvider = new Map<string, Set<string>>();
for (const rawConnection of connections) {
const connection = readConnectionRef(rawConnection);
if (!connection) continue;
if (!connectionIdsByProvider.has(connection.provider)) {
connectionIdsByProvider.set(connection.provider, new Set());
}
connectionIdsByProvider.get(connection.provider)!.add(connection.id);
}
const result: Record<string, SyncedAvailableModel[]> = {};
await Promise.all(
Array.from(connectionIdsByProvider.entries()).map(async ([providerId, connectionIds]) => {
const modelsByConnection = await getSyncedAvailableModelsByConnection(providerId);
const models = enrichCursorCatalog(
providerId,
await unionCustomModels(
providerId,
collectModelsForConnections(modelsByConnection, connectionIds)
)
);
if (models.length > 0) {
result[providerId] = models;
}
})
);
return result;
} catch {
return {};
}
}
/**
* Remove static provider candidates whose active live catalog exists but does
* not contain the requested model. Providers without a usable live catalog
* retain their static fallback behavior.
*/
export async function reconcileProvidersWithActiveSyncedCatalog(
providerIds: string[],
modelId: string
): Promise<ProviderCatalogReconciliation> {
const uniqueProviders = Array.from(
new Set(
providerIds.filter(
(provider): provider is string => typeof provider === "string" && provider.length > 0
)
)
);
const states = await Promise.all(
uniqueProviders.map(async (provider) => ({
provider,
catalog: await getActiveSyncedCatalog(provider),
}))
);
const providers: string[] = [];
const excludedProviders: string[] = [];
for (const { provider, catalog } of states) {
const modelIsLive = catalog.models.some((model) => model.id === modelId);
// Cursor auto-router: always allow `auto` / router variants even if a stale live
// catalog omitted them (AvailableModels / agent list often returns wire id
// `default` only; listing injects `auto` + cost/balance/intelligence).
const cursorAutoAllow =
provider === "cursor" &&
(modelId === "auto" ||
modelId === "default" ||
modelId === "auto-cost" ||
modelId === "auto-balance" ||
modelId === "auto-intelligence");
if (!catalog.authoritative || modelIsLive || cursorAutoAllow) {
providers.push(provider);
} else {
excludedProviders.push(provider);
}
}
return { providers, excludedProviders };
}