diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index 32647304..f2451faf 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -79,8 +79,9 @@ const parseOpenAIStyleModels = (data) => { return data?.data || data?.models || data?.results || []; }; -// Matches provider IDs that are upstream/cross-instance connections (contain a UUID suffix) -const UPSTREAM_CONNECTION_RE = /[-_][0-9a-f]{8,}$/i; +// Header sent by fetchCompatibleModelIds to detect cross-instance /models fetches +// and break recursive loops between 9router instances connected to each other. +const INTERNAL_MODELS_FETCH_HEADER = "x-9r-internal-models-fetch"; // LLM kind sentinel — combos/models with no explicit kind default to LLM const LLM_KIND = "llm"; @@ -145,7 +146,7 @@ async function fetchCompatibleModelIds(connection) { const timeoutId = setTimeout(() => controller.abort(), 5000); const response = await fetch(url, { method: "GET", - headers, + headers: { ...headers, [INTERNAL_MODELS_FETCH_HEADER]: "1" }, cache: "no-store", signal: controller.signal, }); @@ -189,7 +190,11 @@ function comboMatchesKinds(combo, kindFilter) { * Build OpenAI-format models list filtered by service kinds. * @param {string[]} kindFilter - List of service kinds to include (e.g. ["llm"], ["webSearch","webFetch"]). */ -export async function buildModelsList(kindFilter) { +export async function buildModelsList(kindFilter, options = {}) { + // When this header is present, the /v1/models request came from another + // 9router instance's fetchCompatibleModelIds — skip dynamic fetch to break + // cross-instance recursive loops. + const skipDynamicFetch = options.skipDynamicFetch === true; let connections = []; try { connections = await getProviderConnections(); @@ -319,7 +324,7 @@ export async function buildModelsList(kindFilter) { ) : providerModels.map((model) => model.id); - if (isCompatibleProvider && rawModelIds.length === 0 && !UPSTREAM_CONNECTION_RE.test(providerId)) { + if (isCompatibleProvider && rawModelIds.length === 0 && !skipDynamicFetch) { rawModelIds = await fetchCompatibleModelIds(conn); } @@ -475,9 +480,11 @@ export async function OPTIONS() { * GET /v1/models - OpenAI compatible models list (LLM/chat models only by default). * For other capabilities use /v1/models/{kind} (image, tts, stt, embedding, image-to-text, web). */ -export async function GET() { +export async function GET(request) { try { - const data = await buildModelsList([LLM_KIND]); + // Detect cross-instance recursive /models fetch (another 9router fetching our /models) + const skipDynamicFetch = request?.headers?.get(INTERNAL_MODELS_FETCH_HEADER) === "1"; + const data = await buildModelsList([LLM_KIND], { skipDynamicFetch }); return Response.json({ object: "list", data }, { headers: { "Access-Control-Allow-Origin": "*" }, });