Files
OmniRoute/src/app/api/v1/models/catalog.ts

1604 lines
68 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
import { NOAUTH_PROVIDERS } from "@/shared/constants/providers";
import {
getCachedRawProviderConnections,
getCombos,
getAllCustomModels,
getSettings,
getCachedProviderNodes,
getModelIsHidden,
getModelAliases,
getDatabaseSettings,
} from "@/lib/localDb";
import { createLazyConnectionView } from "@/lib/db/providers/lazyConnectionView";
import { extractAliasBackedModels } from "./aliasBackedModels";
import { appendNoThinkingVariants } from "@omniroute/open-sse/utils/noThinkingAlias";
import { appendClaudeEffortVariants } from "@omniroute/open-sse/utils/claudeEffortVariants";
import { appendSyncedEffortVariants } from "@omniroute/open-sse/utils/syncedEffortVariants";
import { buildSyncedCapabilities, mergeSyncedCapabilities } from "./syncedCapabilities";
import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry";
import {
getAllImageModels,
isRegisteredImageModel,
} from "@omniroute/open-sse/config/imageRegistry";
import { getAllRerankModels } from "@omniroute/open-sse/config/rerankRegistry";
import { getAllAudioModels } from "@omniroute/open-sse/config/audioRegistry";
import { getAllModerationModels } from "@omniroute/open-sse/config/moderationRegistry";
import { getAllVideoModels } from "@omniroute/open-sse/config/videoRegistry";
import { getAllMusicModels } from "@omniroute/open-sse/config/musicRegistry";
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry";
import { CODEX_NATIVE_UNPREFIXED_MODELS } from "@omniroute/open-sse/services/model";
import { resolveNestedComboTargets } from "@omniroute/open-sse/services/combo";
import {
AUTO_TEMPLATE_VARIANTS,
AUTO_SUFFIX_VARIANTS,
AUTO_FAMILY_IDS,
createBuiltinAutoCombo,
isPaidTierAutoId,
} from "@omniroute/open-sse/services/autoCombo/builtinCatalog";
import { getAllSyncedAvailableModels, type SyncedAvailableModel } from "@/lib/db/models";
import { getModelCatalogCacheVersion } from "@/lib/db/readCache";
import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels";
import { providerUsesCuratedModelsOnly } from "@/lib/providers/modelListingCapability";
import { getOpenRouterCatalog } from "@/lib/catalog/openrouterCatalog";
import { hasEligibleConnectionForModel } from "@/domain/connectionModelRules";
import {
INTERNAL_PROXY_ERROR,
enrichCatalogModelEntry,
getCanonicalModelMetadata,
getCatalogDiagnosticsHeaders,
disambiguateCatalogModelNames,
} from "@/lib/modelMetadataRegistry";
import { getSyncedCapability } from "@/lib/modelsDevSync";
import { getModelSpec } from "@/shared/constants/modelSpecs";
import {
isModelCatalogNamesEnabled,
getModelsCatalogPrefixMode,
} from "@/shared/utils/featureFlags";
import { dedupeExactCatalogIds } from "./catalogDedupe";
import {
isNoAuthProviderBlocked,
isNoAuthProviderKey,
isNoAuthRawProviderPrefix,
normalizeBlockedProviderSet,
} from "@/shared/utils/noAuthProviders";
import { getTokenLimit } from "@omniroute/open-sse/services/contextManager";
import { extractApiKey } from "@/sse/services/auth";
import type { ComboModelStep } from "@/lib/combos/steps";
import {
type CustomModelEntry,
type ComboCatalogTarget,
type ComboTargetCatalogMetadata,
isPositiveFiniteNumber,
parseJsonStringArray,
intersectStringArrays,
minKnownNumber,
maybeOmitCatalogModelName,
getThinkingCapabilityFields,
mergeComboCapabilities,
} from "./catalogHelpers";
import {
qualifyOpenRouterModelId,
normalizeOpenRouterModalities,
getOpenRouterModelType,
isOpenRouterFreeModel,
getOpenRouterDisplayName,
} from "./catalogOpenrouter";
import { getVisionCapabilityFields, getCustomVisionCapabilityFields } from "./catalogVision";
import {
buildAliasMaps,
prefixRoutesToProvider,
resolveCanonicalProviderId as resolveCanonicalProviderIdFromMaps,
getProviderPrefixes as getProviderPrefixesFromMaps,
getComboTargetModelId as getComboTargetModelIdFromMaps,
} from "./catalogProviderMaps";
import { getModelCatalogAuthRejection, isCodexModelCatalogClient } from "./catalogRequest";
import { isFreeModel, providerHasFreeModels } from "@/shared/utils/freeModels";
import { isCodexDiscoveryModelExcluded } from "@/shared/services/codexDiscoveryPolicy";
// Public API of this module is preserved after the catalog helper extraction:
// `isVisionModelId` (vision-detection-consistency.test.ts) and
// `getCustomVisionCapabilityFields` (llm-selector-custom-vision-models.test.ts)
// are still importable from here.
export { isVisionModelId } from "@/shared/constants/visionModels";
export { getCustomVisionCapabilityFields };
// #6408 — Concurrent GET /v1/models requests serialized (~1.2s each × N). The
// per-request builder walks 8 registries + hits SQLite for connections, combos,
// custom models, and aliases; under Next.js single-threaded App Router request
// handling, N concurrent calls execute back-to-back and the Nth completes
// N × single-request latency (linear staircase reproduced in the issue).
//
// Fix: coalesce identical concurrent requests onto a single in-flight promise,
// then memoize the serialized body for a short window so a burst (SDK startup,
// multi-tab dashboard poll) returns from cache. Auth-rejection paths are NOT
// cached (they depend on live session state — dashboard cookies, API key).
type CachedCatalog = {
body: string;
headers: Record<string, string>;
status: number;
expiresAt: number;
};
const CATALOG_CACHE_TTL_MS_DEFAULT = 1500; // fallback; overridden by settings
const catalogCache = new Map<string, CachedCatalog>();
const catalogInFlight = new Map<string, Promise<CachedCatalog>>();
// Test hook — increments each time the full catalog builder runs. Used by
// tests/unit/v1-models-concurrent-6408.test.ts to prove concurrent requests
// share one execution. Not part of the public API; do not read from app code.
let _catalogBuilderRuns = 0;
export function __resetCatalogBuilderRunsForTest(): void {
_catalogBuilderRuns = 0;
catalogCache.clear();
catalogInFlight.clear();
lastSeenCatalogCacheVersion = getModelCatalogCacheVersion();
}
export function __getCatalogBuilderRunsForTest(): number {
return _catalogBuilderRuns;
}
function buildCatalogCacheKey(request: Request): string {
const url = new URL(request.url);
const prefix = url.searchParams.get("prefix") || "";
const apiKey = extractApiKey(request) || "";
const isCodex = isCodexModelCatalogClient(request) ? "1" : "0";
const configuredOnly = url.searchParams.get("configuredOnly") === "true" ? "1" : "0";
return `${prefix}|${isCodex}|${apiKey}|${configuredOnly}`;
}
// Tracks the model-catalog cache version (src/lib/db/readCache.ts) as of the last
// cache access. invalidateDbCache() bumps that version on every settings/connections/
// combos/pricing write; when it moves on, every memoized entry here was built from
// state that no longer holds, so drop them all rather than keying by version (which
// would leak one Map entry per version forever instead of ever pruning old ones).
let lastSeenCatalogCacheVersion = getModelCatalogCacheVersion();
function dropCatalogCacheIfStateChanged(): void {
const currentVersion = getModelCatalogCacheVersion();
if (currentVersion === lastSeenCatalogCacheVersion) return;
lastSeenCatalogCacheVersion = currentVersion;
catalogCache.clear();
// Deliberately NOT clearing catalogInFlight: an in-flight build already reads live
// DB/settings state as of when it started, so letting it finish and populate the
// (now-current) cache entry is correct — clearing it would just force a redundant
// second builder run for requests that arrive mid-flight.
}
// Header sources here mix Title-Case keys (diagnosticHeaders, corsHeaders — plain
// objects built by app code) with lower-case keys (payload/cached.headers — captured
// via the Fetch `Headers` iterator, which always yields lower-cased names). Merging
// those with a plain object spread leaves both casings present as distinct object
// keys; the `Response` constructor then treats them as the same case-insensitive
// header and *appends* rather than overwrites, producing a comma-joined duplicate
// (e.g. request-id echoing "foo, foo"). Merge through a real `Headers` instance
// instead so `.set()` overwrites case-insensitively. Sources listed earlier are the
// base (cached/freshly-built payload headers); `diagnosticHeaders` is applied last so
// per-request fields (e.g. X-Request-Id) always reflect the *current* request rather
// than whichever request happened to populate the cache entry.
function mergeCatalogHeaders(...sources: Array<Record<string, string> | undefined>): Headers {
const merged = new Headers();
for (const source of sources) {
if (!source) continue;
for (const [key, value] of Object.entries(source)) {
merged.set(key, value);
}
}
return merged;
}
/**
* Build unified OpenAI-compatible model catalog response.
* Reused by `/api/v1/models` and `/api/v1` to avoid semantic drift (T09).
*/
export async function getUnifiedModelsResponse(
request: Request,
corsHeaders: Record<string, string> = {}
) {
const diagnosticHeaders = getCatalogDiagnosticsHeaders({ request });
// #6408 fast path: reject unauthorized callers first (auth state is per-request
// and MUST NOT be cached), then coalesce identical concurrent requests + short-
// TTL memoize the serialized JSON body.
try {
let settingsForAuth: Record<string, any> = {};
try {
settingsForAuth = await getSettings();
} catch {}
const authRejection = await getModelCatalogAuthRejection(request, settingsForAuth, {
...corsHeaders,
...diagnosticHeaders,
});
if (authRejection) return authRejection;
} catch {
// Fall through to full builder on auth-check failure; core handles errors.
}
dropCatalogCacheIfStateChanged();
const cacheKey = buildCatalogCacheKey(request);
const cached = catalogCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return new Response(cached.body, {
status: cached.status,
headers: mergeCatalogHeaders(corsHeaders, cached.headers, diagnosticHeaders),
});
}
let inflight = catalogInFlight.get(cacheKey);
if (!inflight) {
inflight = buildCatalogPayload(request).then((payload) => {
catalogCache.set(cacheKey, {
body: payload.body,
headers: payload.headers,
status: payload.status,
expiresAt: Date.now() + payload.cacheTTL,
});
return payload;
});
catalogInFlight.set(cacheKey, inflight);
inflight.finally(() => {
if (catalogInFlight.get(cacheKey) === inflight) catalogInFlight.delete(cacheKey);
});
}
try {
const payload = await inflight;
return new Response(payload.body, {
status: payload.status,
headers: mergeCatalogHeaders(corsHeaders, payload.headers, diagnosticHeaders),
});
} catch (err) {
return Response.json(
{
error: {
message: err instanceof Error ? err.message : String(err),
type: "server_error",
code: INTERNAL_PROXY_ERROR,
},
},
{ status: 500, headers: { ...corsHeaders, ...diagnosticHeaders } }
);
}
}
async function buildCatalogPayload(
request: Request
): Promise<{ body: string; headers: Record<string, string>; status: number; cacheTTL: number }> {
_catalogBuilderRuns++;
const built = await buildUnifiedModelsResponseCore(request);
const body = await built.text();
const headers: Record<string, string> = {};
built.headers.forEach((value, key) => {
headers[key] = value;
});
// Read the configurable cache TTL from database settings.
// Falls back to the hardcoded default if not set or on error.
let cacheTTL = CATALOG_CACHE_TTL_MS_DEFAULT;
try {
const dbSettings = await getDatabaseSettings();
cacheTTL = dbSettings.cache?.modelCatalogCacheTtlMs ?? CATALOG_CACHE_TTL_MS_DEFAULT;
} catch {
// Swallow — use default TTL on DB error
}
return { body, headers, status: built.status, cacheTTL };
}
/**
* Original catalog builder. Runs once per unique cache key per TTL window.
*/
async function buildUnifiedModelsResponseCore(
request: Request,
corsHeaders: Record<string, string> = {}
) {
const diagnosticHeaders = getCatalogDiagnosticsHeaders({ request });
try {
let settings: Record<string, any> = {};
try {
settings = await getSettings();
} catch {}
const authRejection = await getModelCatalogAuthRejection(request, settings, {
...corsHeaders,
...diagnosticHeaders,
});
if (authRejection) return authRejection;
const { aliasToProviderId, providerIdToAlias } = buildAliasMaps();
const _qp = new URL(request.url).searchParams.get("prefix");
const prefixMode =
_qp === "alias" || _qp === "canonical" || _qp === "dual" ? _qp : getModelsCatalogPrefixMode();
const includeAlias = prefixMode !== "canonical";
const includeCanonical = prefixMode !== "alias";
const resolveCanonicalProviderId = (aliasOrProviderId: string, fallbackProviderId?: string) =>
resolveCanonicalProviderIdFromMaps(aliasToProviderId, aliasOrProviderId, fallbackProviderId);
const aliasMaps = { aliasToProviderId, providerIdToAlias };
// Issue #96: Allow blocking specific providers from the models list
const blockedProviders = normalizeBlockedProviderSet(settings.blockedProviders);
// #6316: Opt-in filter — hide paid-only models via `isFreeModel()`. Only applied to
// PROVIDER_MODELS + OpenRouter loops (where pricing metadata / :free suffix / catalog
// membership is available). Modality registries (embedding/image/rerank/audio/
// moderation/video/music) represent local capabilities without pricing, so they are
// exempt. Combos + auto/* + synced/custom/alias-backed rows also stay unfiltered —
// extending v1 scope to those requires per-entry pricing lookup not available today.
const hidePaid = settings.hidePaidModels === true;
const shouldHidePaid = (providerKey: string, modelId: string, pricing?: unknown): boolean => {
if (!hidePaid) return false;
const provider = aliasToProviderId[providerKey] || providerKey;
if (!providerHasFreeModels(provider)) return true;
return !isFreeModel(provider, { id: modelId, pricing: pricing as any });
};
// Get active provider connections
let connections = [];
let totalConnectionCount = 0; // Track if DB has ANY connections (even disabled)
try {
connections = (await getCachedRawProviderConnections()).map(createLazyConnectionView);
totalConnectionCount = connections.length;
// Filter to only active connections
connections = connections.filter((c) => c.isActive !== false);
} catch (e) {
// If database not available, show no provider models (safe default)
console.log("[catalog] Could not fetch providers:", e);
}
// Get provider nodes (for compatible providers with custom prefixes)
let providerNodes = [];
try {
providerNodes = await getCachedProviderNodes();
} catch (e) {
console.log("Could not fetch provider nodes");
}
// Build map of provider node ID to prefix and type for compatible providers
const providerIdToPrefix: Record<string, string> = {};
const nodeIdToProviderType: Record<string, string> = {};
for (const node of providerNodes) {
if (node.prefix) {
providerIdToPrefix[node.id] = node.prefix;
}
if (node.type) {
nodeIdToProviderType[node.id] = node.type;
}
}
// #8327: `resolveCanonicalProviderId`/`canonicalProviderId` only know the static
// AI_PROVIDERS/PROVIDER_MODELS alias maps, so a compatible-provider node (whose raw
// `id` is an internal UUID, never present in those static maps) falls through every
// lookup and returns the raw UUID verbatim. That UUID is still required for the
// internal registry/connection/hidden-model lookups that key off `canonicalProviderId`
// (getConnectionsForProvider, getModelIsHidden, etc. are keyed by the raw node id, not
// the prefix) — so `canonicalProviderId` itself must stay untouched. What must NOT leak
// is the raw UUID in the *public* `owned_by` field: resolve it to the operator's
// configured prefix there, and only there.
const resolvePublicOwnerId = (providerId: string, canonicalProviderId: string): string =>
providerIdToPrefix[providerId] || canonicalProviderId;
// Get combos
let combos = [];
try {
combos = await getCombos();
} catch (e) {
console.log("Could not fetch combos");
}
// Build set of active provider aliases
const activeAliases = new Set();
const connectionsByProvider = new Map<string, typeof connections>();
const registerConnectionKey = (
key: string | null | undefined,
connection: (typeof connections)[number]
) => {
if (!key) return;
const existing = connectionsByProvider.get(key) || [];
existing.push(connection);
connectionsByProvider.set(key, existing);
};
for (const conn of connections) {
const alias = providerIdToAlias[conn.provider] || conn.provider;
activeAliases.add(alias);
activeAliases.add(conn.provider);
registerConnectionKey(alias, conn);
registerConnectionKey(conn.provider, conn);
}
// noAuth providers have no DB rows; settings.blockedProviders disables them.
for (const p of Object.values(NOAUTH_PROVIDERS)) {
if (isNoAuthProviderBlocked(blockedProviders, p.id, "alias" in p ? p.alias : null)) continue;
activeAliases.add(p.id);
if ("alias" in p && typeof p.alias === "string") activeAliases.add(p.alias);
}
const getConnectionsForProvider = (...keys: Array<string | null | undefined>) => {
const seen = new Set<string>();
const collected: typeof connections = [];
for (const key of keys) {
if (!key) continue;
for (const connection of connectionsByProvider.get(key) || []) {
if (!connection?.id || seen.has(connection.id)) continue;
seen.add(connection.id);
collected.push(connection);
}
}
return collected;
};
const providerSupportsModel = (providerKey: string, modelId: string) => {
const providerId = aliasToProviderId[providerKey] || providerKey;
const alias = providerIdToAlias[providerId] || providerKey;
// noAuth providers have no connection rows — treat every model as eligible. (#2798)
const isNoAuth = isNoAuthProviderKey(providerId, providerKey, alias);
if (isNoAuth && !isNoAuthProviderBlocked(blockedProviders, providerId, providerKey, alias))
return true;
return hasEligibleConnectionForModel(
getConnectionsForProvider(providerKey, providerId, alias),
modelId
);
};
const getRegistryModel = (providerId: string, modelId: string) => {
const alias = providerIdToAlias[providerId] || PROVIDER_ID_TO_ALIAS[providerId] || providerId;
const providerModels = PROVIDER_MODELS[alias] || PROVIDER_MODELS[providerId] || [];
return providerModels.find((model) => model?.id === modelId) || null;
};
// prefixRoutesToProvider is imported directly from catalogProviderMaps.ts (no
// map dependency — pure parseModel() probe), used both here and at the two
// includeCanonical prefix-collision checks below.
const getProviderPrefixes = (providerId: string, rawProvider: string) =>
getProviderPrefixesFromMaps(aliasMaps, providerId, rawProvider);
const getComboTargetModelId = (target: ComboCatalogTarget) =>
getComboTargetModelIdFromMaps(aliasMaps, target);
const getComboTargetCatalogMetadata = (
target: ComboCatalogTarget
): ComboTargetCatalogMetadata | null => {
const targetModel = getComboTargetModelId(target);
if (!targetModel) return null;
const canonical = getCanonicalModelMetadata({
provider: targetModel.providerId,
model: targetModel.modelId,
});
if (!canonical) return null;
const source = canonical.metadata.source;
if (!source.providerRegistry && !source.staticSpec && !source.syncedCapability) return null;
const providerId = canonical.provider || targetModel.providerId;
const modelId = canonical.model || targetModel.modelId;
const synced = getSyncedCapability(providerId, modelId);
const spec = getModelSpec(modelId);
const registryModel = getRegistryModel(providerId, modelId);
const syncedInputModalities = parseJsonStringArray(synced?.modalities_input);
const syncedOutputModalities = parseJsonStringArray(synced?.modalities_output);
const syncedContext = isPositiveFiniteNumber(synced?.limit_context)
? synced.limit_context
: undefined;
const registryContext = isPositiveFiniteNumber(registryModel?.contextLength)
? registryModel.contextLength
: undefined;
const specContext = isPositiveFiniteNumber(spec?.contextWindow)
? spec.contextWindow
: undefined;
const contextLength =
syncedContext ??
registryContext ??
specContext ??
(getTokenLimit(providerId, modelId) || undefined);
const registryInputLimit = isPositiveFiniteNumber(registryModel?.maxInputTokens)
? registryModel.maxInputTokens
: undefined;
const syncedInputLimit = isPositiveFiniteNumber(synced?.limit_input)
? synced.limit_input
: undefined;
const maxInputTokens = registryInputLimit ?? syncedInputLimit ?? contextLength;
const maxOutputTokens = isPositiveFiniteNumber(synced?.limit_output)
? synced.limit_output
: isPositiveFiniteNumber(spec?.maxOutputTokens)
? spec.maxOutputTokens
: undefined;
const syncedVision =
typeof synced?.attachment === "boolean"
? synced.attachment
: syncedInputModalities.length > 0 || syncedOutputModalities.length > 0
? [...syncedInputModalities, ...syncedOutputModalities].some((entry) =>
// eslint-disable-next-line no-restricted-syntax -- teknik string kontrolü, kullanıcı metni araması değil
entry.toLowerCase().includes("image")
)
: undefined;
const registryVision =
typeof registryModel?.supportsVision === "boolean"
? registryModel.supportsVision
: undefined;
const specVision =
typeof spec?.supportsVision === "boolean" ? spec.supportsVision : undefined;
const knownVision = syncedVision ?? registryVision ?? specVision;
const inputModalities =
syncedInputModalities.length > 0
? syncedInputModalities
: knownVision === true
? ["text", "image"]
: undefined;
const outputModalities =
syncedOutputModalities.length > 0
? syncedOutputModalities
: knownVision === true
? ["text"]
: undefined;
const capabilities: Record<string, boolean | string[]> = {};
capabilities.tool_calling = canonical.capabilities.toolCalling;
capabilities.reasoning = canonical.capabilities.reasoning;
if (typeof canonical.capabilities.vision === "boolean") {
capabilities.vision = canonical.capabilities.vision;
}
if (typeof canonical.capabilities.attachment === "boolean") {
capabilities.attachment = canonical.capabilities.attachment;
}
if (typeof canonical.capabilities.structuredOutput === "boolean") {
capabilities.structured_output = canonical.capabilities.structuredOutput;
}
if (typeof canonical.capabilities.temperature === "boolean") {
capabilities.temperature = canonical.capabilities.temperature;
}
Object.assign(
capabilities,
getThinkingCapabilityFields(providerId, modelId, canonical.capabilities.supportsThinking)
);
return {
...(contextLength ? { contextLength } : {}),
...(maxInputTokens ? { maxInputTokens } : {}),
...(maxOutputTokens ? { maxOutputTokens } : {}),
...(inputModalities && inputModalities.length > 0 ? { inputModalities } : {}),
...(outputModalities && outputModalities.length > 0 ? { outputModalities } : {}),
capabilities,
};
};
const buildComboCatalogMetadata = (
combo: Parameters<typeof resolveNestedComboTargets>[0],
allCombos: Parameters<typeof resolveNestedComboTargets>[1]
) => {
const explicitContextLength = isPositiveFiniteNumber(combo.context_length)
? combo.context_length
: undefined;
const baseMetadata = explicitContextLength ? { context_length: explicitContextLength } : {};
const targets = resolveNestedComboTargets(combo, allCombos) as ComboCatalogTarget[];
if (targets.length === 0) return baseMetadata;
const targetMetadata = targets.map((target) => getComboTargetCatalogMetadata(target));
const knownMetadata = targetMetadata.filter(
(metadata): metadata is ComboTargetCatalogMetadata => metadata !== null
);
if (knownMetadata.length === 0) return baseMetadata;
const contextLength =
explicitContextLength ??
minKnownNumber(knownMetadata.map((metadata) => metadata.contextLength));
const maxInputTokens = minKnownNumber(
knownMetadata.map((metadata) => metadata.maxInputTokens)
);
const maxOutputTokens = minKnownNumber(
knownMetadata.map((metadata) => metadata.maxOutputTokens)
);
const inputModalities = knownMetadata.every(
(metadata) => Array.isArray(metadata.inputModalities) && metadata.inputModalities.length > 0
)
? intersectStringArrays(knownMetadata.map((metadata) => metadata.inputModalities || []))
: [];
const outputModalities = knownMetadata.every(
(metadata) =>
Array.isArray(metadata.outputModalities) && metadata.outputModalities.length > 0
)
? intersectStringArrays(knownMetadata.map((metadata) => metadata.outputModalities || []))
: [];
const capabilities = mergeComboCapabilities(knownMetadata);
return {
...baseMetadata,
...(contextLength ? { context_length: contextLength } : {}),
...(maxInputTokens ? { max_input_tokens: maxInputTokens } : {}),
...(maxOutputTokens ? { max_output_tokens: maxOutputTokens } : {}),
...(inputModalities.length > 0 ? { input_modalities: inputModalities } : {}),
...(outputModalities.length > 0 ? { output_modalities: outputModalities } : {}),
...(Object.keys(capabilities).length > 0 ? { capabilities } : {}),
};
};
// Collect models from active providers (or all if none active)
const models = [];
const timestamp = Math.floor(Date.now() / 1000);
const listedIds = new Set<string>();
// #4164: advertise the built-in zero-setup `auto/*` combos at the very top.
// #4189: enrich each with the combo's advertised context/output limits (computed
// by createBuiltinAutoCombo from its candidate pool) + baseline capabilities, so
// OpenAI-compatible clients that build their picker from /v1/models (e.g. Hermes)
// receive token metadata before the first request instead of a bare entry. If the
// combo cannot be materialized (e.g. no eligible connections yet) the minimal
// #4164 entry is emitted instead, so the id is never dropped.
// #4235 Phase B: also advertise the curated `auto/<category>[:<tier>]` combos.
// #6453: also advertise the `auto/<family>` combos (auto/glm, auto/minimax, ...).
for (const autoId of [
...Object.keys(AUTO_TEMPLATE_VARIANTS),
...AUTO_SUFFIX_VARIANTS,
...AUTO_FAMILY_IDS,
]) {
if (blockedProviders.has("auto") || listedIds.has(autoId)) continue; // #5192
// #6328 (follow-up to #6495 / #6512): REMOVE — not just hide — paid-tier
// auto/* ids (auto/pro-* + auto/*:pro) from the advertised catalog when the
// operator opts into hidePaidModels. The candidate-pool filter in
// virtualFactory (#6512) still gates request-time routing for the rest.
if (hidePaid && isPaidTierAutoId(autoId)) continue;
listedIds.add(autoId);
const baseAutoEntry = {
id: autoId,
object: "model",
created: timestamp,
owned_by: "combo",
permission: [],
root: autoId,
parent: null,
};
try {
const suffix = autoId.replace(/^auto\/?/, "");
const virtualCombo = await createBuiltinAutoCombo(autoId, suffix);
const contextLength = virtualCombo.advertisedContextLength || 128000;
const maxOutputTokens = virtualCombo.advertisedMaxOutputTokens || 8192;
models.push({
...baseAutoEntry,
context_length: contextLength,
max_input_tokens: contextLength,
max_output_tokens: maxOutputTokens,
capabilities: {
tool_calling: true,
reasoning: true,
thinking: true,
temperature: true,
},
});
} catch (err) {
console.log(`[catalog] Could not materialize built-in auto model ${autoId}:`, err);
models.push(baseAutoEntry);
}
}
// Add combos first (they appear at the top) — only active ones
for (const combo of combos) {
if (combo.isActive === false || combo.isHidden === true) continue;
if (typeof combo.name !== "string" || combo.name.length === 0) continue;
if (listedIds.has(combo.name)) continue; // #4164: don't shadow a built-in auto/* id
// Skip combos whose any underlying target model is hidden
const comboTargets = resolveNestedComboTargets(
combo as Parameters<typeof resolveNestedComboTargets>[0],
combos as Parameters<typeof resolveNestedComboTargets>[1]
) as ComboCatalogTarget[];
if (
comboTargets.some((target) => {
const resolved = getComboTargetModelId(target);
return resolved ? getModelIsHidden(resolved.providerId, resolved.modelId) : false;
})
) {
continue;
}
const comboMetadata = buildComboCatalogMetadata(combo, combos);
listedIds.add(combo.name);
models.push({
id: combo.name,
object: "model",
created: timestamp,
owned_by: "combo",
permission: [],
root: combo.name,
parent: null,
...comboMetadata,
});
}
let syncedModelsByProvider: Record<string, SyncedAvailableModel[]> = {};
try {
syncedModelsByProvider = await getAllSyncedAvailableModels();
} catch (e) {
// DB unavailable — log and fall through; static models remain as defaults.
console.log("[catalog] Could not fetch synced available models:", e);
}
const providersWithSyncedModels = new Set(
Object.keys(syncedModelsByProvider).filter((pid) => {
if (providerUsesCuratedModelsOnly(pid)) return false;
const models = syncedModelsByProvider[pid];
return Array.isArray(models) && models.length > 0;
})
);
// Add provider models (chat)
for (const [alias, providerModels] of Object.entries(PROVIDER_MODELS)) {
const providerId = aliasToProviderId[alias] || alias;
const canonicalProviderId = resolveCanonicalProviderId(alias, providerId);
if (
isNoAuthProviderBlocked(blockedProviders, canonicalProviderId, alias) ||
blockedProviders.has(alias) ||
blockedProviders.has(canonicalProviderId)
)
continue;
if (isNoAuthRawProviderPrefix(canonicalProviderId, alias)) continue;
if (!activeAliases.has(alias) && !activeAliases.has(canonicalProviderId)) {
continue;
}
if (providersWithSyncedModels.has(canonicalProviderId)) continue;
for (const model of providerModels) {
if (!providerSupportsModel(canonicalProviderId, model.id)) continue;
const aliasId = `${alias}/${model.id}`;
if (getModelIsHidden(canonicalProviderId, model.id)) continue;
if (shouldHidePaid(canonicalProviderId, model.id, (model as { pricing?: unknown }).pricing))
continue;
const visionFields =
getVisionCapabilityFields(aliasId) || getVisionCapabilityFields(model.id);
if (includeAlias) {
models.push({
id: aliasId,
object: "model",
created: timestamp,
owned_by: canonicalProviderId,
permission: [],
root: model.id,
parent: null,
...(visionFields || {}),
});
}
if (
includeCanonical &&
canonicalProviderId !== alias &&
!isNoAuthProviderKey(canonicalProviderId) &&
prefixRoutesToProvider(canonicalProviderId, canonicalProviderId)
) {
const providerIdModel = `${canonicalProviderId}/${model.id}`;
const providerVisionFields =
getVisionCapabilityFields(providerIdModel) || getVisionCapabilityFields(model.id);
models.push({
id: providerIdModel,
object: "model",
created: timestamp,
owned_by: canonicalProviderId,
permission: [],
root: model.id,
parent: includeAlias ? aliasId : null,
...(providerVisionFields || {}),
});
}
}
}
for (const modelId of CODEX_NATIVE_UNPREFIXED_MODELS) {
if (!providerSupportsModel("codex", modelId)) continue;
if (getModelIsHidden("codex", modelId)) continue;
const alias = providerIdToAlias.codex || "cx";
const aliasId = `${alias}/${modelId}`;
const providerIdModel = `codex/${modelId}`;
const entries = [
{ id: aliasId, parent: null },
{ id: providerIdModel, parent: aliasId },
{ id: modelId, parent: providerIdModel },
];
for (const entry of entries) {
if (models.some((existingModel) => existingModel.id === entry.id)) continue;
models.push({
id: entry.id,
object: "model",
created: timestamp,
owned_by: "codex",
permission: [],
root: modelId,
parent: entry.parent,
});
}
}
try {
for (const [providerId, syncedModels] of Object.entries(syncedModelsByProvider)) {
if (providerUsesCuratedModelsOnly(providerId)) continue;
if (!Array.isArray(syncedModels) || syncedModels.length === 0) continue;
if (blockedProviders.has(providerId)) continue;
if (providerId === "reka") continue;
const prefix = providerIdToPrefix[providerId];
const alias = prefix || providerIdToAlias[providerId] || providerId;
const canonicalProviderId = resolveCanonicalProviderId(alias, providerId);
const parentProviderType = nodeIdToProviderType[providerId];
if (
!activeAliases.has(alias) &&
!activeAliases.has(canonicalProviderId) &&
!activeAliases.has(providerId) &&
!(parentProviderType && activeAliases.has(parentProviderType))
) {
continue;
}
for (const sm of syncedModels) {
if (!providerSupportsModel(canonicalProviderId, sm.id)) continue;
if (canonicalProviderId === "codex" && isCodexDiscoveryModelExcluded(sm)) {
continue;
}
if (getModelIsHidden(providerId, sm.id)) continue;
// #6457: some upstream discovery catalogs (e.g. HuggingFace's live
// `/v1/models`) return image/diffusion models with no modality info,
// so `endpoints` below would default to ["chat"] and misrepresent
// them as chat-capable. Skip a registered image model only when its
// synced metadata does not explicitly advertise a chat endpoint.
// Multi-capability models may intentionally share an id between the
// chat and image catalogs; getAllImageModels() adds the image entry.
const explicitlySupportsChat = sm.supportedEndpoints?.some(
(endpoint) => endpoint === "chat" || endpoint === "responses"
);
if (
!explicitlySupportsChat &&
(isRegisteredImageModel(canonicalProviderId, sm.id) ||
isRegisteredImageModel(providerId, sm.id))
) {
continue;
}
// #6328: apply hidePaidModels to synced provider rows too. Synced rows
// rarely carry pricing metadata, so shouldHidePaid() falls through to
// the FREE_MODEL_IDS_BY_PROVIDER catalog — providers with a curated
// free roster show only those; providers with none fall through to
// hide-all via providerHasFreeModels() === false.
if (shouldHidePaid(canonicalProviderId, sm.id, (sm as { pricing?: unknown }).pricing))
continue;
const registryEntry = REGISTRY[providerId];
const displayModelId =
registryEntry?.modelIdPrefix && sm.id.startsWith(registryEntry.modelIdPrefix)
? sm.id.slice(registryEntry.modelIdPrefix.length)
: sm.id;
const aliasId = `${alias}/${displayModelId}`;
const endpoints = Array.isArray(sm.supportedEndpoints) ? sm.supportedEndpoints : ["chat"];
const apiFormat = typeof sm.apiFormat === "string" ? sm.apiFormat : "chat-completions";
let modelType: string | undefined;
if (endpoints.includes("embeddings")) modelType = "embedding";
else if (endpoints.includes("rerank")) modelType = "rerank";
else if (endpoints.includes("images")) modelType = "image";
else if (endpoints.includes("audio")) modelType = "audio";
const syncedFields = {
...(modelType ? { type: modelType } : {}),
...(apiFormat !== "chat-completions" ? { api_format: apiFormat } : {}),
...(modelType === "audio" ? { subtype: "transcription" } : {}),
...(sm.inputTokenLimit ? { context_length: sm.inputTokenLimit } : {}),
...(typeof sm.outputTokenLimit === "number"
? { max_output_tokens: sm.outputTokenLimit }
: {}),
...(endpoints.length > 1 || !endpoints.includes("chat")
? { supported_endpoints: endpoints }
: {}),
// #4264/#7694: vision + reasoning-effort-tier flags captured at sync time,
// merged into a single capabilities object (see ./syncedCapabilities.ts).
...(buildSyncedCapabilities(sm) ? { capabilities: buildSyncedCapabilities(sm) } : {}),
};
const existingAliasModel = models.find((model) => model.id === aliasId);
if (existingAliasModel) {
const mergedCapabilities = mergeSyncedCapabilities(existingAliasModel.capabilities, sm);
Object.assign(existingAliasModel, syncedFields);
if (mergedCapabilities) existingAliasModel.capabilities = mergedCapabilities;
continue;
}
if (includeAlias) {
models.push({
id: aliasId,
object: "model",
created: timestamp,
owned_by: resolvePublicOwnerId(providerId, canonicalProviderId),
permission: [],
root: sm.id,
parent: null,
...syncedFields,
});
}
if (includeAlias && modelType === "audio") {
models.push({
id: aliasId,
object: "model",
created: timestamp,
owned_by: resolvePublicOwnerId(providerId, canonicalProviderId),
permission: [],
root: sm.id,
parent: null,
type: "audio",
subtype: "speech",
...(sm.inputTokenLimit ? { context_length: sm.inputTokenLimit } : {}),
...(typeof sm.outputTokenLimit === "number"
? { max_output_tokens: sm.outputTokenLimit }
: {}),
...(endpoints.length > 1 || !endpoints.includes("chat")
? { supported_endpoints: endpoints }
: {}),
});
}
if (includeCanonical && canonicalProviderId !== alias && !prefix) {
const providerPrefixedId = `${canonicalProviderId}/${displayModelId}`;
if (!models.some((model) => model.id === providerPrefixedId)) {
models.push({
id: providerPrefixedId,
object: "model",
created: timestamp,
owned_by: resolvePublicOwnerId(providerId, canonicalProviderId),
permission: [],
root: sm.id,
parent: includeAlias ? aliasId : null,
...syncedFields,
});
}
}
}
}
} catch (err) {
console.error("[catalog] Error fetching synced provider models:", err);
}
if (
activeAliases.has("openrouter") &&
!blockedProviders.has("openrouter") &&
!providersWithSyncedModels.has("openrouter")
) {
try {
const openRouterCatalog = await getOpenRouterCatalog();
for (const openRouterModel of openRouterCatalog.data || []) {
if (!openRouterModel?.id || typeof openRouterModel.id !== "string") continue;
const qualifiedId = qualifyOpenRouterModelId(openRouterModel.id);
if (models.some((existingModel: any) => existingModel?.id === qualifiedId)) continue;
const inputModalities = normalizeOpenRouterModalities(
openRouterModel.architecture?.input_modalities
);
const outputModalities = normalizeOpenRouterModalities(
openRouterModel.architecture?.output_modalities
);
const modelType = getOpenRouterModelType(inputModalities, outputModalities);
const isFree = isOpenRouterFreeModel(openRouterModel);
if (hidePaid && !isFree) continue;
const supportedParameters = Array.isArray(openRouterModel.supported_parameters)
? openRouterModel.supported_parameters
: [];
const capabilities: Record<string, boolean> = {};
if (inputModalities.includes("image")) capabilities.vision = true;
if (
supportedParameters.includes("reasoning") ||
supportedParameters.includes("include_reasoning")
) {
capabilities.reasoning = true;
}
if (supportedParameters.includes("tools")) capabilities.tool_calling = true;
if (
supportedParameters.includes("structured_outputs") ||
supportedParameters.includes("response_format")
) {
capabilities.structured_output = true;
}
models.push({
id: qualifiedId,
object: "model",
created: openRouterModel.created || timestamp,
owned_by: "openrouter",
permission: [],
root: openRouterModel.id,
parent: null,
name: getOpenRouterDisplayName(openRouterModel),
type: modelType,
...(isFree ? { free: true } : {}),
...(typeof openRouterModel.context_length === "number"
? { context_length: openRouterModel.context_length }
: {}),
...(typeof openRouterModel.top_provider?.max_completion_tokens === "number"
? { max_output_tokens: openRouterModel.top_provider.max_completion_tokens }
: {}),
...(inputModalities.length > 0 ? { input_modalities: inputModalities } : {}),
...(outputModalities.length > 0 ? { output_modalities: outputModalities } : {}),
...(Object.keys(capabilities).length > 0 ? { capabilities } : {}),
});
}
} catch (err) {
console.error("[catalog] Error loading OpenRouter catalog:", err);
}
}
// Helper: check if a provider is active (by provider id or alias)
const isProviderActive = (provider: string) => {
if (activeAliases.size === 0) return false; // No active connections = show nothing
const alias = providerIdToAlias[provider] || provider;
const canonicalProviderId = resolveCanonicalProviderId(alias, provider);
// FIX #1752: Ensure blocked providers are not returned for non-chat models
if (
blockedProviders.has(alias) ||
blockedProviders.has(canonicalProviderId) ||
blockedProviders.has(provider)
) {
return false;
}
return activeAliases.has(alias) || activeAliases.has(provider);
};
const hasEquivalentSpecialtyModel = (
providerId: string,
rawModelId: string,
type: string,
scopedModelId: string
) =>
models.some((model: any) => {
if (model?.id === scopedModelId) return true;
if (model?.owned_by !== providerId || model?.type !== type) return false;
const existingRoot =
typeof model?.root === "string"
? model.root
: typeof model?.id === "string"
? model.id.split("/").pop()
: null;
return existingRoot === rawModelId;
});
// Add embedding models (filtered by active providers)
for (const embModel of getAllEmbeddingModels()) {
if (!isProviderActive(embModel.provider)) continue;
const rawModelId = embModel.id.startsWith(`${embModel.provider}/`)
? embModel.id.slice(embModel.provider.length + 1)
: embModel.id;
if (!providerSupportsModel(embModel.provider, rawModelId)) continue;
if (getModelIsHidden(embModel.provider, rawModelId)) continue;
if (hasEquivalentSpecialtyModel(embModel.provider, rawModelId, "embedding", embModel.id)) {
continue;
}
models.push({
id: embModel.id,
object: "model",
created: timestamp,
owned_by: embModel.provider,
root: rawModelId,
type: "embedding",
dimensions: embModel.dimensions,
});
}
// Add image models (filtered by active providers)
for (const imgModel of getAllImageModels()) {
if (!isProviderActive(imgModel.provider)) continue;
const rawModelId = imgModel.id.split("/").pop() || imgModel.id;
if (!providerSupportsModel(imgModel.provider, rawModelId)) continue;
if (getModelIsHidden(imgModel.provider, rawModelId)) continue;
models.push({
id: imgModel.id,
object: "model",
created: timestamp,
owned_by: imgModel.provider,
type: "image",
supported_sizes: imgModel.supportedSizes,
input_modalities: imgModel.inputModalities || ["text"],
output_modalities: ["image"],
...(imgModel.description ? { description: imgModel.description } : {}),
});
}
// Add rerank models (filtered by active providers)
for (const rerankModel of getAllRerankModels()) {
if (!isProviderActive(rerankModel.provider)) continue;
const rawModelId = rerankModel.id.split("/").pop() || rerankModel.id;
if (!providerSupportsModel(rerankModel.provider, rawModelId)) continue;
if (getModelIsHidden(rerankModel.provider, rawModelId)) continue;
if (hasEquivalentSpecialtyModel(rerankModel.provider, rawModelId, "rerank", rerankModel.id)) {
continue;
}
models.push({
id: rerankModel.id,
object: "model",
created: timestamp,
owned_by: rerankModel.provider,
root: rawModelId,
type: "rerank",
});
}
// Add audio models (filtered by active providers)
for (const audioModel of getAllAudioModels()) {
if (!isProviderActive(audioModel.provider)) continue;
const rawModelId = audioModel.id.split("/").pop() || audioModel.id;
if (!providerSupportsModel(audioModel.provider, rawModelId)) continue;
if (getModelIsHidden(audioModel.provider, rawModelId)) continue;
models.push({
id: audioModel.id,
object: "model",
created: timestamp,
owned_by: audioModel.provider,
type: "audio",
subtype: audioModel.subtype,
});
}
// Add moderation models (filtered by active providers)
for (const modModel of getAllModerationModels()) {
if (!isProviderActive(modModel.provider)) continue;
const rawModelId = modModel.id.split("/").pop() || modModel.id;
if (!providerSupportsModel(modModel.provider, rawModelId)) continue;
if (getModelIsHidden(modModel.provider, rawModelId)) continue;
models.push({
id: modModel.id,
object: "model",
created: timestamp,
owned_by: modModel.provider,
type: "moderation",
});
}
// Add video models (filtered by active providers)
for (const videoModel of getAllVideoModels()) {
if (!isProviderActive(videoModel.provider)) continue;
const rawModelId = videoModel.id.split("/").pop() || videoModel.id;
if (!providerSupportsModel(videoModel.provider, rawModelId)) continue;
if (getModelIsHidden(videoModel.provider, rawModelId)) continue;
models.push({
id: videoModel.id,
object: "model",
created: timestamp,
owned_by: videoModel.provider,
type: "video",
});
}
// Add music models (filtered by active providers)
for (const musicModel of getAllMusicModels()) {
if (!isProviderActive(musicModel.provider)) continue;
const rawModelId = musicModel.id.split("/").pop() || musicModel.id;
if (!providerSupportsModel(musicModel.provider, rawModelId)) continue;
if (getModelIsHidden(musicModel.provider, rawModelId)) continue;
models.push({
id: musicModel.id,
object: "model",
created: timestamp,
owned_by: musicModel.provider,
type: "music",
});
}
// Add custom models (user-defined)
try {
const customModelsMap = (await getAllCustomModels()) as Record<string, unknown>;
for (const [providerId, rawProviderCustomModels] of Object.entries(customModelsMap)) {
if (providerUsesCuratedModelsOnly(providerId)) continue;
// Skip Gemini — handled by syncedAvailableModels above
if (providerId === "gemini") continue;
if (providerId === "reka") continue;
const providerCustomModels: CustomModelEntry[] = Array.isArray(rawProviderCustomModels)
? rawProviderCustomModels.filter(
(model): model is CustomModelEntry =>
!!model && typeof model === "object" && !Array.isArray(model)
)
: [];
// For compatible providers, use the prefix from provider nodes
const prefix = providerIdToPrefix[providerId];
const alias = prefix || providerIdToAlias[providerId] || providerId;
const canonicalProviderId = resolveCanonicalProviderId(alias, providerId);
// Only include if provider is active — check alias, canonical ID, raw providerId,
// or the parent provider type (for compatible providers whose node ID is a UUID)
const parentProviderType = nodeIdToProviderType[providerId];
if (
!activeAliases.has(alias) &&
!activeAliases.has(canonicalProviderId) &&
!activeAliases.has(providerId) &&
!(parentProviderType && activeAliases.has(parentProviderType))
)
continue;
for (const model of providerCustomModels) {
const modelId = typeof model.id === "string" ? model.id : null;
if (!modelId) continue;
if (model.isHidden === true) continue;
if (getModelIsHidden(canonicalProviderId, modelId)) continue;
// #6328: apply hidePaidModels to user-defined custom rows too.
// Custom entries do not carry pricing, so shouldHidePaid() decides
// via FREE_MODEL_IDS_BY_PROVIDER — matches synced/PROVIDER_MODELS.
if (
shouldHidePaid(canonicalProviderId, modelId, (model as { pricing?: unknown }).pricing)
)
continue;
// noAuth providers have no connection rows; keep auth providers gated. (#2798/#3200)
const isNoAuthProvider = isNoAuthProviderKey(canonicalProviderId, providerId, alias);
if (
(!isNoAuthProvider ||
isNoAuthProviderBlocked(blockedProviders, canonicalProviderId, providerId, alias)) &&
!hasEligibleConnectionForModel(
getConnectionsForProvider(alias, canonicalProviderId, providerId, parentProviderType),
modelId
)
) {
continue;
}
// Skip if already added as built-in
const aliasId = `${alias}/${modelId}`;
if (models.some((m) => m.id === aliasId)) continue;
// Determine type from supportedEndpoints
const endpoints = Array.isArray(model.supportedEndpoints)
? model.supportedEndpoints
: ["chat"];
const apiFormat =
typeof model.apiFormat === "string" ? model.apiFormat : "chat-completions";
let modelType: string | undefined;
if (endpoints.includes("embeddings")) modelType = "embedding";
else if (endpoints.includes("rerank")) modelType = "rerank";
else if (endpoints.includes("images")) modelType = "image";
else if (endpoints.includes("audio")) modelType = "audio";
if (
modelType &&
hasEquivalentSpecialtyModel(canonicalProviderId, modelId, modelType, aliasId)
) {
continue;
}
const visionFields =
modelType === "chat" ? getCustomVisionCapabilityFields(model, aliasId, modelId) : null;
if (includeAlias) {
models.push({
id: aliasId,
object: "model",
created: timestamp,
owned_by: resolvePublicOwnerId(providerId, canonicalProviderId),
permission: [],
root: modelId,
parent: null,
custom: true,
...(modelType ? { type: modelType } : {}),
...(apiFormat !== "chat-completions" ? { api_format: apiFormat } : {}),
...(endpoints.length > 1 || !endpoints.includes("chat")
? { supported_endpoints: endpoints }
: {}),
...(typeof model.inputTokenLimit === "number"
? { context_length: model.inputTokenLimit }
: {}),
...(typeof (model as any).outputTokenLimit === "number"
? { max_output_tokens: (model as any).outputTokenLimit }
: {}),
...(visionFields || {}),
});
}
if (includeCanonical && canonicalProviderId !== alias && !prefix && !isNoAuthProvider) {
const providerPrefixedId = `${canonicalProviderId}/${modelId}`;
if (models.some((m) => m.id === providerPrefixedId)) continue;
const providerVisionFields =
modelType === "chat"
? getCustomVisionCapabilityFields(model, providerPrefixedId, modelId)
: null;
models.push({
id: providerPrefixedId,
object: "model",
created: timestamp,
owned_by: resolvePublicOwnerId(providerId, canonicalProviderId),
permission: [],
root: modelId,
parent: includeAlias ? aliasId : null,
custom: true,
...(modelType ? { type: modelType } : {}),
...(typeof model.inputTokenLimit === "number"
? { context_length: model.inputTokenLimit }
: {}),
...(typeof (model as any).outputTokenLimit === "number"
? { max_output_tokens: (model as any).outputTokenLimit }
: {}),
...(providerVisionFields || {}),
});
}
}
}
} catch (e) {
console.log("Could not fetch custom models");
}
// Port of decolua/9router#730 — surface models registered ONLY through a model
// alias (`key_value` namespace `modelAliases`, value `"<providerKey>/<modelId>"`).
// Without this walk, a compatible-provider entry like `setModelAlias("kimi-k2.6",
// "custom/kimi-k2.6")` resolves at request time but never shows up in `/v1/models`.
// We respect the same gating as the static/custom listing path: provider must be
// active (or noAuth+unblocked), model must not be hidden, and the canonical alias
// entry must not already exist (so we don't shadow combo / synced / custom rows).
try {
const modelAliases = await getModelAliases();
const aliasBacked = extractAliasBackedModels(modelAliases);
for (const { providerKey, modelId } of aliasBacked) {
const canonicalProviderId = resolveCanonicalProviderId(providerKey);
if (!canonicalProviderId) continue;
if (
blockedProviders.has(providerKey) ||
blockedProviders.has(canonicalProviderId) ||
isNoAuthProviderBlocked(blockedProviders, canonicalProviderId, providerKey)
) {
continue;
}
const alias = providerIdToAlias[canonicalProviderId] || providerKey;
if (
!activeAliases.has(alias) &&
!activeAliases.has(canonicalProviderId) &&
!activeAliases.has(providerKey)
) {
continue;
}
if (getModelIsHidden(canonicalProviderId, modelId)) continue;
// #6328: apply hidePaidModels to alias-backed rows too. Alias mappings
// point at providerKey/modelId with no pricing, so shouldHidePaid()
// decides via the FREE_MODEL_IDS_BY_PROVIDER catalog tier.
if (shouldHidePaid(canonicalProviderId, modelId)) continue;
const aliasId = `${alias}/${modelId}`;
const rawPrefixedId = `${providerKey}/${modelId}`;
if (
models.some((m: any) => m?.id === aliasId) ||
models.some((m: any) => m?.id === rawPrefixedId)
) {
continue;
}
const visionFields =
getVisionCapabilityFields(aliasId) || getVisionCapabilityFields(modelId);
if (includeAlias) {
models.push({
id: aliasId,
object: "model",
created: timestamp,
owned_by: resolvePublicOwnerId(providerKey, canonicalProviderId),
permission: [],
root: modelId,
parent: null,
...(visionFields || {}),
});
}
if (
includeCanonical &&
canonicalProviderId !== alias &&
!isNoAuthProviderKey(canonicalProviderId) &&
prefixRoutesToProvider(canonicalProviderId, canonicalProviderId)
) {
const providerPrefixedId = `${canonicalProviderId}/${modelId}`;
if (models.some((m: any) => m?.id === providerPrefixedId)) continue;
const providerVisionFields =
getVisionCapabilityFields(providerPrefixedId) || getVisionCapabilityFields(modelId);
models.push({
id: providerPrefixedId,
object: "model",
created: timestamp,
owned_by: resolvePublicOwnerId(providerKey, canonicalProviderId),
permission: [],
root: modelId,
parent: includeAlias ? aliasId : null,
...(providerVisionFields || {}),
});
}
}
} catch (e) {
console.log("Could not fetch model aliases");
}
// Add managed fallback models for compatible providers that don't import a model list.
for (const conn of connections) {
const providerId = typeof conn.provider === "string" ? conn.provider : null;
if (!providerId) continue;
if (blockedProviders.has(providerId)) continue;
const fallbackModels = getCompatibleFallbackModels(providerId);
if (!Array.isArray(fallbackModels) || fallbackModels.length === 0) continue;
const prefix = providerIdToPrefix[providerId];
const alias = prefix || providerIdToAlias[providerId] || providerId;
const canonicalProviderId = resolveCanonicalProviderId(alias, providerId);
for (const model of fallbackModels) {
const modelId = typeof model.id === "string" ? model.id : null;
if (!modelId) continue;
if (getModelIsHidden(canonicalProviderId, modelId)) continue;
// #6328: apply hidePaidModels to managed-fallback rows too. Compatible
// provider fallbacks lack pricing; shouldHidePaid() decides via the
// FREE_MODEL_IDS_BY_PROVIDER catalog tier.
if (shouldHidePaid(canonicalProviderId, modelId, (model as { pricing?: unknown }).pricing))
continue;
if (!hasEligibleConnectionForModel([conn], modelId)) continue;
const aliasId = `${alias}/${modelId}`;
if (models.some((m) => m.id === aliasId)) continue;
const visionFields =
getVisionCapabilityFields(aliasId) || getVisionCapabilityFields(modelId);
const contextLength =
typeof model.contextLength === "number" ? model.contextLength : undefined;
models.push({
id: aliasId,
object: "model",
created: timestamp,
owned_by: resolvePublicOwnerId(providerId, canonicalProviderId),
permission: [],
root: modelId,
parent: null,
...(contextLength ? { context_length: contextLength } : {}),
...(visionFields || {}),
});
}
}
// Filter by API key permissions if requested
const apiKey = extractApiKey(request);
let finalModels = models;
if (apiKey) {
const { isModelAllowedForKey, getApiKeyMetadata } = await import("@/lib/db/apiKeys");
// Quota-exclusive keys (allowedQuotas non-empty): list ONLY the pool's qtSd/*
// virtual models. #4806: build from the hidden qtSd/* combos directly — the base
// `models` list drops hidden combos, so filtering it returned nothing (0 models).
const keyMeta = await getApiKeyMetadata(apiKey);
if (keyMeta && keyMeta.allowedQuotas && keyMeta.allowedQuotas.length > 0) {
const { buildQuotaExclusiveModels } = await import("@/lib/quota/quotaCombos");
finalModels = await buildQuotaExclusiveModels(
keyMeta.allowedQuotas,
combos,
timestamp,
(c) => buildComboCatalogMetadata(c, combos)
);
} else if (!keyMeta) {
// #6406: A valid apiKey without a DB metadata row is an env-var master key
// (OMNIROUTE_API_KEY / ROUTER_API_KEY per isValidApiKey). Those keys have no
// per-key allow/deny/quota restrictions — they authenticate the request but
// do NOT scope the catalog. Skipping the per-model filter matches the intent:
// auth GATES access; env-var master keys see everything the unauth path sees.
// Without this branch, isModelAllowedForKey returns false for every model
// (metadata missing → deny), collapsing /v1/models to 0 entries.
} else {
const filtered = [];
for (const m of models) {
// m.id is the full identifier (e.g. openai/gpt-4o), m.root is the raw model string
// check either one as the config could use either patterns
if (
(await isModelAllowedForKey(apiKey, m.id)) ||
(await isModelAllowedForKey(apiKey, m.root))
) {
filtered.push(m);
}
}
finalModels = filtered;
}
}
// ?configuredOnly — hide models that have no eligible DB connection.
// Applied after the API-key filter so the key filter runs first, then
// variants are only generated for surviving models.
if (new URL(request.url).searchParams.get("configuredOnly") === "true") {
finalModels = finalModels.filter((m) => {
if (!m.root) return true;
return hasEligibleConnectionForModel(connections, m.root);
});
}
// Advertise Claude reasoning-effort variants (claude/<model>-{low,medium,high[,xhigh]}).
// Derived from the already key-filtered list so a variant only appears when its real
// model is permitted. Runs before the no-thinking pass: the gateway already routes these
// suffixed ids (claudeEffortVariant.ts), this just makes them selectable in catalog-only
// clients (OpenCode) that can't set a reasoning_effort config the way VS Code does.
finalModels = appendClaudeEffortVariants(
finalModels,
prefixMode === "canonical" ? aliasToProviderId : undefined
);
// Advertise no-thinking gateway variants (Fase 8.1). Derived from the already
// key-filtered list, so a variant only appears when its real model is permitted.
finalModels = appendNoThinkingVariants(
finalModels,
prefixMode === "canonical" ? aliasToProviderId : undefined
);
// #7694: advertise `<provider>/<model>-<tier>` variants for synced models that
// captured `reasoning.supported_efforts` at sync time (capabilities.effort_tiers).
// Derived from the already key-filtered list; skips codex/kimi (own suffix mechanism).
finalModels = appendSyncedEffortVariants(finalModels);
// #4424 follow-up — drop exact-duplicate ids that slip through the per-source push
// guards (e.g. `codex/gpt-5.5`, `veo-free/seedance` listed twice). Keyed by listing
// identity (id, type, subtype) so the intentional same-id audio transcription/speech
// pair survives. Independent of MODELS_CATALOG_PREFIX_MODE; runs as the final guard.
finalModels = dedupeExactCatalogIds(finalModels);
const getDefaultContextFallback = (model: any): number | undefined => {
if (typeof model.context_length === "number") return undefined;
if (model.owned_by === "combo") return undefined;
if (model.type && model.type !== "chat") return undefined;
const provider = typeof model.owned_by === "string" ? model.owned_by : null;
if (!provider) return undefined;
const canonicalId = aliasToProviderId[provider] || provider;
const registryFallback = REGISTRY[canonicalId]?.defaultContextLength;
if (registryFallback) return registryFallback;
const modelId =
model.root || (typeof model.id === "string" ? model.id.split("/").pop() : undefined);
return modelId ? getTokenLimit(canonicalId, modelId) : getTokenLimit(canonicalId);
};
const includeModelNames = isModelCatalogNamesEnabled();
const enrichedModels = disambiguateCatalogModelNames(
finalModels.map((model) => {
if (model.owned_by === "combo") {
return maybeOmitCatalogModelName(model, includeModelNames);
}
const enriched = enrichCatalogModelEntry(model);
const fallbackContextLength = getDefaultContextFallback(enriched);
const listedModel = fallbackContextLength
? { ...enriched, context_length: fallbackContextLength }
: enriched;
return maybeOmitCatalogModelName(listedModel, includeModelNames);
})
);
// Codex CLI compatibility: its model-catalog refresh (codex_models_manager) does
// GET /v1/models?client_version=<v> and decodes a JSON object with a TOP-LEVEL
// `models` array, so the OpenAI-standard `{object,data}` shape makes it fail with
// "missing field `models`" and log "failed to refresh available models" on every
// startup. For codex clients only (detected by the codex originator/user-agent) we add
// an EMPTY `models: []` so the decode succeeds and the error disappears. Every other
// OpenAI consumer keeps the byte-identical `{object,data}` response.
//
// We deliberately keep it EMPTY rather than mirroring the catalog: codex replaces its
// built-in per-model agent prompt (`base_instructions`, ~21k chars) with whatever a
// populated entry carries for the selected model, so emitting our models with an
// empty/foreign `base_instructions` would drop codex's agent prompt to nothing and
// break its agent behavior (verified empirically against codex 0.137). An empty array
// keeps codex on its built-in model info — same inference as today, minus the error.
const responseBody: Record<string, unknown> = {
object: "list",
data: enrichedModels,
};
if (isCodexModelCatalogClient(request)) {
responseBody.models = [];
}
return Response.json(responseBody, {
headers: {
...corsHeaders,
...diagnosticHeaders,
},
});
} catch (error) {
console.log("Error fetching models:", error);
return Response.json(
{
error: {
message: error instanceof Error ? error.message : String(error),
type: "server_error",
code: INTERNAL_PROXY_ERROR,
},
},
{
status: 500,
headers: {
...corsHeaders,
...diagnosticHeaders,
},
}
);
}
}