From d69f5214916e444769a759908684b59bf805d9e4 Mon Sep 17 00:00:00 2001 From: Aman <1402357+Zartharas@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:08:23 -0600 Subject: [PATCH] fix: reconcile active live model catalogs (#9294) Validated in post-merge-train sweep (boards clean on release/v3.8.50 tip) --- open-sse/config/providerRegistry.ts | 18 ++ .../providers/registry/command-code/index.ts | 3 + open-sse/config/providers/shared.ts | 9 + open-sse/services/combo/providerWildcard.ts | 37 +--- open-sse/services/model.ts | 91 ++++++-- open-sse/utils/registeredEffortVariants.ts | 36 ++++ src/app/api/v1/models/catalog.ts | 5 +- src/lib/db/models/activeSyncedCatalog.ts | 197 +++++++++++++++++ src/sse/services/model.ts | 50 ++++- tests/unit/combo-provider-wildcard.test.ts | 62 +++++- ...-model-catalog-reconciliation-8926.test.ts | 204 ++++++++++++++++++ 11 files changed, 658 insertions(+), 54 deletions(-) create mode 100644 open-sse/utils/registeredEffortVariants.ts create mode 100644 src/lib/db/models/activeSyncedCatalog.ts create mode 100644 tests/unit/live-model-catalog-reconciliation-8926.test.ts diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 70f6ce831c..bb3b6e0f9f 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -180,6 +180,24 @@ export function getRegistryEntry(provider: string): RegistryEntry | null { return REGISTRY[provider] || _byAlias.get(provider) || null; } +/** + * Decide whether a non-empty live catalog may exclude omitted static models + * during request routing and wildcard expansion. + * + * Live discovery is authoritative by default, including for dynamic providers. + * Providers with intentionally partial discovery must explicitly opt out in + * their registry entry. + */ +export function providerUsesAuthoritativeLiveCatalog(provider: string): boolean { + const entry = getRegistryEntry(provider); + + if (entry && typeof entry.liveCatalogAuthoritative === "boolean") { + return entry.liveCatalogAuthoritative; + } + + return true; +} + /** Get all registered provider IDs */ export function getRegisteredProviders(): string[] { return Object.keys(REGISTRY); diff --git a/open-sse/config/providers/registry/command-code/index.ts b/open-sse/config/providers/registry/command-code/index.ts index affe935180..23a73efe46 100644 --- a/open-sse/config/providers/registry/command-code/index.ts +++ b/open-sse/config/providers/registry/command-code/index.ts @@ -8,6 +8,9 @@ export const command_codeProvider: RegistryEntry = { baseUrl: "https://api.commandcode.ai", chatPath: "/alpha/generate", modelsUrl: "https://api.commandcode.ai/provider/v1/models", + // The discovery response is a partial routing catalog; static registry + // entries omitted from it can still be accepted by the gateway. + liveCatalogAuthoritative: false, authType: "apikey", authHeader: "Authorization", authPrefix: "Bearer ", diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index e8e2e96d75..7ed53eaf71 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -139,6 +139,15 @@ export interface RegistryEntry { clientVersion?: string; timeoutMs?: number; passthroughModels?: boolean; + /** + * Whether a non-empty synchronized live model list is exhaustive enough + * to reject static registry IDs that it omits. + * + * Defaults to true. Set this explicitly to false for providers whose + * discovery endpoint is known to return only a partial subset of the models + * that the provider can route. + */ + liveCatalogAuthoritative?: boolean; /** Default context window for all models in this provider (can be overridden per-model) */ defaultContextLength?: number; /** Maximum OpenAI-compatible function name length accepted by this provider. */ diff --git a/open-sse/services/combo/providerWildcard.ts b/open-sse/services/combo/providerWildcard.ts index fdc5cd60ab..d27c168cd5 100644 --- a/open-sse/services/combo/providerWildcard.ts +++ b/open-sse/services/combo/providerWildcard.ts @@ -31,7 +31,7 @@ import { wildcardMatch } from "../wildcardRouter.ts"; import { getProviderModels } from "../../config/providerModels.ts"; -import { getSyncedAvailableModels } from "../../../src/lib/db/models.ts"; +import { getActiveSyncedCatalog } from "../../../src/lib/db/models/activeSyncedCatalog.ts"; import type { ComboLike } from "./types.ts"; /** Sentinel pattern used for "all models of a provider". */ @@ -116,39 +116,18 @@ function parseWildcardEntry(entry: unknown): ProviderWildcardSpec | null { } /** - * Collect candidate model IDs for a provider from two sources: - * 1. Synced available models in the DB (runtime-dynamic; custom/OAuth providers) - * 2. Static provider registry (built-in providers bundled with the release) - * - * The union is deduped by model id. + * Collect candidate model IDs using the active synced catalog as the + * authoritative source when it is non-empty. Static registry models remain a + * fail-open fallback when no active usable catalog exists. */ async function collectProviderModelIds(providerId: string): Promise { - const seen = new Set(); - const ids: string[] = []; + const liveCatalog = await getActiveSyncedCatalog(providerId); - // 1. Synced DB models (highest priority — reflects the live catalog) - try { - const synced = await getSyncedAvailableModels(providerId); - for (const m of synced) { - if (m.id && !seen.has(m.id)) { - seen.add(m.id); - ids.push(m.id); - } - } - } catch { - // Non-fatal — DB may be offline in tests or at early init. + if (liveCatalog.authoritative) { + return liveCatalog.models.map((model) => model.id); } - // 2. Static registry models (fallback / built-in providers) - const registryModels = getProviderModels(providerId); - for (const m of registryModels) { - if (m.id && !seen.has(m.id)) { - seen.add(m.id); - ids.push(m.id); - } - } - - return ids; + return getProviderModels(providerId).map((model) => model.id); } /** diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index 9bfc53a451..640fb10802 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -1,5 +1,6 @@ import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "../config/providerModels.ts"; import { resolveWildcardAlias } from "./wildcardRouter.ts"; +import { getRegisteredProviderEffortBaseModelId } from "../utils/registeredEffortVariants.ts"; type ProviderModelAliasMap = Record>; type ModelAliasValue = string | { provider?: string; model?: string }; @@ -341,6 +342,48 @@ async function getActiveSyncedProvidersForModel(modelId: string) { } } +async function reconcileInferredProvidersWithActiveCatalog(providerIds: string[], modelId: string) { + const uniqueProviders = Array.from(new Set(providerIds)); + + try { + const { reconcileProvidersWithActiveSyncedCatalog } = + await import("@/lib/db/models/activeSyncedCatalog"); + + const reconciliations = await Promise.all( + uniqueProviders.map(async (provider) => { + const effortBaseModelId = getRegisteredProviderEffortBaseModelId(provider, modelId); + + const catalogModelId = effortBaseModelId ?? modelId; + + const reconciliation = await reconcileProvidersWithActiveSyncedCatalog( + [provider], + catalogModelId + ); + + return { + provider, + allowed: reconciliation.providers.includes(provider), + excluded: reconciliation.excludedProviders.includes(provider), + }; + }) + ); + + return { + providers: reconciliations + .filter((result) => result.allowed) + .map((result) => result.provider), + excludedProviders: reconciliations + .filter((result) => result.excluded) + .map((result) => result.provider), + }; + } catch { + return { + providers: uniqueProviders, + excludedProviders: [], + }; + } +} + function isTruthyEnv(value: string | undefined) { return typeof value === "string" && /^(1|true|yes|on)$/i.test(value.trim()); } @@ -597,19 +640,22 @@ async function resolveModelByProviderInference(modelId: string, extendedContext: }; } } - // #FIX: synced catalogs (populated from `/v1/models` per connection) can - // claim ownership of models the provider does not actually serve (e.g. a - // `kiro` upstream briefly advertising `claude-opus-5` before it was - // vendored into the registry). Without this filter the bare-routing path - // would forward traffic to providers that 404 on the upstream call. - // Auto-discovery still wins when no static registry entry exists for the - // model id — only entries that conflict with the static catalog are dropped. - const staticCatalogProviders = MODEL_TO_PROVIDERS.get(modelId) || []; - const validatedSyncedProviders = - staticCatalogProviders.length > 0 - ? activeSyncedProviders.filter((p) => staticCatalogProviders.includes(p)) - : activeSyncedProviders; - const providers = getInferredProvidersForModel(modelId, validatedSyncedProviders); + + const candidateProviders = getInferredProvidersForModel(modelId, activeSyncedProviders); + const { providers, excludedProviders } = await reconcileInferredProvidersWithActiveCatalog( + candidateProviders, + modelId + ); + + if (providers.length === 0 && excludedProviders.length > 0) { + return { + provider: null, + model: modelId, + extendedContext, + errorType: "model_not_found", + errorMessage: `Model '${modelId}' is not available in the active live catalog for provider(s): ${excludedProviders.join(", ")}.`, + }; + } const nonOpenAIProviders = providers.filter((p) => p !== "openai"); // Bare model IDs from Codex CLI do not preserve OmniRoute's `cx/` prefix. @@ -686,6 +732,25 @@ async function resolveModelByProviderInference(modelId: string, extendedContext: activeCandidates = canonicalCandidates.filter((p) => activeProviders.has(p)); } + // An authoritative active live catalog excluded at least one static + // candidate, and none of the remaining static candidates has an active + // connection. Do not escape the live-catalog decision by selecting an + // unrelated inactive provider that happens to share the same static model id. + if ( + activeProviders && + activeProviders.size > 0 && + activeCandidates.length === 0 && + excludedProviders.length > 0 + ) { + return { + provider: null, + model: modelId, + extendedContext, + errorType: "model_not_found", + errorMessage: `Model '${modelId}' is not available in the active live catalog for provider(s): ${excludedProviders.join(", ")}.`, + }; + } + // Auto-pick: // 1. If active providers match, pick from active candidates (first active provider). // 2. If no active providers filter applied, but canonical candidates deduplicate to 1 provider, pick it. diff --git a/open-sse/utils/registeredEffortVariants.ts b/open-sse/utils/registeredEffortVariants.ts new file mode 100644 index 0000000000..06e2d5dfc6 --- /dev/null +++ b/open-sse/utils/registeredEffortVariants.ts @@ -0,0 +1,36 @@ +import { getProviderModels } from "../config/providerModels.ts"; + +const REGISTERED_EFFORT_SUFFIXES = ["none", "low", "medium", "high", "max", "xhigh"] as const; + +/** + * Return the registered base model for an explicit effort variant. + * + * Both the exact variant and its base must exist in the provider registry. + * Callers with an authoritative live catalog must additionally verify that + * the returned base model is present in that live catalog. + */ +export function getRegisteredProviderEffortBaseModelId( + providerId: string, + modelId: string +): string | null { + const providerModels = getProviderModels(providerId); + + if (!providerModels.some((candidate) => candidate.id === modelId)) { + return null; + } + + for (const effort of REGISTERED_EFFORT_SUFFIXES) { + const suffix = `-${effort}`; + if (!modelId.endsWith(suffix)) continue; + + const baseModelId = modelId.slice(0, -suffix.length); + + return providerModels.some((candidate) => candidate.id === baseModelId) ? baseModelId : null; + } + + return null; +} + +export function isRegisteredProviderEffortVariant(providerId: string, modelId: string): boolean { + return getRegisteredProviderEffortBaseModelId(providerId, modelId) !== null; +} diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 2688561192..e59431fca1 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -37,7 +37,8 @@ import { createBuiltinAutoCombo, isPaidTierAutoId, } from "@omniroute/open-sse/services/autoCombo/builtinCatalog"; -import { getAllSyncedAvailableModels, type SyncedAvailableModel } from "@/lib/db/models"; +import type { SyncedAvailableModel } from "@/lib/db/models"; +import { getAllActiveSyncedModels } from "@/lib/db/models/activeSyncedCatalog"; import { getModelCatalogCacheVersion } from "@/lib/db/readCache"; import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels"; import { providerUsesCuratedModelsOnly } from "@/lib/providers/modelListingCapability"; @@ -676,7 +677,7 @@ async function buildUnifiedModelsResponseCore( let syncedModelsByProvider: Record = {}; try { - syncedModelsByProvider = await getAllSyncedAvailableModels(); + syncedModelsByProvider = await getAllActiveSyncedModels(); } catch (e) { // DB unavailable — log and fall through; static models remain as defaults. console.log("[catalog] Could not fetch synced available models:", e); diff --git a/src/lib/db/models/activeSyncedCatalog.ts b/src/lib/db/models/activeSyncedCatalog.ts new file mode 100644 index 0000000000..7ef7604374 --- /dev/null +++ b/src/lib/db/models/activeSyncedCatalog.ts @@ -0,0 +1,197 @@ +import { providerUsesAuthoritativeLiveCatalog } from "@omniroute/open-sse/config/providerRegistry"; +import { PROVIDER_ID_TO_ALIAS } from "@omniroute/open-sse/config/providerModels.ts"; +import { getSyncedAvailableModelsByConnection, type SyncedAvailableModel } from "../models"; +import { getRawProviderConnections } from "../providers"; + +export type ActiveSyncedCatalog = { + authoritative: boolean; + models: SyncedAvailableModel[]; +}; + +export type ProviderCatalogReconciliation = { + providers: string[]; + excludedProviders: string[]; +}; + +type ProviderConnectionRef = { + id: string; + provider: string; +}; + +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; +} + +function readConnectionRef(connection: unknown): ProviderConnectionRef | null { + if (!connection || typeof connection !== "object") return null; + + const record = connection as { + id?: unknown; + provider?: 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, + }; +} + +function collectModelsForConnections( + modelsByConnection: Record, + connectionIds: Iterable +): SyncedAvailableModel[] { + const models = new Map(); + + 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()); +} + +/** + * 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. Missing, empty, malformed, or unavailable state + * fails open to the static registry. + */ +export async function getActiveSyncedCatalog(providerId: string): Promise { + const storedProviderId = resolveStoredProviderId(providerId); + if (!storedProviderId) { + return { authoritative: false, models: [] }; + } + + try { + const [connections, modelsByConnection] = await Promise.all([ + getRawProviderConnections( + { provider: storedProviderId, isActive: true }, + undefined, + undefined, + ["id", "provider"] + ), + getSyncedAvailableModelsByConnection(storedProviderId), + ]); + + const activeConnectionIds = connections + .map(readConnectionRef) + .filter((connection): connection is ProviderConnectionRef => connection !== null) + .map((connection) => connection.id); + + const models = collectModelsForConnections(modelsByConnection, activeConnectionIds); + + return { + authoritative: models.length > 0 && providerUsesAuthoritativeLiveCatalog(providerId), + models, + }; + } 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> { + try { + const connections = await getRawProviderConnections({ isActive: true }, undefined, undefined, [ + "id", + "provider", + ]); + + const connectionIdsByProvider = new Map>(); + + 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 = {}; + + await Promise.all( + Array.from(connectionIdsByProvider.entries()).map(async ([providerId, connectionIds]) => { + const modelsByConnection = await getSyncedAvailableModelsByConnection(providerId); + + const models = 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 { + 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); + + if (!catalog.authoritative || modelIsLive) { + providers.push(provider); + } else { + excludedProviders.push(provider); + } + } + + return { providers, excludedProviders }; +} diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts index a1e02a4a44..7c9f2ccdbc 100644 --- a/src/sse/services/model.ts +++ b/src/sse/services/model.ts @@ -8,7 +8,7 @@ import { getCustomModels, } from "@/lib/localDb"; import { getCachedSettings } from "@/lib/localDb"; -import { getSyncedAvailableModels } from "@/lib/db/models"; +import { getActiveSyncedCatalog } from "@/lib/db/models/activeSyncedCatalog"; import { parseModel, getModelInfoCore, @@ -16,6 +16,7 @@ import { stripContextWindowSuffix, } from "@omniroute/open-sse/services/model.ts"; import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; +import { getRegisteredProviderEffortBaseModelId } from "@omniroute/open-sse/utils/registeredEffortVariants.ts"; export { parseModel, stripContextWindowSuffix }; @@ -210,12 +211,18 @@ function buildRuntimeModelMeta(customMatch: any, syncedMatch: any): RuntimeModel async function lookupModelMeta( providerId: string, modelId: string -): Promise<{ modelId: string; metadata: RuntimeModelMeta }> { +): Promise<{ + modelId: string; + metadata: RuntimeModelMeta; + available: boolean; +}> { try { - const [customModels, syncedModels] = await Promise.all([ + const [customModels, liveCatalog] = await Promise.all([ getCustomModels(providerId), - getSyncedAvailableModels(providerId), + getActiveSyncedCatalog(providerId), ]); + const syncedModels = liveCatalog.models; + // #7694: no direct match on the raw modelId? try a synced-declared `-{effort}` // suffix before falling back to the literal id, so `/-` // resolves to the real base model + a resolved effort. @@ -224,15 +231,25 @@ async function lookupModelMeta( modelId, syncedModels ); - // #7364: exact match first; retain the case-insensitive custom-model fallback - // while also consulting the API-synced catalog for Kimi runtime metadata. + + // Custom models remain explicit operator overrides even when live discovery + // is authoritative for the provider. const customMatch = findCustomModelMeta(customModels, resolvedModelId); const syncedMatch = findSyncedModelMeta(syncedModels, resolvedModelId); + const effortBaseModelId = getRegisteredProviderEffortBaseModelId(providerId, modelId); + + const liveBackedEffortVariant = + effortBaseModelId !== null && syncedModels.some((model) => model.id === effortBaseModelId); + + const available = + !liveCatalog.authoritative || Boolean(customMatch || syncedMatch || liveBackedEffortVariant); + const metadata = buildRuntimeModelMeta(customMatch, syncedMatch); if (effort) metadata.resolvedThinkingEffort = effort; - return { modelId: resolvedModelId, metadata }; + + return { modelId: resolvedModelId, metadata, available }; } catch { - return { modelId, metadata: {} }; + return { modelId, metadata: {}, available: true }; } } @@ -261,8 +278,23 @@ export async function getModelInfo(modelStr) { const attachRuntimeModelMeta = async (info: any) => { if (!info?.provider || !info?.model) return info; - const { modelId, metadata } = await lookupModelMeta(String(info.provider), String(info.model)); + + const providerId = String(info.provider); + const requestedModelId = String(info.model); + const { modelId, metadata, available } = await lookupModelMeta(providerId, requestedModelId); + + if (!available) { + return { + provider: null, + model: requestedModelId, + extendedContext: info.extendedContext, + errorType: "model_not_found", + errorMessage: `Model '${requestedModelId}' is not available in the active live catalog for provider '${providerId}'.`, + }; + } + const resolvedInfo = modelId !== info.model ? { ...info, model: modelId } : info; + return Object.keys(metadata).length > 0 ? { ...resolvedInfo, ...metadata } : resolvedInfo; }; diff --git a/tests/unit/combo-provider-wildcard.test.ts b/tests/unit/combo-provider-wildcard.test.ts index 9c9e371b84..95008b6b07 100644 --- a/tests/unit/combo-provider-wildcard.test.ts +++ b/tests/unit/combo-provider-wildcard.test.ts @@ -43,7 +43,21 @@ function makeCombo(models: unknown[], name = "test-combo") { } // Seed synced models for a provider into the DB. -async function seedSyncedModels(providerId: string, connectionId: string, modelIds: string[]) { +async function seedSyncedModels( + providerId: string, + connectionId: string, + modelIds: string[], + isActive = true +) { + const db = core.getDbInstance(); + const now = new Date().toISOString(); + + db.prepare( + `INSERT OR REPLACE INTO provider_connections + (id, provider, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)` + ).run(connectionId, providerId, isActive ? 1 : 0, now, now); + await replaceSyncedAvailableModelsForConnection( providerId, connectionId, @@ -346,3 +360,49 @@ test("expandProviderWildcardsInCombo: three providers mixed with explicit entrie assert.equal((result.models[3] as any).model, `${p2}/m-c`); assert.equal(result.models[4], "openai/gpt-4o"); }); + +test("#8926: active synced catalog replaces static wildcard entries", async () => { + const providerId = "github"; + const liveModelId = "live-only-model-8926"; + + await seedSyncedModels(providerId, "github-live-8926", [liveModelId]); + + const result = await expandProviderWildcardsInCombo( + makeCombo([`${providerId}/*`], "github-live-authority-8926") + ); + + assert.deepEqual( + result.models.map((entry) => (entry as { model: string }).model), + [`${providerId}/${liveModelId}`], + "an active non-empty synced catalog must replace, not union with, static models" + ); + + const aliasResult = await expandProviderWildcardsInCombo( + makeCombo(["gh/*"], "github-live-alias-8926") + ); + + assert.deepEqual( + aliasResult.models.map((entry) => (entry as { model: string }).model), + [`gh/${liveModelId}`], + "the public provider alias must use the canonical provider's active catalog" + ); +}); + +test("#8926: inactive synced catalog does not override static wildcard fallback", async () => { + const providerId = "openai"; + const inactiveModelId = "inactive-only-model-8926"; + + await seedSyncedModels(providerId, "openai-inactive-8926", [inactiveModelId], false); + + const result = await expandProviderWildcardsInCombo( + makeCombo([`${providerId}/*`], "openai-static-fallback-8926") + ); + + const expanded = result.models.map((entry) => (entry as { model: string }).model); + + assert.ok(expanded.length > 0, "static fallback should remain available"); + assert.ok( + !expanded.includes(`${providerId}/${inactiveModelId}`), + "an inactive connection must not make its synced catalog authoritative" + ); +}); diff --git a/tests/unit/live-model-catalog-reconciliation-8926.test.ts b/tests/unit/live-model-catalog-reconciliation-8926.test.ts new file mode 100644 index 0000000000..2796071e43 --- /dev/null +++ b/tests/unit/live-model-catalog-reconciliation-8926.test.ts @@ -0,0 +1,204 @@ +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"; +import { providerUsesAuthoritativeLiveCatalog } from "../../open-sse/config/providerRegistry.ts"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-live-catalog-8926-")); + +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "live-catalog-8926-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const { addCustomModel, replaceSyncedAvailableModelsForConnection } = + await import("../../src/lib/db/models.ts"); +const { getActiveSyncedCatalog, getAllActiveSyncedModels } = + await import("../../src/lib/db/models/activeSyncedCatalog.ts"); +const { isRegisteredProviderEffortVariant } = + await import("../../open-sse/utils/registeredEffortVariants.ts"); + +const { getModelInfo } = await import("../../src/sse/services/model.ts"); +const { getProviderModels } = await import("../../open-sse/config/providerModels.ts"); + +const PROVIDER = "github"; +const CONNECTION_ID = "github-live-catalog-8926"; +const LIVE_MODEL = "gpt-4.1"; +const PHANTOM_MODEL = "oswe-vscode-prime"; + +function seedProviderCatalog( + providerId: string, + connectionId: string, + modelIds: string[], + isActive = true +) { + const db = core.getDbInstance(); + const now = new Date().toISOString(); + + db.prepare( + `INSERT OR REPLACE INTO provider_connections + (id, provider, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)` + ).run(connectionId, providerId, isActive ? 1 : 0, now, now); + + return replaceSyncedAvailableModelsForConnection( + providerId, + connectionId, + modelIds.map((id) => ({ + id, + name: id, + source: "imported" as const, + })) + ); +} + +function seedActiveLiveCatalog() { + return seedProviderCatalog(PROVIDER, CONNECTION_ID, [LIVE_MODEL]); +} + +test.beforeEach(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + + assert.ok( + getProviderModels(PROVIDER).some((model: { id?: string }) => model.id === PHANTOM_MODEL), + `precondition: ${PHANTOM_MODEL} must exist in the static GitHub catalog` + ); + + await seedActiveLiveCatalog(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#8926: bare inference excludes a stale static model absent from the active live catalog", async () => { + const resolved = await getModelInfo(PHANTOM_MODEL); + + assert.equal(resolved.provider, null); + assert.equal(resolved.errorType, "model_not_found"); + assert.match( + resolved.errorMessage, + /active live catalog/i, + "the error should explain that live availability rejected the stale entry" + ); +}); + +test("#8926: explicit provider/model rejects a stale static model before upstream dispatch", async () => { + const resolved = await getModelInfo(`${PROVIDER}/${PHANTOM_MODEL}`); + + assert.equal(resolved.provider, null); + assert.equal(resolved.errorType, "model_not_found"); + assert.match( + resolved.errorMessage, + /active live catalog/i, + "the explicit route should report live-catalog unavailability" + ); +}); + +test("#8926: active alternative provider remains eligible", async () => { + await seedProviderCatalog("ghe-copilot", "ghe-copilot-active-8926", []); + + const resolved = await getModelInfo(PHANTOM_MODEL); + + assert.equal(resolved.provider, "ghe-copilot"); + assert.equal(resolved.model, PHANTOM_MODEL); +}); + +test("#8926: explicit custom model overrides live-catalog exclusion", async () => { + await addCustomModel(PROVIDER, PHANTOM_MODEL, "Operator custom override"); + + const resolved = await getModelInfo(`${PROVIDER}/${PHANTOM_MODEL}`); + + assert.equal(resolved.provider, PROVIDER); + assert.equal(resolved.model, PHANTOM_MODEL); +}); + +test("#8926: effort helper identifies only explicitly registered variants", () => { + assert.equal(isRegisteredProviderEffortVariant("cursor", "gpt-5.3-codex-high"), true); + + assert.equal( + isRegisteredProviderEffortVariant("cursor", "gpt-5.3-codex-max"), + false, + "an invented suffix must not bypass live-catalog authority" + ); +}); + +test("#8926: registered effort route survives while invented effort route is rejected", async () => { + await seedProviderCatalog("cursor", "cursor-live-8926", ["gpt-5.3-codex"]); + + const registered = await getModelInfo("cursor/gpt-5.3-codex-high"); + + assert.equal(registered.provider, "cursor"); + assert.equal(registered.model, "gpt-5.3-codex-high"); + + const invented = await getModelInfo("cursor/gpt-5.3-codex-max"); + + assert.equal(invented.provider, null); + assert.equal(invented.errorType, "model_not_found"); +}); + +test("#8926: active-only catalog excludes inactive siblings and resolves aliases", async () => { + await seedProviderCatalog(PROVIDER, "github-inactive-sibling-8926", [PHANTOM_MODEL], false); + + const allActive = await getAllActiveSyncedModels(); + + assert.deepEqual( + allActive[PROVIDER]?.map((model) => model.id), + [LIVE_MODEL] + ); + + const aliasCatalog = await getActiveSyncedCatalog("gh"); + + assert.equal(aliasCatalog.authoritative, true); + + assert.deepEqual( + aliasCatalog.models.map((model) => model.id), + [LIVE_MODEL] + ); +}); + +test("#8926: providers without an authoritative live catalog retain static fallback", async () => { + const resolved = await getModelInfo("openai/gpt-4o-mini"); + + assert.equal(resolved.provider, "openai"); + assert.equal(resolved.model, "gpt-4o-mini"); +}); + +test("#8926: registered effort variant is rejected when its live base is absent", async () => { + await seedProviderCatalog("cursor", "cursor-live-without-base-8926", ["cursor-live-only-8926"]); + + const explicit = await getModelInfo("cursor/gpt-5.3-codex-high"); + + assert.equal(explicit.provider, null); + assert.equal(explicit.errorType, "model_not_found"); + assert.match(explicit.errorMessage, /active live catalog/i); + + const bare = await getModelInfo("gpt-5.3-codex-high"); + + assert.equal(bare.provider, null); + assert.equal(bare.errorType, "model_not_found"); + assert.match(bare.errorMessage, /active live catalog/i); +}); + +test("#8926: live authority defaults to strict and honors explicit partial-discovery opt-outs", () => { + assert.equal(providerUsesAuthoritativeLiveCatalog("github"), true); + assert.equal(providerUsesAuthoritativeLiveCatalog("cursor"), true); + assert.equal(providerUsesAuthoritativeLiveCatalog("unknown-provider-8926"), true); + assert.equal(providerUsesAuthoritativeLiveCatalog("theoldllm"), true); + assert.equal(providerUsesAuthoritativeLiveCatalog("command-code"), false); +}); + +test("#8926: partial passthrough discovery remains non-authoritative", async () => { + await seedProviderCatalog("command-code", "command-code-partial-live-8926", ["gpt-5.6-luna"]); + + const catalog = await getActiveSyncedCatalog("command-code"); + + assert.equal(catalog.authoritative, false); + assert.deepEqual( + catalog.models.map((model) => model.id), + ["gpt-5.6-luna"] + ); +});