mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
Feat : Skills
This commit is contained in:
+304
-195
@@ -1,5 +1,10 @@
|
||||
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
|
||||
import { getProviderAlias, isAnthropicCompatibleProvider, isOpenAICompatibleProvider } from "@/shared/constants/providers";
|
||||
import {
|
||||
AI_PROVIDERS,
|
||||
getProviderAlias,
|
||||
isAnthropicCompatibleProvider,
|
||||
isOpenAICompatibleProvider,
|
||||
} from "@/shared/constants/providers";
|
||||
import { getProviderConnections, getCombos, getCustomModels, getModelAliases } from "@/lib/localDb";
|
||||
|
||||
const parseOpenAIStyleModels = (data) => {
|
||||
@@ -10,6 +15,34 @@ const parseOpenAIStyleModels = (data) => {
|
||||
// Matches provider IDs that are upstream/cross-instance connections (contain a UUID suffix)
|
||||
const UPSTREAM_CONNECTION_RE = /[-_][0-9a-f]{8,}$/i;
|
||||
|
||||
// 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",
|
||||
};
|
||||
|
||||
function modelKind(model) {
|
||||
if (!model?.type) return LLM_KIND;
|
||||
return MODEL_TYPE_TO_KIND[model.type] || LLM_KIND;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
async function fetchCompatibleModelIds(connection) {
|
||||
if (!connection?.apiKey) return [];
|
||||
|
||||
@@ -67,6 +100,271 @@ async function fetchCompatibleModelIds(connection) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
let connections = [];
|
||||
try {
|
||||
connections = await getProviderConnections();
|
||||
connections = connections.filter(c => c.isActive !== false);
|
||||
} catch (e) {
|
||||
console.log("Could not fetch providers, returning all models");
|
||||
}
|
||||
|
||||
let combos = [];
|
||||
try {
|
||||
combos = await getCombos();
|
||||
} catch (e) {
|
||||
console.log("Could not fetch combos");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
const activeConnectionByProvider = new Map();
|
||||
for (const conn of connections) {
|
||||
if (!activeConnectionByProvider.has(conn.provider)) {
|
||||
activeConnectionByProvider.set(conn.provider, conn);
|
||||
}
|
||||
}
|
||||
|
||||
const models = [];
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
// 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",
|
||||
created: timestamp,
|
||||
owned_by: "combo",
|
||||
};
|
||||
if (combo.kind === "webSearch" || combo.kind === "webFetch") {
|
||||
entry.kind = combo.kind;
|
||||
}
|
||||
models.push(entry);
|
||||
}
|
||||
|
||||
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;
|
||||
models.push({
|
||||
id: `${alias}/${model.id}`,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: alias,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
models.push({
|
||||
id: `${providerAlias}/${modelId}`,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: providerAlias,
|
||||
});
|
||||
}
|
||||
} 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)])
|
||||
);
|
||||
|
||||
let rawModelIds = hasExplicitEnabledModels
|
||||
? Array.from(
|
||||
new Set(
|
||||
enabledModels.filter(
|
||||
(modelId) => typeof modelId === "string" && modelId.trim() !== "",
|
||||
),
|
||||
),
|
||||
)
|
||||
: providerModels.map((model) => model.id);
|
||||
|
||||
if (isCompatibleProvider && rawModelIds.length === 0 && !UPSTREAM_CONNECTION_RE.test(providerId)) {
|
||||
rawModelIds = await fetchCompatibleModelIds(conn);
|
||||
}
|
||||
|
||||
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() !== "");
|
||||
|
||||
const customModelIds = customModels
|
||||
.filter((m) => {
|
||||
if (!m?.id || (m.type && m.type !== "llm")) return false;
|
||||
const alias = m.providerAlias;
|
||||
return alias === staticAlias || alias === outputAlias || alias === providerId;
|
||||
})
|
||||
.map((m) => String(m.id).trim())
|
||||
.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) {
|
||||
// Resolve kind: prefer static metadata, otherwise infer from ID heuristics
|
||||
const kind = staticModelKindById.get(modelId) || inferKindFromUnknownModelId(modelId);
|
||||
if (!kindFilter.includes(kind)) continue;
|
||||
|
||||
models.push({
|
||||
id: `${outputAlias}/${modelId}`,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: outputAlias,
|
||||
});
|
||||
}
|
||||
|
||||
// Merge sub-config models (TTS / embedding) that live on AI_PROVIDERS, not PROVIDER_MODELS
|
||||
const providerInfo = AI_PROVIDERS[providerId];
|
||||
const subConfigModels = [];
|
||||
if (kindFilter.includes("tts") && Array.isArray(providerInfo?.ttsConfig?.models)) {
|
||||
for (const m of providerInfo.ttsConfig.models) {
|
||||
if (m?.id) subConfigModels.push(m.id);
|
||||
}
|
||||
}
|
||||
if (kindFilter.includes("embedding") && Array.isArray(providerInfo?.embeddingConfig?.models)) {
|
||||
for (const m of providerInfo.embeddingConfig.models) {
|
||||
if (m?.id) subConfigModels.push(m.id);
|
||||
}
|
||||
}
|
||||
for (const subId of subConfigModels) {
|
||||
models.push({
|
||||
id: `${outputAlias}/${subId}`,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: outputAlias,
|
||||
});
|
||||
}
|
||||
|
||||
// Web search/fetch — provider IS the model, expose as {alias}/search and/or {alias}/fetch with explicit kind
|
||||
if (kindFilter.includes("webSearch") && providerInfo?.searchConfig) {
|
||||
models.push({
|
||||
id: `${outputAlias}/search`,
|
||||
object: "model",
|
||||
kind: "webSearch",
|
||||
created: timestamp,
|
||||
owned_by: outputAlias,
|
||||
});
|
||||
}
|
||||
if (kindFilter.includes("webFetch") && providerInfo?.fetchConfig) {
|
||||
models.push({
|
||||
id: `${outputAlias}/fetch`,
|
||||
object: "model",
|
||||
kind: "webFetch",
|
||||
created: timestamp,
|
||||
owned_by: outputAlias,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
@@ -81,203 +379,14 @@ export async function OPTIONS() {
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /v1/models - OpenAI compatible models list
|
||||
* Returns models from all active providers and combos in OpenAI format
|
||||
* 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() {
|
||||
try {
|
||||
// Get active provider connections
|
||||
let connections = [];
|
||||
try {
|
||||
connections = await getProviderConnections();
|
||||
// Filter to only active connections
|
||||
connections = connections.filter(c => c.isActive !== false);
|
||||
} catch (e) {
|
||||
// If database not available, return all models
|
||||
console.log("Could not fetch providers, returning all models");
|
||||
}
|
||||
|
||||
// Get combos
|
||||
let combos = [];
|
||||
try {
|
||||
combos = await getCombos();
|
||||
} catch (e) {
|
||||
console.log("Could not fetch combos");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
// Build first active connection per provider (connections already sorted by priority)
|
||||
const activeConnectionByProvider = new Map();
|
||||
for (const conn of connections) {
|
||||
if (!activeConnectionByProvider.has(conn.provider)) {
|
||||
activeConnectionByProvider.set(conn.provider, conn);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect models from active providers (or all if none active)
|
||||
const models = [];
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Add combos first (they appear at the top)
|
||||
for (const combo of combos) {
|
||||
models.push({
|
||||
id: combo.name,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: "combo",
|
||||
});
|
||||
}
|
||||
|
||||
// Add provider models
|
||||
if (connections.length === 0) {
|
||||
// DB unavailable or no active providers -> return all static models
|
||||
for (const [alias, providerModels] of Object.entries(PROVIDER_MODELS)) {
|
||||
for (const model of providerModels) {
|
||||
models.push({
|
||||
id: `${alias}/${model.id}`,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: alias,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Also include custom LLM models when no active connections are available.
|
||||
for (const customModel of customModels) {
|
||||
if (!customModel?.id || (customModel.type && customModel.type !== "llm")) continue;
|
||||
const providerAlias = customModel.providerAlias;
|
||||
if (!providerAlias) continue;
|
||||
|
||||
const modelId = String(customModel.id).trim();
|
||||
if (!modelId) continue;
|
||||
|
||||
models.push({
|
||||
id: `${providerAlias}/${modelId}`,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: providerAlias,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (const [providerId, conn] of activeConnectionByProvider.entries()) {
|
||||
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);
|
||||
|
||||
// Default: if no explicit selection, all static models are active.
|
||||
// For compatible providers with no explicit selection, fetch remote /models dynamically.
|
||||
// If explicit selection exists, expose exactly those model IDs (including non-static IDs).
|
||||
let rawModelIds = hasExplicitEnabledModels
|
||||
? Array.from(
|
||||
new Set(
|
||||
enabledModels.filter(
|
||||
(modelId) => typeof modelId === "string" && modelId.trim() !== "",
|
||||
),
|
||||
),
|
||||
)
|
||||
: providerModels.map((model) => model.id);
|
||||
|
||||
if (isCompatibleProvider && rawModelIds.length === 0 && !UPSTREAM_CONNECTION_RE.test(providerId)) {
|
||||
rawModelIds = await fetchCompatibleModelIds(conn);
|
||||
}
|
||||
|
||||
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() !== "");
|
||||
|
||||
const customModelIds = customModels
|
||||
.filter((m) => {
|
||||
if (!m?.id || (m.type && m.type !== "llm")) return false;
|
||||
const alias = m.providerAlias;
|
||||
return alias === staticAlias || alias === outputAlias || alias === providerId;
|
||||
})
|
||||
.map((m) => String(m.id).trim())
|
||||
.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) {
|
||||
models.push({
|
||||
id: `${outputAlias}/${modelId}`,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: outputAlias,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 Response.json({
|
||||
object: "list",
|
||||
data: dedupedModels,
|
||||
}, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
const data = await buildModelsList([LLM_KIND]);
|
||||
return Response.json({ object: "list", data }, {
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error fetching models:", error);
|
||||
|
||||
Reference in New Issue
Block a user