Feat : Skills
This commit is contained in:
@@ -665,7 +665,10 @@ export default function APIPageClient({ machineId }) {
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Endpoint Card */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold mb-4">API Endpoint</h2>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary">api</span>
|
||||
API Endpoint
|
||||
</h2>
|
||||
|
||||
{/* Endpoint rows */}
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -851,7 +854,10 @@ export default function APIPageClient({ machineId }) {
|
||||
{/* Token Saver (RTK + Caveman) */}
|
||||
<Card id="rtk">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-lg font-semibold">Token Saver</h2>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary">bolt</span>
|
||||
Token Saver
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2 pb-4 border-b border-border gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -922,7 +928,10 @@ export default function APIPageClient({ machineId }) {
|
||||
{/* API Keys */}
|
||||
<Card id="require-api-key">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">API Keys</h2>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary">vpn_key</span>
|
||||
API Keys
|
||||
</h2>
|
||||
<Button icon="add" onClick={() => setShowAddModal(true)}>
|
||||
Create Key
|
||||
</Button>
|
||||
|
||||
@@ -885,6 +885,10 @@ function GenericExampleCard({ providerId, kind }) {
|
||||
|
||||
// Get models for this kind (e.g., type="image")
|
||||
const kindModels = getModelsByProviderId(providerId).filter((m) => m.type === kind);
|
||||
// Kinds that need a model identifier in the request (image/video/music)
|
||||
const KIND_NEEDS_MODEL = new Set(["image", "video", "music", "stt", "imageToText"]);
|
||||
const needsModel = KIND_NEEDS_MODEL.has(kind);
|
||||
const allowManualModel = needsModel && kindModels.length === 0;
|
||||
const [selectedModel, setSelectedModel] = useState(kindModels[0]?.id ?? "");
|
||||
const selectedModelObj = kindModels.find((m) => m.id === selectedModel);
|
||||
const supportsEdit = !!selectedModelObj?.capabilities?.includes("edit");
|
||||
@@ -935,8 +939,10 @@ function GenericExampleCard({ providerId, kind }) {
|
||||
|
||||
const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
|
||||
const apiPath = kindConfig.endpoint.path;
|
||||
// For kinds without model concept (webSearch/webFetch), use providerAlias directly
|
||||
const modelFull = kindModels.length === 0 ? providerAlias : (selectedModel ? `${providerAlias}/${selectedModel}` : "");
|
||||
// webSearch/webFetch: use providerAlias only. Other kinds: append model when present.
|
||||
const modelFull = !needsModel
|
||||
? providerAlias
|
||||
: (selectedModel ? `${providerAlias}/${selectedModel}` : (allowManualModel ? "" : providerAlias));
|
||||
|
||||
// Build request body with optional extra fields (only non-empty values)
|
||||
const extraBodyFromFields = Object.entries(extraValues).reduce((acc, [k, v]) => {
|
||||
@@ -1060,8 +1066,8 @@ function GenericExampleCard({ providerId, kind }) {
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold mb-4">Example</h2>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{/* Model selector - only show if models available */}
|
||||
{kindModels.length > 0 && (
|
||||
{/* Model selector — dropdown if presets exist, else manual input for media kinds */}
|
||||
{kindModels.length > 0 ? (
|
||||
<Row label="Model">
|
||||
<select
|
||||
value={selectedModel}
|
||||
@@ -1073,7 +1079,16 @@ function GenericExampleCard({ providerId, kind }) {
|
||||
))}
|
||||
</select>
|
||||
</Row>
|
||||
)}
|
||||
) : allowManualModel ? (
|
||||
<Row label="Model">
|
||||
<input
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
placeholder="Enter model id (provider-specific)"
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
|
||||
/>
|
||||
</Row>
|
||||
) : null}
|
||||
|
||||
{/* Endpoint */}
|
||||
<Row label="Endpoint">
|
||||
|
||||
112
src/app/(dashboard)/dashboard/skills/page.js
Normal file
112
src/app/(dashboard)/dashboard/skills/page.js
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { Card, Badge } from "@/shared/components";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import {
|
||||
SKILLS,
|
||||
SKILLS_REPO_URL,
|
||||
getSkillRawUrl,
|
||||
getSkillBlobUrl,
|
||||
} from "@/shared/constants/skills";
|
||||
|
||||
function CopyButton({ value, label = "Copy link" }) {
|
||||
const { copied, copy } = useCopyToClipboard(2000);
|
||||
return (
|
||||
<button
|
||||
onClick={() => copy(value)}
|
||||
className="px-2 py-1 rounded-md bg-primary text-white text-[11px] font-medium hover:bg-primary/90 transition-colors cursor-pointer shrink-0 inline-flex items-center gap-1"
|
||||
title={value}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">
|
||||
{copied ? "check" : "content_copy"}
|
||||
</span>
|
||||
{copied ? "Copied!" : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillRow({ skill }) {
|
||||
const url = getSkillRawUrl(skill.id);
|
||||
return (
|
||||
<div
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
|
||||
skill.isEntry
|
||||
? "border-primary/40 bg-primary/5"
|
||||
: "border-border-subtle hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`size-9 rounded-lg flex items-center justify-center shrink-0 ${
|
||||
skill.isEntry ? "bg-primary text-white" : "bg-primary/10 text-primary"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">{skill.icon}</span>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-sm text-text-main">{skill.name}</h3>
|
||||
{skill.isEntry && (
|
||||
<Badge variant="primary" size="sm">START HERE</Badge>
|
||||
)}
|
||||
{skill.endpoint && (
|
||||
<Badge variant="default" size="sm">
|
||||
<code className="text-[10px]">{skill.endpoint}</code>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-0.5">{skill.description}</p>
|
||||
<a
|
||||
href={getSkillBlobUrl(skill.id)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[11px] text-text-muted hover:text-primary mt-1 inline-flex items-center gap-1 break-all"
|
||||
>
|
||||
{url}
|
||||
<span className="material-symbols-outlined text-[12px]">open_in_new</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<CopyButton value={url} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SkillsPage() {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<Card padding="md">
|
||||
<div className="text-xs text-text-muted mb-2">Paste this to your AI:</div>
|
||||
<div className="px-3 py-2 rounded bg-surface-2 font-mono text-[12px] text-text-main">
|
||||
Read this skill and use it: {getSkillRawUrl("9router")}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-2">
|
||||
{SKILLS.map((skill) => (
|
||||
<SkillRow key={skill.id} skill={skill} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card padding="md">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-main">More on GitHub</h2>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Browse source, README, and examples.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href={`${SKILLS_REPO_URL}/tree/main/app/skills`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">open_in_new</span>
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,38 +40,40 @@ async function probeWebProvider(provider, apiKey) {
|
||||
return res.status !== 401 && res.status !== 403;
|
||||
}
|
||||
|
||||
// Probe a tts/embedding provider using ttsConfig/embeddingConfig.
|
||||
// Returns true if API key is accepted (status !== 401 && !== 403); null to skip.
|
||||
// Probe a media provider (tts/embedding/stt/image/video) using *Config.
|
||||
// Returns true if API key is accepted; null to skip (let default handler decide).
|
||||
async function probeMediaProvider(provider, apiKey) {
|
||||
const p = AI_PROVIDERS[provider];
|
||||
if (!p) return null;
|
||||
// Only probe providers that are media-only (not LLM dual-purpose, let LLM validate handle those)
|
||||
const MEDIA_KINDS = new Set(["tts", "embedding", "stt", "image", "video", "music", "imageToText"]);
|
||||
const kinds = p.serviceKinds || ["llm"];
|
||||
const isMediaOnly = kinds.every((k) => k === "tts" || k === "embedding" || k === "stt");
|
||||
const isMediaOnly = kinds.every((k) => MEDIA_KINDS.has(k));
|
||||
if (!isMediaOnly) return null;
|
||||
const cfg = p.ttsConfig || p.embeddingConfig;
|
||||
if (!cfg) return null;
|
||||
const cfg = p.ttsConfig || p.embeddingConfig || p.imageConfig || p.videoConfig || p.musicConfig;
|
||||
// No probe config → best-effort accept (validate at usage time)
|
||||
if (!cfg) return true;
|
||||
if (p.noAuth || cfg.authType === "none") return true;
|
||||
// Skip auth schemes that need provider-specific data
|
||||
if (cfg.authHeader === "playht" || cfg.authHeader === "aws-sigv4") return null;
|
||||
if (cfg.authHeader === "playht" || cfg.authHeader === "aws-sigv4") return true;
|
||||
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
const headers = { "Content-Type": "application/json", ...(cfg.extraHeaders || {}) };
|
||||
|
||||
// Apply auth based on authHeader
|
||||
switch (cfg.authHeader) {
|
||||
case "bearer": headers["Authorization"] = `Bearer ${apiKey}`; break;
|
||||
case "key": headers["Authorization"] = `Key ${apiKey}`; break;
|
||||
case "x-api-key": headers["x-api-key"] = apiKey; break;
|
||||
case "x-key": headers["x-key"] = apiKey; break;
|
||||
case "xi-api-key": headers["xi-api-key"] = apiKey; break;
|
||||
case "token": headers["Authorization"] = `Token ${apiKey}`; break;
|
||||
case "basic": headers["Authorization"] = `Basic ${apiKey}`; break;
|
||||
default: return null;
|
||||
}
|
||||
|
||||
// Minimal POST body — server will reject auth before validating body
|
||||
const method = cfg.method || "POST";
|
||||
const res = await fetch(cfg.baseUrl, {
|
||||
method: "POST",
|
||||
method,
|
||||
headers,
|
||||
body: JSON.stringify({ input: "ping", text: "ping", model: cfg.models?.[0]?.id || "test" }),
|
||||
body: method === "GET" ? undefined : JSON.stringify({ input: "ping", text: "ping", prompt: "ping", model: cfg.models?.[0]?.id || "test" }),
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
return res.status !== 401 && res.status !== 403;
|
||||
|
||||
55
src/app/api/v1/models/[kind]/route.js
Normal file
55
src/app/api/v1/models/[kind]/route.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { buildModelsList } from "../route.js";
|
||||
|
||||
// URL slug → service kind(s). `web` covers both webSearch and webFetch.
|
||||
const KIND_SLUG_MAP = {
|
||||
"image": ["image"],
|
||||
"tts": ["tts"],
|
||||
"stt": ["stt"],
|
||||
"embedding": ["embedding"],
|
||||
"image-to-text": ["imageToText"],
|
||||
"web": ["webSearch", "webFetch"],
|
||||
};
|
||||
|
||||
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/{kind} - OpenAI-compatible models list filtered by capability.
|
||||
* Supported kinds: image, tts, stt, embedding, image-to-text, web.
|
||||
*/
|
||||
export async function GET(_request, { params }) {
|
||||
try {
|
||||
const { kind } = await params;
|
||||
const kindFilter = KIND_SLUG_MAP[kind];
|
||||
|
||||
if (!kindFilter) {
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
message: `Unknown model kind: ${kind}. Supported: ${Object.keys(KIND_SLUG_MAP).join(", ")}`,
|
||||
type: "invalid_request_error",
|
||||
},
|
||||
},
|
||||
{ status: 404, headers: { "Access-Control-Allow-Origin": "*" } }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await buildModelsList(kindFilter);
|
||||
return Response.json({ object: "list", data }, {
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error fetching models by kind:", error);
|
||||
return Response.json(
|
||||
{ error: { message: error.message, type: "server_error" } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -123,6 +123,13 @@ const getPageInfo = (pathname) => {
|
||||
icon: "lan",
|
||||
breadcrumbs: [],
|
||||
};
|
||||
if (pathname.includes("/skills"))
|
||||
return {
|
||||
title: "Agent Skills",
|
||||
description: "Copy a link and paste to your AI to use 9Router — no install needed",
|
||||
icon: "extension",
|
||||
breadcrumbs: [],
|
||||
};
|
||||
if (pathname.includes("/endpoint"))
|
||||
return {
|
||||
title: "Endpoint",
|
||||
|
||||
@@ -34,6 +34,7 @@ const debugItems = [
|
||||
|
||||
const systemItems = [
|
||||
{ href: "/dashboard/proxy-pools", label: "Proxy Pools", icon: "lan" },
|
||||
{ href: "/dashboard/skills", label: "Skills", icon: "extension" },
|
||||
];
|
||||
|
||||
export default function Sidebar({ onClose }) {
|
||||
@@ -171,14 +172,14 @@ export default function Sidebar({ onClose }) {
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto custom-scrollbar">
|
||||
<nav className="flex-1 px-4 py-2 space-y-0.5 overflow-y-auto custom-scrollbar">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all group",
|
||||
"flex items-center gap-3 px-3 py-1 rounded-lg transition-all group",
|
||||
isActive(item.href)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||
@@ -197,7 +198,7 @@ export default function Sidebar({ onClose }) {
|
||||
))}
|
||||
|
||||
{/* System section */}
|
||||
<div className="pt-4 mt-2 space-y-1">
|
||||
<div className="pt-3 mt-2 space-y-0.5">
|
||||
<p className="px-4 text-xs font-semibold text-text-muted/60 uppercase tracking-wider mb-2">
|
||||
System
|
||||
</p>
|
||||
@@ -206,7 +207,7 @@ export default function Sidebar({ onClose }) {
|
||||
<button
|
||||
onClick={() => setMediaOpen((v) => !v)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all group",
|
||||
"w-full flex items-center gap-3 px-3 py-1 rounded-lg transition-all group",
|
||||
pathname.startsWith("/dashboard/media-providers")
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||
@@ -226,7 +227,7 @@ export default function Sidebar({ onClose }) {
|
||||
href={`/dashboard/media-providers/${kind.id}`}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-1.5 rounded-lg transition-all group",
|
||||
"flex items-center gap-3 px-4 py-1 rounded-lg transition-all group",
|
||||
pathname.startsWith(`/dashboard/media-providers/${kind.id}`)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||
@@ -241,7 +242,7 @@ export default function Sidebar({ onClose }) {
|
||||
href={COMBINED_WEB_ITEM.href}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-1.5 rounded-lg transition-all group",
|
||||
"flex items-center gap-3 px-4 py-1 rounded-lg transition-all group",
|
||||
pathname.startsWith(COMBINED_WEB_ITEM.href)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||
@@ -259,7 +260,7 @@ export default function Sidebar({ onClose }) {
|
||||
href={item.href}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all group",
|
||||
"flex items-center gap-3 px-3 py-1 rounded-lg transition-all group",
|
||||
isActive(item.href)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||
@@ -286,7 +287,7 @@ export default function Sidebar({ onClose }) {
|
||||
href={item.href}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all group",
|
||||
"flex items-center gap-3 px-3 py-1 rounded-lg transition-all group",
|
||||
isActive(item.href)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||
@@ -310,7 +311,7 @@ export default function Sidebar({ onClose }) {
|
||||
href="/dashboard/profile"
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all group",
|
||||
"flex items-center gap-3 px-3 py-1 rounded-lg transition-all group",
|
||||
isActive("/dashboard/profile")
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||
|
||||
@@ -82,7 +82,7 @@ export const APIKEY_PROVIDERS = {
|
||||
hyperbolic: { id: "hyperbolic", alias: "hyp", name: "Hyperbolic", icon: "bolt", color: "#00D4FF", textIcon: "HY", website: "https://hyperbolic.xyz", notice: { apiKeyUrl: "https://app.hyperbolic.xyz/settings" }, serviceKinds: ["llm", "tts"], ttsConfig: { baseUrl: "https://api.hyperbolic.xyz/v1/audio/generation", authType: "apikey", authHeader: "bearer", format: "hyperbolic", models: [{ id: "melo-tts", name: "Melo TTS" }] } },
|
||||
deepgram: { id: "deepgram", alias: "dg", name: "Deepgram", icon: "mic", color: "#13EF93", textIcon: "DG", website: "https://deepgram.com", notice: { text: "$200 free credit on signup (no card required). Aura-1: $0.015/1k chars, Aura-2: $0.030/1k chars (Pay-As-You-Go).", apiKeyUrl: "https://console.deepgram.com/api-keys" }, serviceKinds: ["stt", "imageToText", "tts"], ttsConfig: { baseUrl: "https://api.deepgram.com/v1/speak", authType: "apikey", authHeader: "token", format: "deepgram", models: [] } },
|
||||
assemblyai: { id: "assemblyai", alias: "aai", name: "AssemblyAI", icon: "record_voice_over", color: "#0062FF", textIcon: "AA", website: "https://assemblyai.com", notice: { apiKeyUrl: "https://www.assemblyai.com/app/api-keys" }, serviceKinds: ["stt"] },
|
||||
nanobanana: { id: "nanobanana", alias: "nb", name: "NanoBanana", icon: "image", color: "#FFD700", textIcon: "NB", website: "https://nanobananaapi.ai", notice: { apiKeyUrl: "https://nanobananaapi.ai/dashboard" }, serviceKinds: ["image"] },
|
||||
nanobanana: { id: "nanobanana", alias: "nb", name: "NanoBanana API", icon: "extension", color: "#FFD700", textIcon: "🍌", website: "https://nanobananaapi.ai", notice: { text: "3rd-party proxy for Google Nano Banana (Gemini 2.5/3 Flash Image). For official, use Gemini provider.", apiKeyUrl: "https://nanobananaapi.ai/dashboard" }, serviceKinds: ["image"] },
|
||||
elevenlabs: { id: "elevenlabs", alias: "el", name: "ElevenLabs", icon: "record_voice_over", color: "#6C47FF", textIcon: "EL", website: "https://elevenlabs.io", notice: { apiKeyUrl: "https://elevenlabs.io/app/settings/api-keys" }, serviceKinds: ["tts"], ttsConfig: { baseUrl: "https://api.elevenlabs.io/v1/text-to-speech", authType: "apikey", authHeader: "xi-api-key", format: "elevenlabs", models: [{ id: "eleven_multilingual_v2", name: "Eleven Multilingual v2" }, { id: "eleven_turbo_v2_5", name: "Eleven Turbo v2.5" }] } },
|
||||
cartesia: { id: "cartesia", alias: "cartesia", name: "Cartesia", icon: "spatial_audio", color: "#FF4F8B", textIcon: "CA", website: "https://cartesia.ai", notice: { apiKeyUrl: "https://play.cartesia.ai/keys" }, serviceKinds: ["tts"], hidden: true, ttsConfig: { baseUrl: "https://api.cartesia.ai/tts/bytes", authType: "apikey", authHeader: "x-api-key", format: "cartesia", models: [{ id: "sonic-2", name: "Sonic 2" }, { id: "sonic-3", name: "Sonic 3" }] } },
|
||||
playht: { id: "playht", alias: "playht", name: "PlayHT", icon: "play_circle", color: "#00B4D8", textIcon: "PH", website: "https://play.ht", notice: { apiKeyUrl: "https://play.ht/studio/api-access" }, serviceKinds: ["tts"], hidden: true, ttsConfig: { baseUrl: "https://api.play.ht/api/v2/tts/stream", authType: "apikey", authHeader: "playht", format: "playht", models: [{ id: "PlayDialog", name: "PlayDialog" }, { id: "Play3.0-mini", name: "Play 3.0 Mini" }] } },
|
||||
@@ -110,6 +110,14 @@ export const APIKEY_PROVIDERS = {
|
||||
searchapi: { id: "searchapi", alias: "searchapi", name: "SearchAPI", icon: "search", color: "#0EA5A4", textIcon: "SA", website: "https://www.searchapi.io", notice: { apiKeyUrl: "https://www.searchapi.io/dashboard" }, serviceKinds: ["webSearch"], searchConfig: { baseUrl: "https://www.searchapi.io/api/v1/search", method: "GET", authType: "apikey", authHeader: "api_key", costPerQuery: 0.004, freeMonthlyQuota: 100, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 100, timeoutMs: 10000, cacheTTLMs: 300000 } },
|
||||
youcom: { id: "youcom", alias: "youcom", name: "You.com Search", icon: "search", color: "#7C3AED", textIcon: "YC", website: "https://you.com", notice: { apiKeyUrl: "https://api.you.com" }, serviceKinds: ["webSearch"], searchConfig: { baseUrl: "https://ydc-index.io/v1/search", method: "GET", authType: "apikey", authHeader: "x-api-key", costPerQuery: 0.005, freeMonthlyQuota: 0, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 100, timeoutMs: 10000, cacheTTLMs: 300000 } },
|
||||
firecrawl: { id: "firecrawl", alias: "firecrawl", name: "Firecrawl", icon: "local_fire_department", color: "#F59E0B", textIcon: "FC", website: "https://firecrawl.dev", notice: { apiKeyUrl: "https://www.firecrawl.dev/app/api-keys" }, serviceKinds: ["webFetch"], fetchConfig: { baseUrl: "https://api.firecrawl.dev/v1/scrape", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.002, freeMonthlyQuota: 500, formats: ["markdown", "html", "text"], maxCharacters: 200000, timeoutMs: 30000 } },
|
||||
"fal-ai": { id: "fal-ai", alias: "fal", name: "Fal.ai", icon: "image", color: "#2563EB", textIcon: "FL", website: "https://fal.ai", notice: { apiKeyUrl: "https://fal.ai/dashboard/keys" }, serviceKinds: ["image"], imageConfig: { baseUrl: "https://queue.fal.run/fal-ai/flux/schnell", method: "POST", authType: "apikey", authHeader: "key" } },
|
||||
"stability-ai": { id: "stability-ai", alias: "stability", name: "Stability AI", icon: "image", color: "#8B5CF6", textIcon: "SA", website: "https://stability.ai", notice: { apiKeyUrl: "https://platform.stability.ai/account/keys" }, serviceKinds: ["image"], imageConfig: { baseUrl: "https://api.stability.ai/v1/user/account", method: "GET", authType: "apikey", authHeader: "bearer" } },
|
||||
"black-forest-labs": { id: "black-forest-labs", alias: "bfl", name: "Black Forest Labs", icon: "image", color: "#111827", textIcon: "BF", website: "https://blackforestlabs.ai", notice: { apiKeyUrl: "https://api.bfl.ai" }, serviceKinds: ["image"], imageConfig: { baseUrl: "https://api.bfl.ai/v1/get_result?id=ping", method: "GET", authType: "apikey", authHeader: "x-key" } },
|
||||
recraft: { id: "recraft", alias: "recraft", name: "Recraft", icon: "image", color: "#EC4899", textIcon: "RC", website: "https://recraft.ai", notice: { apiKeyUrl: "https://www.recraft.ai/profile/api" }, serviceKinds: ["image"], imageConfig: { baseUrl: "https://external.api.recraft.ai/v1/users/me", method: "GET", authType: "apikey", authHeader: "bearer" } },
|
||||
topaz: { id: "topaz", alias: "topaz", name: "Topaz", icon: "image", color: "#059669", textIcon: "TP", website: "https://topazlabs.com", notice: { apiKeyUrl: "https://topazlabs.com/account" }, serviceKinds: ["image"] },
|
||||
runwayml: { id: "runwayml", alias: "runway", name: "Runway ML", icon: "movie", color: "#000000", textIcon: "RW", website: "https://runwayml.com", notice: { apiKeyUrl: "https://dev.runwayml.com" }, serviceKinds: ["image", "video"], imageConfig: { baseUrl: "https://api.dev.runwayml.com/v1/organization", method: "GET", authType: "apikey", authHeader: "bearer", extraHeaders: { "X-Runway-Version": "2024-11-06" } } },
|
||||
"aws-polly": { id: "aws-polly", alias: "polly", name: "AWS Polly", icon: "record_voice_over", color: "#FF9900", textIcon: "PL", website: "https://aws.amazon.com/polly/", notice: { text: "Use AWS Secret Access Key as API key; set providerSpecificData.accessKeyId and optional region.", apiKeyUrl: "https://console.aws.amazon.com/iam/home#/security_credentials" }, serviceKinds: ["tts"], hasProviderSpecificData: true, ttsConfig: { baseUrl: "https://polly.{region}.amazonaws.com/v1/speech", authType: "apikey", authHeader: "aws-sigv4", format: "aws-polly", models: [{ id: "standard", name: "Standard" }, { id: "neural", name: "Neural" }, { id: "long-form", name: "Long-form" }, { id: "generative", name: "Generative" }] } },
|
||||
"jina-ai": { id: "jina-ai", alias: "jina", name: "Jina AI", icon: "blur_on", color: "#2563EB", textIcon: "JA", website: "https://jina.ai", notice: { text: "10M free tokens on signup (non-commercial), no credit card required.", apiKeyUrl: "https://jina.ai/?sui=apikey" }, serviceKinds: ["embedding"], embeddingConfig: { baseUrl: "https://api.jina.ai/v1/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "jina-embeddings-v3", name: "Jina Embeddings v3", dimensions: 1024 }, { id: "jina-embeddings-v2-base-en", name: "Jina Embeddings v2 Base EN", dimensions: 768 }, { id: "jina-embeddings-v2-base-code", name: "Jina Embeddings v2 Base Code", dimensions: 768 }] } },
|
||||
"jina-reader": { id: "jina-reader", alias: "jina", name: "Jina Reader", icon: "menu_book", color: "#000000", textIcon: "JR", website: "https://jina.ai/reader", notice: { apiKeyUrl: "https://jina.ai/?sui=apikey" }, serviceKinds: ["webFetch"], fetchConfig: { baseUrl: "https://r.jina.ai", method: "GET", authType: "apikey", authHeader: "bearer", costPerQuery: 0, freeMonthlyQuota: 1000000, formats: ["markdown", "text", "html"], maxCharacters: 200000, timeoutMs: 30000 } },
|
||||
};
|
||||
|
||||
|
||||
71
src/shared/constants/skills.js
Normal file
71
src/shared/constants/skills.js
Normal file
@@ -0,0 +1,71 @@
|
||||
// Agent Skills metadata — single source of truth for /dashboard/skills page.
|
||||
// Each skill = 1 raw GitHub URL the user copies and pastes to any AI agent.
|
||||
|
||||
const REPO = "decolua/9router";
|
||||
const BRANCH = "main";
|
||||
const SKILL_PATH = "app/skills";
|
||||
|
||||
export const SKILLS_REPO_URL = `https://github.com/${REPO}`;
|
||||
export const SKILLS_RAW_BASE = `https://raw.githubusercontent.com/${REPO}/${BRANCH}/${SKILL_PATH}`;
|
||||
export const SKILLS_BLOB_BASE = `https://github.com/${REPO}/blob/${BRANCH}/${SKILL_PATH}`;
|
||||
|
||||
export const SKILLS = [
|
||||
{
|
||||
id: "9router",
|
||||
name: "9Router (Entry)",
|
||||
description: "Setup + index of all capabilities. Start here — covers base URL, auth, model discovery, and links to every capability skill.",
|
||||
endpoint: null,
|
||||
icon: "hub",
|
||||
isEntry: true,
|
||||
},
|
||||
{
|
||||
id: "9router-chat",
|
||||
name: "Chat",
|
||||
description: "Chat / code-gen via OpenAI or Anthropic format with streaming.",
|
||||
endpoint: "/v1/chat/completions",
|
||||
icon: "chat",
|
||||
},
|
||||
{
|
||||
id: "9router-image",
|
||||
name: "Image Generation",
|
||||
description: "Text-to-image via DALL-E, Imagen, FLUX, MiniMax, SDWebUI…",
|
||||
endpoint: "/v1/images/generations",
|
||||
icon: "image",
|
||||
},
|
||||
{
|
||||
id: "9router-tts",
|
||||
name: "Text-to-Speech",
|
||||
description: "OpenAI / ElevenLabs / Edge / Google / Deepgram voices.",
|
||||
endpoint: "/v1/audio/speech",
|
||||
icon: "record_voice_over",
|
||||
},
|
||||
{
|
||||
id: "9router-embeddings",
|
||||
name: "Embeddings",
|
||||
description: "Vectors for RAG / semantic search via OpenAI, Gemini, Mistral…",
|
||||
endpoint: "/v1/embeddings",
|
||||
icon: "scatter_plot",
|
||||
},
|
||||
{
|
||||
id: "9router-web-search",
|
||||
name: "Web Search",
|
||||
description: "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.",
|
||||
endpoint: "/v1/search",
|
||||
icon: "search",
|
||||
},
|
||||
{
|
||||
id: "9router-web-fetch",
|
||||
name: "Web Fetch",
|
||||
description: "URL → markdown / text / HTML via Firecrawl, Jina, Tavily, Exa.",
|
||||
endpoint: "/v1/web/fetch",
|
||||
icon: "language",
|
||||
},
|
||||
];
|
||||
|
||||
export function getSkillRawUrl(id) {
|
||||
return `${SKILLS_RAW_BASE}/${id}/SKILL.md`;
|
||||
}
|
||||
|
||||
export function getSkillBlobUrl(id) {
|
||||
return `${SKILLS_BLOB_BASE}/${id}/SKILL.md`;
|
||||
}
|
||||
Reference in New Issue
Block a user