fix(models): list compatible provider models in /v1/models

Replace the overly-broad UPSTREAM_CONNECTION_RE regex (which matched all
provider IDs with UUID suffixes) with an x-9r-internal-models-fetch
header to detect cross-instance recursive /models fetches.

fetchCompatibleModelIds now sends the header when fetching upstream
/models; the GET handler detects it and skips dynamic fetching, breaking
the recursion loop while letting compatible providers (MLX, Ollama, vLLM)
list their models. Fixes #2626.
This commit is contained in:
liamgnc
2026-07-16 11:15:58 +07:00
committed by decolua
parent ba508f2506
commit 88a8c72d2d

View File

@@ -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": "*" },
});