mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +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;
|
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 */
|
/** Get all registered provider IDs */
|
||||||
export function getRegisteredProviders(): string[] {
|
export function getRegisteredProviders(): string[] {
|
||||||
return Object.keys(REGISTRY);
|
return Object.keys(REGISTRY);
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export const command_codeProvider: RegistryEntry = {
|
|||||||
baseUrl: "https://api.commandcode.ai",
|
baseUrl: "https://api.commandcode.ai",
|
||||||
chatPath: "/alpha/generate",
|
chatPath: "/alpha/generate",
|
||||||
modelsUrl: "https://api.commandcode.ai/provider/v1/models",
|
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",
|
authType: "apikey",
|
||||||
authHeader: "Authorization",
|
authHeader: "Authorization",
|
||||||
authPrefix: "Bearer ",
|
authPrefix: "Bearer ",
|
||||||
|
|||||||
@@ -139,6 +139,15 @@ export interface RegistryEntry {
|
|||||||
clientVersion?: string;
|
clientVersion?: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
passthroughModels?: boolean;
|
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) */
|
/** Default context window for all models in this provider (can be overridden per-model) */
|
||||||
defaultContextLength?: number;
|
defaultContextLength?: number;
|
||||||
/** Maximum OpenAI-compatible function name length accepted by this provider. */
|
/** Maximum OpenAI-compatible function name length accepted by this provider. */
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
|
|
||||||
import { wildcardMatch } from "../wildcardRouter.ts";
|
import { wildcardMatch } from "../wildcardRouter.ts";
|
||||||
import { getProviderModels } from "../../config/providerModels.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";
|
import type { ComboLike } from "./types.ts";
|
||||||
|
|
||||||
/** Sentinel pattern used for "all models of a provider". */
|
/** 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:
|
* Collect candidate model IDs using the active synced catalog as the
|
||||||
* 1. Synced available models in the DB (runtime-dynamic; custom/OAuth providers)
|
* authoritative source when it is non-empty. Static registry models remain a
|
||||||
* 2. Static provider registry (built-in providers bundled with the release)
|
* fail-open fallback when no active usable catalog exists.
|
||||||
*
|
|
||||||
* The union is deduped by model id.
|
|
||||||
*/
|
*/
|
||||||
async function collectProviderModelIds(providerId: string): Promise<string[]> {
|
async function collectProviderModelIds(providerId: string): Promise<string[]> {
|
||||||
const seen = new Set<string>();
|
const liveCatalog = await getActiveSyncedCatalog(providerId);
|
||||||
const ids: string[] = [];
|
|
||||||
|
|
||||||
// 1. Synced DB models (highest priority — reflects the live catalog)
|
if (liveCatalog.authoritative) {
|
||||||
try {
|
return liveCatalog.models.map((model) => model.id);
|
||||||
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.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Static registry models (fallback / built-in providers)
|
return getProviderModels(providerId).map((model) => model.id);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "../config/providerModels.ts";
|
import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "../config/providerModels.ts";
|
||||||
import { resolveWildcardAlias } from "./wildcardRouter.ts";
|
import { resolveWildcardAlias } from "./wildcardRouter.ts";
|
||||||
|
import { getRegisteredProviderEffortBaseModelId } from "../utils/registeredEffortVariants.ts";
|
||||||
|
|
||||||
type ProviderModelAliasMap = Record<string, Record<string, string>>;
|
type ProviderModelAliasMap = Record<string, Record<string, string>>;
|
||||||
type ModelAliasValue = string | { provider?: string; model?: 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) {
|
function isTruthyEnv(value: string | undefined) {
|
||||||
return typeof value === "string" && /^(1|true|yes|on)$/i.test(value.trim());
|
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
|
const candidateProviders = getInferredProvidersForModel(modelId, activeSyncedProviders);
|
||||||
// `kiro` upstream briefly advertising `claude-opus-5` before it was
|
const { providers, excludedProviders } = await reconcileInferredProvidersWithActiveCatalog(
|
||||||
// vendored into the registry). Without this filter the bare-routing path
|
candidateProviders,
|
||||||
// would forward traffic to providers that 404 on the upstream call.
|
modelId
|
||||||
// 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) || [];
|
if (providers.length === 0 && excludedProviders.length > 0) {
|
||||||
const validatedSyncedProviders =
|
return {
|
||||||
staticCatalogProviders.length > 0
|
provider: null,
|
||||||
? activeSyncedProviders.filter((p) => staticCatalogProviders.includes(p))
|
model: modelId,
|
||||||
: activeSyncedProviders;
|
extendedContext,
|
||||||
const providers = getInferredProvidersForModel(modelId, validatedSyncedProviders);
|
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");
|
const nonOpenAIProviders = providers.filter((p) => p !== "openai");
|
||||||
|
|
||||||
// Bare model IDs from Codex CLI do not preserve OmniRoute's `cx/` prefix.
|
// 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));
|
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:
|
// Auto-pick:
|
||||||
// 1. If active providers match, pick from active candidates (first active provider).
|
// 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.
|
// 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,
|
createBuiltinAutoCombo,
|
||||||
isPaidTierAutoId,
|
isPaidTierAutoId,
|
||||||
} from "@omniroute/open-sse/services/autoCombo/builtinCatalog";
|
} 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 { getModelCatalogCacheVersion } from "@/lib/db/readCache";
|
||||||
import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels";
|
import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels";
|
||||||
import { providerUsesCuratedModelsOnly } from "@/lib/providers/modelListingCapability";
|
import { providerUsesCuratedModelsOnly } from "@/lib/providers/modelListingCapability";
|
||||||
@@ -676,7 +677,7 @@ async function buildUnifiedModelsResponseCore(
|
|||||||
|
|
||||||
let syncedModelsByProvider: Record<string, SyncedAvailableModel[]> = {};
|
let syncedModelsByProvider: Record<string, SyncedAvailableModel[]> = {};
|
||||||
try {
|
try {
|
||||||
syncedModelsByProvider = await getAllSyncedAvailableModels();
|
syncedModelsByProvider = await getAllActiveSyncedModels();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// DB unavailable — log and fall through; static models remain as defaults.
|
// DB unavailable — log and fall through; static models remain as defaults.
|
||||||
console.log("[catalog] Could not fetch synced available models:", e);
|
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,
|
getCustomModels,
|
||||||
} from "@/lib/localDb";
|
} from "@/lib/localDb";
|
||||||
import { getCachedSettings } from "@/lib/localDb";
|
import { getCachedSettings } from "@/lib/localDb";
|
||||||
import { getSyncedAvailableModels } from "@/lib/db/models";
|
import { getActiveSyncedCatalog } from "@/lib/db/models/activeSyncedCatalog";
|
||||||
import {
|
import {
|
||||||
parseModel,
|
parseModel,
|
||||||
getModelInfoCore,
|
getModelInfoCore,
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
stripContextWindowSuffix,
|
stripContextWindowSuffix,
|
||||||
} from "@omniroute/open-sse/services/model.ts";
|
} from "@omniroute/open-sse/services/model.ts";
|
||||||
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
|
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||||
|
import { getRegisteredProviderEffortBaseModelId } from "@omniroute/open-sse/utils/registeredEffortVariants.ts";
|
||||||
|
|
||||||
export { parseModel, stripContextWindowSuffix };
|
export { parseModel, stripContextWindowSuffix };
|
||||||
|
|
||||||
@@ -210,12 +211,18 @@ function buildRuntimeModelMeta(customMatch: any, syncedMatch: any): RuntimeModel
|
|||||||
async function lookupModelMeta(
|
async function lookupModelMeta(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
modelId: string
|
modelId: string
|
||||||
): Promise<{ modelId: string; metadata: RuntimeModelMeta }> {
|
): Promise<{
|
||||||
|
modelId: string;
|
||||||
|
metadata: RuntimeModelMeta;
|
||||||
|
available: boolean;
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
const [customModels, syncedModels] = await Promise.all([
|
const [customModels, liveCatalog] = await Promise.all([
|
||||||
getCustomModels(providerId),
|
getCustomModels(providerId),
|
||||||
getSyncedAvailableModels(providerId),
|
getActiveSyncedCatalog(providerId),
|
||||||
]);
|
]);
|
||||||
|
const syncedModels = liveCatalog.models;
|
||||||
|
|
||||||
// #7694: no direct match on the raw modelId? try a synced-declared `-{effort}`
|
// #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>`
|
// suffix before falling back to the literal id, so `<prefix>/<model>-<tier>`
|
||||||
// resolves to the real base model + a resolved effort.
|
// resolves to the real base model + a resolved effort.
|
||||||
@@ -224,15 +231,25 @@ async function lookupModelMeta(
|
|||||||
modelId,
|
modelId,
|
||||||
syncedModels
|
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 customMatch = findCustomModelMeta(customModels, resolvedModelId);
|
||||||
const syncedMatch = findSyncedModelMeta(syncedModels, 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);
|
const metadata = buildRuntimeModelMeta(customMatch, syncedMatch);
|
||||||
if (effort) metadata.resolvedThinkingEffort = effort;
|
if (effort) metadata.resolvedThinkingEffort = effort;
|
||||||
return { modelId: resolvedModelId, metadata };
|
|
||||||
|
return { modelId: resolvedModelId, metadata, available };
|
||||||
} catch {
|
} catch {
|
||||||
return { modelId, metadata: {} };
|
return { modelId, metadata: {}, available: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,8 +278,23 @@ export async function getModelInfo(modelStr) {
|
|||||||
|
|
||||||
const attachRuntimeModelMeta = async (info: any) => {
|
const attachRuntimeModelMeta = async (info: any) => {
|
||||||
if (!info?.provider || !info?.model) return info;
|
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;
|
const resolvedInfo = modelId !== info.model ? { ...info, model: modelId } : info;
|
||||||
|
|
||||||
return Object.keys(metadata).length > 0 ? { ...resolvedInfo, ...metadata } : resolvedInfo;
|
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.
|
// 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(
|
await replaceSyncedAvailableModelsForConnection(
|
||||||
providerId,
|
providerId,
|
||||||
connectionId,
|
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[3] as any).model, `${p2}/m-c`);
|
||||||
assert.equal(result.models[4], "openai/gpt-4o");
|
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