mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 23:32:12 +03:00
fix: reconcile active live model catalogs (#9294)
Validated in post-merge-train sweep (boards clean on release/v3.8.50 tip)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 ",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<string[]> {
|
||||
const seen = new Set<string>();
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, Record<string, string>>;
|
||||
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.
|
||||
|
||||
36
open-sse/utils/registeredEffortVariants.ts
Normal file
36
open-sse/utils/registeredEffortVariants.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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<string, SyncedAvailableModel[]> = {};
|
||||
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);
|
||||
|
||||
197
src/lib/db/models/activeSyncedCatalog.ts
Normal file
197
src/lib/db/models/activeSyncedCatalog.ts
Normal file
@@ -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<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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ActiveSyncedCatalog> {
|
||||
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<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 = 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);
|
||||
|
||||
if (!catalog.authoritative || modelIsLive) {
|
||||
providers.push(provider);
|
||||
} else {
|
||||
excludedProviders.push(provider);
|
||||
}
|
||||
}
|
||||
|
||||
return { providers, excludedProviders };
|
||||
}
|
||||
@@ -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 `<prefix>/<model>-<tier>`
|
||||
// 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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
204
tests/unit/live-model-catalog-reconciliation-8926.test.ts
Normal file
204
tests/unit/live-model-catalog-reconciliation-8926.test.ts
Normal file
@@ -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"]
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user