refactor(app): DRY pass — split large files, extract shared utils
S1: delete page.new.js (1724L abandoned) + remove dead getAntigravityProjectId
S2: split large files by natural seams
- usage.js → usage/{github,google,claude,codex,kiro,minimax,misc,shared}.js
- media-providers page → components/{Embedding,Tts,Generic,Stt}ExampleCard.js
- EndpointPageClient → endpointConstants.js + endpointPing.js + components/
- tokenRefresh.js → tokenRefresh/{dedup,providers}.js
- ProviderLimits/index.js: 16 pure fn + 9 constants → utils.js
- oauth/providers.js: 7 pure helpers → providerHelpers.js
S3: shared utils
- getModelKind(m, fallback) → shared/constants/models.js (replaces 20× m.kind||m.type)
- getStatusVariant → shared/utils/connectionStatus.js (dedup ConnectionRow/ConnectionsCard)
- sseChunk → open-sse/utils/sse.js (dedup grok-web/perplexity-web)
- fetchWithTimeout → usage/shared.js (replace 4× AbortController pattern in google.js)
fix: enableObservability2 field name in requestDetailsRepo
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 08:31:09 -04:00
|
|
|
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS, getModelKind } from "@/shared/constants/models";
|
2026-05-04 00:29:02 -04:00
|
|
|
import {
|
|
|
|
|
AI_PROVIDERS,
|
|
|
|
|
getProviderAlias,
|
|
|
|
|
isAnthropicCompatibleProvider,
|
|
|
|
|
isOpenAICompatibleProvider,
|
|
|
|
|
} from "@/shared/constants/providers";
|
2026-05-03 04:22:05 -04:00
|
|
|
import { getProviderConnections, getCombos, getCustomModels, getModelAliases } from "@/lib/localDb";
|
2026-05-07 04:45:09 -04:00
|
|
|
import { getDisabledModels } from "@/lib/disabledModelsDb";
|
2026-05-16 00:20:08 -04:00
|
|
|
import { resolveKiroModels } from "open-sse/services/kiroModels.js";
|
2026-06-29 04:24:39 -04:00
|
|
|
import { resolveKimchiModels } from "open-sse/services/kimchiModels.js";
|
2026-05-23 03:31:50 -04:00
|
|
|
import { resolveQoderModels } from "open-sse/services/qoderModels.js";
|
2026-06-25 23:28:09 -04:00
|
|
|
import { resolveCopilotModels } from "open-sse/services/copilotModels.js";
|
2026-07-02 23:50:32 -04:00
|
|
|
import { resolveClinepassModels } from "open-sse/services/clinepassModels.js";
|
2026-07-16 04:28:25 -04:00
|
|
|
import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js";
|
2026-07-20 04:39:17 -04:00
|
|
|
import { resolveCursorModels } from "open-sse/services/cursorModels.js";
|
2026-06-25 23:28:09 -04:00
|
|
|
import { updateProviderCredentials } from "@/sse/services/tokenRefresh";
|
2026-07-16 04:28:25 -04:00
|
|
|
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
|
2026-07-16 00:32:14 -04:00
|
|
|
import { capabilitiesFromServiceKind, getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
|
2026-01-20 01:16:34 -05:00
|
|
|
|
2026-05-16 00:20:20 -04:00
|
|
|
// Per-provider live model resolvers. Each receives a connection record and
|
|
|
|
|
// returns { models: [{ id, name? }, ...] } | null on failure.
|
|
|
|
|
// Adding a provider here makes /v1/models prefer the live catalog for it.
|
|
|
|
|
const LIVE_MODEL_RESOLVERS = {
|
|
|
|
|
kiro: async (conn) => {
|
|
|
|
|
const result = await resolveKiroModels({
|
|
|
|
|
accessToken: conn.accessToken,
|
|
|
|
|
refreshToken: conn.refreshToken,
|
|
|
|
|
providerSpecificData: conn.providerSpecificData || {}
|
|
|
|
|
}, { log: console });
|
|
|
|
|
return result?.models?.length ? { models: result.models } : null;
|
2026-05-23 03:31:50 -04:00
|
|
|
},
|
|
|
|
|
qoder: async (conn) => {
|
|
|
|
|
const result = await resolveQoderModels({
|
|
|
|
|
accessToken: conn.accessToken,
|
|
|
|
|
refreshToken: conn.refreshToken,
|
|
|
|
|
email: conn.email,
|
|
|
|
|
displayName: conn.displayName,
|
|
|
|
|
providerSpecificData: conn.providerSpecificData || {}
|
|
|
|
|
});
|
|
|
|
|
if (!result?.models?.length) return null;
|
|
|
|
|
return {
|
|
|
|
|
models: result.models.map((m) => ({ id: m.id, name: m.name })),
|
|
|
|
|
};
|
2026-06-25 23:28:09 -04:00
|
|
|
},
|
2026-06-29 04:24:39 -04:00
|
|
|
kimchi: async (conn) => {
|
|
|
|
|
const result = await resolveKimchiModels({
|
|
|
|
|
accessToken: conn.accessToken,
|
|
|
|
|
apiKey: conn.apiKey,
|
|
|
|
|
providerSpecificData: conn.providerSpecificData || {}
|
|
|
|
|
}, { log: console });
|
|
|
|
|
return result?.models?.length ? { models: result.models } : null;
|
|
|
|
|
},
|
2026-06-25 23:28:09 -04:00
|
|
|
github: async (conn) => {
|
|
|
|
|
const result = await resolveCopilotModels({
|
|
|
|
|
accessToken: conn.accessToken,
|
|
|
|
|
refreshToken: conn.refreshToken,
|
|
|
|
|
providerSpecificData: conn.providerSpecificData || {}
|
|
|
|
|
}, {
|
|
|
|
|
log: console,
|
|
|
|
|
onCredentialsRefreshed: async (refreshed) => {
|
|
|
|
|
await updateProviderCredentials(conn.id, {
|
|
|
|
|
copilotToken: refreshed.copilotToken,
|
|
|
|
|
copilotTokenExpiresAt: refreshed.copilotTokenExpiresAt,
|
|
|
|
|
existingProviderSpecificData: conn.providerSpecificData || {},
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
return result?.models?.length ? { models: result.models } : null;
|
2026-07-02 23:50:32 -04:00
|
|
|
},
|
|
|
|
|
clinepass: async (conn) => {
|
|
|
|
|
const result = await resolveClinepassModels({
|
|
|
|
|
accessToken: conn.accessToken,
|
|
|
|
|
apiKey: conn.apiKey,
|
|
|
|
|
});
|
|
|
|
|
return result?.models?.length ? { models: result.models } : null;
|
2026-07-16 04:28:25 -04:00
|
|
|
},
|
|
|
|
|
"grok-cli": async (conn) => {
|
|
|
|
|
const proxy = await resolveConnectionProxyConfig(conn.providerSpecificData || {});
|
|
|
|
|
const result = await resolveGrokCliModels({
|
|
|
|
|
...conn,
|
|
|
|
|
connectionId: conn.id,
|
|
|
|
|
}, {
|
|
|
|
|
log: console,
|
|
|
|
|
proxyOptions: {
|
|
|
|
|
connectionProxyEnabled: proxy.connectionProxyEnabled === true,
|
|
|
|
|
connectionProxyUrl: proxy.connectionProxyUrl || "",
|
|
|
|
|
connectionNoProxy: proxy.connectionNoProxy || "",
|
|
|
|
|
vercelRelayUrl: proxy.vercelRelayUrl || "",
|
|
|
|
|
strictProxy: proxy.strictProxy === true,
|
|
|
|
|
},
|
|
|
|
|
onCredentialsRefreshed: async (refreshed) => {
|
|
|
|
|
await updateProviderCredentials(conn.id, {
|
|
|
|
|
...refreshed,
|
|
|
|
|
existingProviderSpecificData: conn.providerSpecificData || {},
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
return result?.models?.length ? { models: result.models } : null;
|
|
|
|
|
},
|
2026-07-20 04:39:17 -04:00
|
|
|
cursor: async (conn) => {
|
|
|
|
|
const result = await resolveCursorModels({
|
|
|
|
|
accessToken: conn.accessToken,
|
|
|
|
|
providerSpecificData: conn.providerSpecificData || {},
|
|
|
|
|
}, { log: console });
|
|
|
|
|
return result?.models?.length ? { models: result.models } : null;
|
|
|
|
|
}
|
2026-05-16 00:20:20 -04:00
|
|
|
};
|
|
|
|
|
|
2026-04-06 04:09:58 -04:00
|
|
|
const parseOpenAIStyleModels = (data) => {
|
|
|
|
|
if (Array.isArray(data)) return data;
|
|
|
|
|
return data?.data || data?.models || data?.results || [];
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-16 00:15:58 -04:00
|
|
|
// 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";
|
2026-04-09 23:30:07 -04:00
|
|
|
|
2026-05-04 00:29:02 -04:00
|
|
|
// LLM kind sentinel — combos/models with no explicit kind default to LLM
|
|
|
|
|
const LLM_KIND = "llm";
|
|
|
|
|
|
|
|
|
|
// Map per-model `type` field (in PROVIDER_MODELS) to service kind.
|
|
|
|
|
// Models without `type` are treated as LLM.
|
|
|
|
|
const MODEL_TYPE_TO_KIND = {
|
|
|
|
|
image: "image",
|
|
|
|
|
tts: "tts",
|
|
|
|
|
embedding: "embedding",
|
|
|
|
|
stt: "stt",
|
|
|
|
|
imageToText: "imageToText",
|
2026-07-16 04:29:02 -04:00
|
|
|
video: "video",
|
2026-05-04 00:29:02 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function modelKind(model) {
|
2026-06-14 03:32:59 -04:00
|
|
|
const k = model?.kind || model?.type;
|
|
|
|
|
if (!k) return LLM_KIND;
|
|
|
|
|
return MODEL_TYPE_TO_KIND[k] || LLM_KIND;
|
2026-05-04 00:29:02 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// For dynamic/unknown model IDs (compatible providers, alias map, custom models)
|
|
|
|
|
// fall back to provider-level kind matching when per-model type is unavailable.
|
|
|
|
|
function inferKindFromUnknownModelId(modelId) {
|
|
|
|
|
const lower = String(modelId).toLowerCase();
|
|
|
|
|
if (/embed/.test(lower)) return "embedding";
|
|
|
|
|
if (/tts|speech|audio|voice/.test(lower)) return "tts";
|
|
|
|
|
if (/image|imagen|dall-?e|flux|sdxl|sd-|stable-diffusion/.test(lower)) return "image";
|
|
|
|
|
return LLM_KIND;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 04:09:58 -04:00
|
|
|
async function fetchCompatibleModelIds(connection) {
|
|
|
|
|
if (!connection?.apiKey) return [];
|
|
|
|
|
|
|
|
|
|
const baseUrl = typeof connection?.providerSpecificData?.baseUrl === "string"
|
|
|
|
|
? connection.providerSpecificData.baseUrl.trim().replace(/\/$/, "")
|
|
|
|
|
: "";
|
|
|
|
|
|
|
|
|
|
if (!baseUrl) return [];
|
|
|
|
|
|
|
|
|
|
let url = `${baseUrl}/models`;
|
|
|
|
|
const headers = {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (isOpenAICompatibleProvider(connection.provider)) {
|
|
|
|
|
headers.Authorization = `Bearer ${connection.apiKey}`;
|
|
|
|
|
} else if (isAnthropicCompatibleProvider(connection.provider)) {
|
|
|
|
|
if (url.endsWith("/messages/models")) {
|
|
|
|
|
url = url.slice(0, -9);
|
|
|
|
|
} else if (url.endsWith("/messages")) {
|
|
|
|
|
url = `${url.slice(0, -9)}/models`;
|
|
|
|
|
}
|
|
|
|
|
headers["x-api-key"] = connection.apiKey;
|
|
|
|
|
headers["anthropic-version"] = "2023-06-01";
|
|
|
|
|
headers.Authorization = `Bearer ${connection.apiKey}`;
|
|
|
|
|
} else {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-04-09 23:30:07 -04:00
|
|
|
const controller = new AbortController();
|
|
|
|
|
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
2026-04-06 04:09:58 -04:00
|
|
|
const response = await fetch(url, {
|
|
|
|
|
method: "GET",
|
2026-07-16 00:15:58 -04:00
|
|
|
headers: { ...headers, [INTERNAL_MODELS_FETCH_HEADER]: "1" },
|
2026-04-06 04:09:58 -04:00
|
|
|
cache: "no-store",
|
2026-04-09 23:30:07 -04:00
|
|
|
signal: controller.signal,
|
2026-04-06 04:09:58 -04:00
|
|
|
});
|
2026-04-09 23:30:07 -04:00
|
|
|
clearTimeout(timeoutId);
|
2026-04-06 04:09:58 -04:00
|
|
|
|
|
|
|
|
if (!response.ok) return [];
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
const rawModels = parseOpenAIStyleModels(data);
|
|
|
|
|
|
|
|
|
|
return Array.from(
|
|
|
|
|
new Set(
|
|
|
|
|
rawModels
|
|
|
|
|
.map((model) => model?.id || model?.name || model?.model)
|
|
|
|
|
.filter((modelId) => typeof modelId === "string" && modelId.trim() !== "")
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
} catch {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 00:29:02 -04:00
|
|
|
// Provider matches kindFilter when its serviceKinds intersect the requested kinds.
|
|
|
|
|
// LLM is the default kind for providers missing serviceKinds.
|
|
|
|
|
function providerMatchesKinds(providerId, kindFilter) {
|
|
|
|
|
const provider = AI_PROVIDERS[providerId];
|
|
|
|
|
const kinds = Array.isArray(provider?.serviceKinds) && provider.serviceKinds.length > 0
|
|
|
|
|
? provider.serviceKinds
|
|
|
|
|
: [LLM_KIND];
|
|
|
|
|
return kindFilter.some((k) => kinds.includes(k));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Combo matches kindFilter when its `kind` field is in the list.
|
|
|
|
|
// Combos with no kind are treated as LLM.
|
|
|
|
|
function comboMatchesKinds(combo, kindFilter) {
|
|
|
|
|
const kind = combo?.kind || LLM_KIND;
|
|
|
|
|
return kindFilter.includes(kind);
|
2026-01-20 01:16:34 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-04 00:29:02 -04:00
|
|
|
* Build OpenAI-format models list filtered by service kinds.
|
|
|
|
|
* @param {string[]} kindFilter - List of service kinds to include (e.g. ["llm"], ["webSearch","webFetch"]).
|
2026-01-20 01:16:34 -05:00
|
|
|
*/
|
2026-07-16 00:15:58 -04:00
|
|
|
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;
|
2026-05-04 00:29:02 -04:00
|
|
|
let connections = [];
|
2026-01-20 01:16:34 -05:00
|
|
|
try {
|
2026-05-04 00:29:02 -04:00
|
|
|
connections = await getProviderConnections();
|
|
|
|
|
connections = connections.filter(c => c.isActive !== false);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.log("Could not fetch providers, returning all models");
|
|
|
|
|
}
|
2026-01-20 01:16:34 -05:00
|
|
|
|
2026-05-04 00:29:02 -04:00
|
|
|
let combos = [];
|
|
|
|
|
try {
|
|
|
|
|
combos = await getCombos();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.log("Could not fetch combos");
|
|
|
|
|
}
|
2026-01-20 01:16:34 -05:00
|
|
|
|
2026-05-04 00:29:02 -04:00
|
|
|
let customModels = [];
|
|
|
|
|
try {
|
|
|
|
|
customModels = await getCustomModels();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.log("Could not fetch custom models");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let modelAliases = {};
|
|
|
|
|
try {
|
|
|
|
|
modelAliases = await getModelAliases();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.log("Could not fetch model aliases");
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 04:45:09 -04:00
|
|
|
let disabledByAlias = {};
|
|
|
|
|
try {
|
|
|
|
|
disabledByAlias = await getDisabledModels();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.log("Could not fetch disabled models");
|
|
|
|
|
}
|
|
|
|
|
const isDisabled = (alias, modelId) => Array.isArray(disabledByAlias[alias]) && disabledByAlias[alias].includes(modelId);
|
|
|
|
|
|
2026-05-04 00:29:02 -04:00
|
|
|
const activeConnectionByProvider = new Map();
|
|
|
|
|
for (const conn of connections) {
|
|
|
|
|
if (!activeConnectionByProvider.has(conn.provider)) {
|
|
|
|
|
activeConnectionByProvider.set(conn.provider, conn);
|
2026-05-03 04:22:05 -04:00
|
|
|
}
|
2026-05-04 00:29:02 -04:00
|
|
|
}
|
2026-05-03 04:22:05 -04:00
|
|
|
|
2026-05-04 00:29:02 -04:00
|
|
|
const models = [];
|
|
|
|
|
|
|
|
|
|
// Combos first (filtered by kind). Web combos expose `kind` so AI knows search vs fetch.
|
|
|
|
|
for (const combo of combos) {
|
|
|
|
|
if (!comboMatchesKinds(combo, kindFilter)) continue;
|
|
|
|
|
const entry = {
|
|
|
|
|
id: combo.name,
|
|
|
|
|
object: "model",
|
|
|
|
|
owned_by: "combo",
|
|
|
|
|
};
|
|
|
|
|
if (combo.kind === "webSearch" || combo.kind === "webFetch") {
|
|
|
|
|
entry.kind = combo.kind;
|
2026-05-03 04:22:05 -04:00
|
|
|
}
|
2026-05-04 00:29:02 -04:00
|
|
|
models.push(entry);
|
|
|
|
|
}
|
2026-05-03 04:22:05 -04:00
|
|
|
|
2026-05-04 00:29:02 -04:00
|
|
|
if (connections.length === 0) {
|
|
|
|
|
// DB unavailable -> return static models, filtered by per-model kind
|
|
|
|
|
const aliasToProviderId = Object.fromEntries(
|
|
|
|
|
Object.entries(PROVIDER_ID_TO_ALIAS).map(([id, alias]) => [alias, id])
|
|
|
|
|
);
|
|
|
|
|
for (const [alias, providerModels] of Object.entries(PROVIDER_MODELS)) {
|
|
|
|
|
const providerId = aliasToProviderId[alias] || alias;
|
|
|
|
|
if (!providerMatchesKinds(providerId, kindFilter)) continue;
|
|
|
|
|
for (const model of providerModels) {
|
|
|
|
|
if (!kindFilter.includes(modelKind(model))) continue;
|
2026-05-07 04:45:09 -04:00
|
|
|
if (isDisabled(alias, model.id)) continue;
|
2026-05-04 00:29:02 -04:00
|
|
|
models.push({
|
|
|
|
|
id: `${alias}/${model.id}`,
|
|
|
|
|
object: "model",
|
|
|
|
|
owned_by: alias,
|
|
|
|
|
});
|
2026-02-24 23:40:50 -05:00
|
|
|
}
|
2026-01-20 01:16:34 -05:00
|
|
|
}
|
|
|
|
|
|
2026-05-04 00:29:02 -04:00
|
|
|
for (const customModel of customModels) {
|
|
|
|
|
if (!customModel?.id || (customModel.type && customModel.type !== "llm")) continue;
|
|
|
|
|
// Custom models without active connection are LLM-only by current schema
|
|
|
|
|
if (!kindFilter.includes(LLM_KIND)) continue;
|
|
|
|
|
const providerAlias = customModel.providerAlias;
|
|
|
|
|
if (!providerAlias) continue;
|
|
|
|
|
|
|
|
|
|
const modelId = String(customModel.id).trim();
|
|
|
|
|
if (!modelId) continue;
|
2026-01-20 01:16:34 -05:00
|
|
|
|
|
|
|
|
models.push({
|
2026-05-04 00:29:02 -04:00
|
|
|
id: `${providerAlias}/${modelId}`,
|
2026-01-20 01:16:34 -05:00
|
|
|
object: "model",
|
2026-05-04 00:29:02 -04:00
|
|
|
owned_by: providerAlias,
|
2026-01-20 01:16:34 -05:00
|
|
|
});
|
|
|
|
|
}
|
2026-05-04 00:29:02 -04:00
|
|
|
} else {
|
|
|
|
|
for (const [providerId, conn] of activeConnectionByProvider.entries()) {
|
|
|
|
|
if (!providerMatchesKinds(providerId, kindFilter)) continue;
|
|
|
|
|
|
|
|
|
|
const staticAlias = PROVIDER_ID_TO_ALIAS[providerId] || providerId;
|
|
|
|
|
const outputAlias = (
|
|
|
|
|
conn?.providerSpecificData?.prefix
|
|
|
|
|
|| getProviderAlias(providerId)
|
|
|
|
|
|| staticAlias
|
|
|
|
|
).trim();
|
|
|
|
|
const providerModels = PROVIDER_MODELS[staticAlias] || [];
|
|
|
|
|
const enabledModels = conn?.providerSpecificData?.enabledModels;
|
|
|
|
|
const hasExplicitEnabledModels =
|
|
|
|
|
Array.isArray(enabledModels) && enabledModels.length > 0;
|
|
|
|
|
const isCompatibleProvider =
|
|
|
|
|
isOpenAICompatibleProvider(providerId) || isAnthropicCompatibleProvider(providerId);
|
|
|
|
|
|
|
|
|
|
// Build kind lookup for static models so we can filter even when only IDs are exposed
|
|
|
|
|
const staticModelKindById = new Map(
|
|
|
|
|
providerModels.map((m) => [m.id, modelKind(m)])
|
|
|
|
|
);
|
2026-06-29 04:24:39 -04:00
|
|
|
let liveModelKindById = new Map();
|
|
|
|
|
let liveCapabilitiesById = new Map();
|
2026-05-04 00:29:02 -04:00
|
|
|
|
|
|
|
|
let rawModelIds = hasExplicitEnabledModels
|
|
|
|
|
? Array.from(
|
|
|
|
|
new Set(
|
|
|
|
|
enabledModels.filter(
|
|
|
|
|
(modelId) => typeof modelId === "string" && modelId.trim() !== "",
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
: providerModels.map((model) => model.id);
|
2026-01-20 01:16:34 -05:00
|
|
|
|
2026-07-16 00:15:58 -04:00
|
|
|
if (isCompatibleProvider && rawModelIds.length === 0 && !skipDynamicFetch) {
|
2026-05-04 00:29:02 -04:00
|
|
|
rawModelIds = await fetchCompatibleModelIds(conn);
|
2026-01-20 01:16:34 -05:00
|
|
|
}
|
2026-05-03 04:22:05 -04:00
|
|
|
|
2026-05-16 00:20:20 -04:00
|
|
|
// Config-driven live catalog override (e.g. Kiro returns dynamic
|
|
|
|
|
// -thinking/-agentic variants per account). On failure, fall back to
|
|
|
|
|
// whatever rawModelIds already holds.
|
|
|
|
|
const liveResolver = LIVE_MODEL_RESOLVERS[providerId];
|
|
|
|
|
if (liveResolver && !hasExplicitEnabledModels) {
|
|
|
|
|
try {
|
|
|
|
|
const live = await liveResolver(conn);
|
|
|
|
|
if (live?.models?.length) {
|
|
|
|
|
rawModelIds = live.models.map((m) => m.id);
|
2026-06-29 04:24:39 -04:00
|
|
|
liveModelKindById = new Map(
|
|
|
|
|
live.models
|
|
|
|
|
.filter((m) => m?.id)
|
|
|
|
|
.map((m) => [m.id, modelKind(m)])
|
|
|
|
|
);
|
|
|
|
|
liveCapabilitiesById = new Map(
|
|
|
|
|
live.models
|
|
|
|
|
.filter((m) => m?.id && m.capabilities)
|
|
|
|
|
.map((m) => [m.id, m.capabilities])
|
|
|
|
|
);
|
2026-05-16 00:20:20 -04:00
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.log(`Live model fetch failed for ${providerId}: ${err?.message || err}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 00:29:02 -04:00
|
|
|
const modelIds = rawModelIds
|
|
|
|
|
.map((modelId) => {
|
|
|
|
|
if (modelId.startsWith(`${outputAlias}/`)) {
|
|
|
|
|
return modelId.slice(outputAlias.length + 1);
|
|
|
|
|
}
|
|
|
|
|
if (modelId.startsWith(`${staticAlias}/`)) {
|
|
|
|
|
return modelId.slice(staticAlias.length + 1);
|
|
|
|
|
}
|
|
|
|
|
if (modelId.startsWith(`${providerId}/`)) {
|
|
|
|
|
return modelId.slice(providerId.length + 1);
|
|
|
|
|
}
|
|
|
|
|
return modelId;
|
|
|
|
|
})
|
|
|
|
|
.filter((modelId) => typeof modelId === "string" && modelId.trim() !== "");
|
|
|
|
|
|
2026-06-17 22:13:27 -04:00
|
|
|
const customModelKindById = new Map();
|
2026-05-04 00:29:02 -04:00
|
|
|
const customModelIds = customModels
|
|
|
|
|
.filter((m) => {
|
2026-06-17 22:13:27 -04:00
|
|
|
if (!m?.id) return false;
|
|
|
|
|
const kind = getModelKind(m) || LLM_KIND;
|
|
|
|
|
// imageToText custom models are vision-capable chat models: expose them
|
|
|
|
|
// both in the default LLM list and in /v1/models/image-to-text.
|
|
|
|
|
if (!kindFilter.includes(kind) && !(kind === "imageToText" && kindFilter.includes(LLM_KIND))) return false;
|
2026-05-04 00:29:02 -04:00
|
|
|
const alias = m.providerAlias;
|
|
|
|
|
return alias === staticAlias || alias === outputAlias || alias === providerId;
|
|
|
|
|
})
|
2026-06-17 22:13:27 -04:00
|
|
|
.map((m) => {
|
|
|
|
|
const modelId = String(m.id).trim();
|
|
|
|
|
if (modelId) customModelKindById.set(modelId, getModelKind(m) || LLM_KIND);
|
|
|
|
|
return modelId;
|
|
|
|
|
})
|
2026-05-04 00:29:02 -04:00
|
|
|
.filter((modelId) => modelId !== "");
|
|
|
|
|
|
|
|
|
|
const aliasModelIds = Object.values(modelAliases || {})
|
|
|
|
|
.filter((fullModel) => {
|
|
|
|
|
if (typeof fullModel !== "string" || !fullModel.includes("/")) return false;
|
|
|
|
|
return (
|
|
|
|
|
fullModel.startsWith(`${outputAlias}/`) ||
|
|
|
|
|
fullModel.startsWith(`${staticAlias}/`) ||
|
|
|
|
|
fullModel.startsWith(`${providerId}/`)
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
.map((fullModel) => {
|
|
|
|
|
if (fullModel.startsWith(`${outputAlias}/`)) {
|
|
|
|
|
return fullModel.slice(outputAlias.length + 1);
|
|
|
|
|
}
|
|
|
|
|
if (fullModel.startsWith(`${staticAlias}/`)) {
|
|
|
|
|
return fullModel.slice(staticAlias.length + 1);
|
|
|
|
|
}
|
|
|
|
|
if (fullModel.startsWith(`${providerId}/`)) {
|
|
|
|
|
return fullModel.slice(providerId.length + 1);
|
|
|
|
|
}
|
|
|
|
|
return fullModel;
|
|
|
|
|
})
|
|
|
|
|
.filter((modelId) => typeof modelId === "string" && modelId.trim() !== "");
|
|
|
|
|
|
|
|
|
|
const mergedModelIds = Array.from(new Set([...modelIds, ...customModelIds, ...aliasModelIds]));
|
|
|
|
|
|
|
|
|
|
for (const modelId of mergedModelIds) {
|
2026-06-29 04:24:39 -04:00
|
|
|
// Resolve kind: prefer custom/live metadata, then static, then ID heuristics.
|
2026-06-17 22:13:27 -04:00
|
|
|
const customKind = customModelKindById.get(modelId);
|
2026-06-29 04:24:39 -04:00
|
|
|
const liveKind = liveModelKindById.get(modelId);
|
|
|
|
|
const kind = customKind || liveKind || staticModelKindById.get(modelId) || inferKindFromUnknownModelId(modelId);
|
2026-06-17 22:13:27 -04:00
|
|
|
// imageToText custom models stay in the LLM list (vision-capable chat models)
|
|
|
|
|
const allowAsLlm = kind === "imageToText" && kindFilter.includes(LLM_KIND);
|
|
|
|
|
if (!kindFilter.includes(kind) && !allowAsLlm) continue;
|
2026-05-07 04:45:09 -04:00
|
|
|
if (isDisabled(outputAlias, modelId) || isDisabled(staticAlias, modelId)) continue;
|
2026-05-03 04:22:05 -04:00
|
|
|
|
2026-06-17 22:13:27 -04:00
|
|
|
const model = {
|
2026-05-04 00:29:02 -04:00
|
|
|
id: `${outputAlias}/${modelId}`,
|
2026-05-03 04:22:05 -04:00
|
|
|
object: "model",
|
2026-05-04 00:29:02 -04:00
|
|
|
owned_by: outputAlias,
|
2026-06-17 22:13:27 -04:00
|
|
|
};
|
2026-07-16 00:32:14 -04:00
|
|
|
// Live-catalog resolvers (kiro/qoder/github/clinepass) mostly only return
|
|
|
|
|
// { id, name } — no per-model capability data. Fall back to the same
|
|
|
|
|
// pattern-matched capabilities the dashboard uses (useModelCaps.js) so
|
|
|
|
|
// dynamically-discovered LLM models still surface vision/reasoning/search/tools.
|
|
|
|
|
const caps = liveCapabilitiesById.get(modelId)
|
|
|
|
|
|| capabilitiesFromServiceKind(customKind || liveKind)
|
|
|
|
|
|| (kind === LLM_KIND ? getCapabilitiesForModel(providerId, modelId) : null);
|
2026-06-17 22:13:27 -04:00
|
|
|
if (caps) model.capabilities = caps;
|
|
|
|
|
models.push(model);
|
2026-05-03 04:22:05 -04:00
|
|
|
}
|
2026-02-24 23:40:50 -05:00
|
|
|
|
2026-05-04 00:29:02 -04:00
|
|
|
// Web search/fetch — provider IS the model, expose as {alias}/search and/or {alias}/fetch with explicit kind
|
2026-06-14 03:32:59 -04:00
|
|
|
const providerInfo = AI_PROVIDERS[providerId];
|
2026-05-04 00:29:02 -04:00
|
|
|
if (kindFilter.includes("webSearch") && providerInfo?.searchConfig) {
|
|
|
|
|
models.push({
|
|
|
|
|
id: `${outputAlias}/search`,
|
|
|
|
|
object: "model",
|
|
|
|
|
kind: "webSearch",
|
|
|
|
|
owned_by: outputAlias,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (kindFilter.includes("webFetch") && providerInfo?.fetchConfig) {
|
|
|
|
|
models.push({
|
|
|
|
|
id: `${outputAlias}/fetch`,
|
|
|
|
|
object: "model",
|
|
|
|
|
kind: "webFetch",
|
|
|
|
|
owned_by: outputAlias,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-05-03 04:22:05 -04:00
|
|
|
}
|
2026-05-04 00:29:02 -04:00
|
|
|
}
|
2026-05-03 04:22:05 -04:00
|
|
|
|
2026-05-04 00:29:02 -04:00
|
|
|
const dedupedModels = [];
|
|
|
|
|
const seenModelIds = new Set();
|
|
|
|
|
for (const model of models) {
|
|
|
|
|
if (!model?.id || seenModelIds.has(model.id)) continue;
|
|
|
|
|
seenModelIds.add(model.id);
|
|
|
|
|
dedupedModels.push(model);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return dedupedModels;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle CORS preflight
|
|
|
|
|
*/
|
|
|
|
|
export async function OPTIONS() {
|
|
|
|
|
return new Response(null, {
|
|
|
|
|
headers: {
|
|
|
|
|
"Access-Control-Allow-Origin": "*",
|
|
|
|
|
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
|
|
|
"Access-Control-Allow-Headers": "*",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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).
|
|
|
|
|
*/
|
2026-07-16 00:15:58 -04:00
|
|
|
export async function GET(request) {
|
2026-05-04 00:29:02 -04:00
|
|
|
try {
|
2026-07-16 00:15:58 -04:00
|
|
|
// 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 });
|
2026-05-04 00:29:02 -04:00
|
|
|
return Response.json({ object: "list", data }, {
|
|
|
|
|
headers: { "Access-Control-Allow-Origin": "*" },
|
2026-01-20 01:16:34 -05:00
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.log("Error fetching models:", error);
|
|
|
|
|
return Response.json(
|
|
|
|
|
{ error: { message: error.message, type: "server_error" } },
|
|
|
|
|
{ status: 500 }
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|