- Cowork: ComboFormModal
- BaseUrlSelect: add cloud endpoint option, custom URL local state, always default to first option; new cliEndpointMatch helper; CLI tool cards refactor - API: new /v1/audio/voices and /v1/models/info; /v1/models filters disabled models, drop unused timestamp - initializeApp: guard tunnel/tailscale auto-resume to once-per-process - geminiHelper: ensureObjectType for schemas with properties but no type - skills: minor SKILL.md tweaks (chat/embeddings/image/stt/tts/web-*)
This commit is contained in:
104
src/app/api/cli-tools/cowork-mcp-registry/route.js
Normal file
104
src/app/api/cli-tools/cowork-mcp-registry/route.js
Normal file
@@ -0,0 +1,104 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const REGISTRY_URL = "https://api.anthropic.com/mcp-registry/v0/servers";
|
||||
const VISIBILITY = "commercial,gsuite,gsuite-google";
|
||||
const PLUGINS_REPO = "anthropics/knowledge-work-plugins";
|
||||
const GH_API = "https://api.github.com";
|
||||
const GH_RAW = "https://raw.githubusercontent.com";
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000; // 1h
|
||||
|
||||
const G_KEY = "__9routerCoworkMcpRegistryCache";
|
||||
function gcache() {
|
||||
if (!globalThis[G_KEY]) globalThis[G_KEY] = { ts: 0, data: null };
|
||||
return globalThis[G_KEY];
|
||||
}
|
||||
|
||||
// Fetch full registry across pagination
|
||||
async function fetchRegistry() {
|
||||
const out = [];
|
||||
let cursor = "";
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const url = `${REGISTRY_URL}?limit=500&visibility=${VISIBILITY}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
|
||||
const r = await fetch(url, { headers: { "accept": "application/json" } });
|
||||
if (!r.ok) break;
|
||||
const j = await r.json();
|
||||
for (const item of j.servers || []) {
|
||||
const s = item.server || {};
|
||||
const remote = (s.remotes || [])[0];
|
||||
if (!remote?.url) continue;
|
||||
const transport = remote.type === "streamable-http" ? "http" : (remote.type === "sse" ? "sse" : "http");
|
||||
out.push({
|
||||
source: "registry",
|
||||
name: s.name,
|
||||
title: s.title || s.name,
|
||||
description: s.description || "",
|
||||
url: remote.url,
|
||||
transport,
|
||||
});
|
||||
}
|
||||
cursor = j.metadata?.nextCursor;
|
||||
if (!cursor) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Fetch plugins from anthropics/knowledge-work-plugins. Each plugin folder contains
|
||||
// .claude-plugin/plugin.json with mcp_servers map.
|
||||
async function fetchPlugins() {
|
||||
const r = await fetch(`${GH_API}/repos/${PLUGINS_REPO}/contents/`, { headers: { "accept": "application/vnd.github.v3+json" } });
|
||||
if (!r.ok) return [];
|
||||
const items = await r.json();
|
||||
const dirs = items.filter((i) => i.type === "dir" && !i.name.startsWith(".") && i.name !== "partner-built");
|
||||
const out = [];
|
||||
await Promise.all(dirs.map(async (d) => {
|
||||
try {
|
||||
const url = `${GH_RAW}/${PLUGINS_REPO}/main/${d.name}/.claude-plugin/plugin.json`;
|
||||
const pr = await fetch(url);
|
||||
if (!pr.ok) return;
|
||||
const pj = await pr.json();
|
||||
const servers = pj.mcp_servers || pj.mcpServers || {};
|
||||
for (const [key, srv] of Object.entries(servers)) {
|
||||
if (!srv?.url || typeof srv.url !== "string") continue;
|
||||
if (!/^https?:\/\//i.test(srv.url)) continue;
|
||||
const transport = /\/sse(\b|\/)/i.test(srv.url) ? "sse" : (srv.type === "sse" ? "sse" : "http");
|
||||
out.push({
|
||||
source: "plugins",
|
||||
plugin: d.name,
|
||||
name: `${d.name}-${key}`,
|
||||
title: pj.name || d.name,
|
||||
description: pj.description || "",
|
||||
url: srv.url,
|
||||
transport,
|
||||
});
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}));
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function GET(request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const force = searchParams.get("refresh") === "1";
|
||||
const cache = gcache();
|
||||
if (!force && cache.data && Date.now() - cache.ts < CACHE_TTL_MS) {
|
||||
return NextResponse.json({ cached: true, ...cache.data });
|
||||
}
|
||||
try {
|
||||
const [registry, plugins] = await Promise.all([fetchRegistry(), fetchPlugins()]);
|
||||
// Deduplicate by url
|
||||
const seen = new Set();
|
||||
const merged = [...registry, ...plugins].filter((s) => {
|
||||
if (seen.has(s.url)) return false;
|
||||
seen.add(s.url);
|
||||
return true;
|
||||
});
|
||||
const data = { servers: merged, counts: { registry: registry.length, plugins: plugins.length, total: merged.length } };
|
||||
cache.ts = Date.now();
|
||||
cache.data = data;
|
||||
return NextResponse.json({ cached: false, ...data });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: e.message, servers: [], counts: { total: 0 } }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,115 @@ import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import crypto from "crypto";
|
||||
import { COWORK_PLUGINS, buildManagedMcpServers } from "@/shared/constants/coworkPlugins";
|
||||
|
||||
const PROVIDER = "gateway";
|
||||
|
||||
// Plugin folder mount location.
|
||||
// Claude Cowork 3p actually launches with --user-data-dir=Claude-3p, so plugins
|
||||
// must live there (not the system /Library path which requires admin & isn't read in 3p).
|
||||
const getOrgPluginsCandidates = () => {
|
||||
if (os.platform() === "darwin") {
|
||||
const home = os.homedir();
|
||||
return [
|
||||
path.join(home, "Library", "Application Support", "Claude-3p", "org-plugins"),
|
||||
path.join(home, "Library", "Application Support", "Claude", "org-plugins"),
|
||||
"/Library/Application Support/Claude/org-plugins",
|
||||
];
|
||||
}
|
||||
if (os.platform() === "win32") {
|
||||
const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
||||
const programData = process.env.ProgramData || "C:\\ProgramData";
|
||||
return [
|
||||
path.join(localApp, "Claude-3p", "org-plugins"),
|
||||
path.join(localApp, "Claude", "org-plugins"),
|
||||
path.join(programData, "Claude", "org-plugins"),
|
||||
];
|
||||
}
|
||||
return [path.join(os.homedir(), ".config", "Claude-3p", "org-plugins"), "/etc/Claude/org-plugins"];
|
||||
};
|
||||
|
||||
// Pick first writable candidate for org-plugins
|
||||
async function pickPluginsRoot() {
|
||||
for (const dir of getOrgPluginsCandidates()) {
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
// Probe write
|
||||
const probe = path.join(dir, ".__9router_probe");
|
||||
await fs.writeFile(probe, "ok");
|
||||
await fs.unlink(probe);
|
||||
return dir;
|
||||
} catch { /* try next */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create plugin folder mount: org-plugins/<name>/claude-plugin/{plugin.json, version.json, .mcp.json}
|
||||
async function writeOrgPluginsFolder(selectedPluginNames) {
|
||||
const root = await pickPluginsRoot();
|
||||
if (!root) return { error: "no_writable_plugins_dir", written: [] };
|
||||
const set = new Set(selectedPluginNames || []);
|
||||
const selectedPlugins = COWORK_PLUGINS.filter((p) => set.has(p.name));
|
||||
// Remove previously-managed plugin subfolders (best-effort)
|
||||
for (const p of COWORK_PLUGINS) {
|
||||
try { await fs.rm(path.join(root, p.name), { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
const written = [];
|
||||
for (const p of selectedPlugins) {
|
||||
const pluginRoot = path.join(root, p.name);
|
||||
const metaDir = path.join(pluginRoot, ".claude-plugin");
|
||||
try {
|
||||
await fs.mkdir(metaDir, { recursive: true });
|
||||
const manifest = { name: p.name, version: "1.0.0", description: p.description || p.name, author: { name: "9router" } };
|
||||
await fs.writeFile(path.join(metaDir, "plugin.json"), JSON.stringify(manifest, null, 2));
|
||||
// .mcp.json at plugin root, schema: {mcpServers: {name: {type, url, oauth?}}}
|
||||
const mcpServers = {};
|
||||
for (const s of p.servers) {
|
||||
const key = p.servers.length === 1 ? p.name : `${p.name}-${s.key}`;
|
||||
mcpServers[key] = {
|
||||
type: /\/sse(\b|\/)/i.test(s.url) ? "sse" : "http",
|
||||
url: s.url,
|
||||
};
|
||||
}
|
||||
await fs.writeFile(path.join(pluginRoot, ".mcp.json"), JSON.stringify({ mcpServers }, null, 2));
|
||||
written.push(p.name);
|
||||
} catch (e) {
|
||||
return { error: e.code || e.message, written, root };
|
||||
}
|
||||
}
|
||||
return { written, root };
|
||||
}
|
||||
|
||||
// Set operonSkipMcpApprovals[serverName]=true in Claude-3p/config.json so user
|
||||
// is not prompted for every tool call. Mirrors mcpToolAccessProvider.setSkipApprovals.
|
||||
async function writeSkipApprovals(managedServers) {
|
||||
const cfgPath = path.join(getWriteRoot(), "config.json");
|
||||
let cfg = {};
|
||||
try {
|
||||
cfg = JSON.parse(await fs.readFile(cfgPath, "utf-8")) || {};
|
||||
} catch (e) {
|
||||
if (e.code !== "ENOENT") return { error: e.code };
|
||||
}
|
||||
// Reset previous managed entries (those we own == COWORK_PLUGINS server names)
|
||||
const ownedNames = new Set();
|
||||
for (const p of COWORK_PLUGINS) {
|
||||
for (const s of p.servers) {
|
||||
ownedNames.add(p.servers.length === 1 ? p.name : `${p.name}-${s.key}`);
|
||||
}
|
||||
}
|
||||
const skip = (cfg.operonSkipMcpApprovals && typeof cfg.operonSkipMcpApprovals === "object") ? cfg.operonSkipMcpApprovals : {};
|
||||
for (const k of Object.keys(skip)) {
|
||||
if (ownedNames.has(k)) delete skip[k];
|
||||
}
|
||||
for (const srv of managedServers) {
|
||||
if (srv?.name) skip[srv.name] = true;
|
||||
}
|
||||
cfg.operonSkipMcpApprovals = skip;
|
||||
await fs.mkdir(getWriteRoot(), { recursive: true });
|
||||
await fs.writeFile(cfgPath, JSON.stringify(cfg, null, 2));
|
||||
return { written: Object.keys(skip).length };
|
||||
}
|
||||
|
||||
// Candidate user-data roots — Cowork can run from either Claude-3p (3p mode) or Claude (1p mode w/ cowork features)
|
||||
const getCandidateRoots = () => {
|
||||
if (os.platform() === "darwin") {
|
||||
@@ -107,8 +213,6 @@ const checkInstalled = async () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
const isLocalhostUrl = (url) => /localhost|127\.0\.0\.1|0\.0\.0\.0/i.test(url || "");
|
||||
|
||||
const readJson = async (filePath) => {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
@@ -160,6 +264,12 @@ export async function GET() {
|
||||
? config.inferenceModels.map((m) => (typeof m === "string" ? m : m?.name)).filter(Boolean)
|
||||
: [];
|
||||
|
||||
// managedMcpServers stored as native array in configLibrary <uuid>.json
|
||||
const managedMcpArr = Array.isArray(config?.managedMcpServers) ? config.managedMcpServers : [];
|
||||
const selectedPlugins = COWORK_PLUGINS
|
||||
.filter((p) => p.servers.some((s) => managedMcpArr.some((v) => v?.url === s.url)))
|
||||
.map((p) => p.name);
|
||||
|
||||
const has9Router = !!(config?.inferenceProvider === PROVIDER && baseUrl);
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -172,7 +282,9 @@ export async function GET() {
|
||||
baseUrl,
|
||||
models,
|
||||
provider: config?.inferenceProvider || null,
|
||||
selectedPlugins,
|
||||
},
|
||||
availablePlugins: COWORK_PLUGINS.map((p) => ({ name: p.name, description: p.description })),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error reading cowork settings:", error);
|
||||
@@ -182,23 +294,20 @@ export async function GET() {
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { baseUrl, apiKey, models } = await request.json();
|
||||
const { baseUrl, apiKey, models, plugins } = await request.json();
|
||||
|
||||
if (!baseUrl || !apiKey) {
|
||||
return NextResponse.json({ error: "baseUrl and apiKey are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (isLocalhostUrl(baseUrl)) {
|
||||
return NextResponse.json({
|
||||
error: "Claude Cowork sandbox cannot reach localhost. Enable Tunnel/Cloud Endpoint or use Tailscale/VPS.",
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const modelsArray = Array.isArray(models) ? models.filter((m) => typeof m === "string" && m.trim()) : [];
|
||||
if (modelsArray.length === 0) {
|
||||
return NextResponse.json({ error: "At least one model is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const pluginsArray = Array.isArray(plugins) ? plugins.filter((p) => typeof p === "string") : [];
|
||||
const managedMcpServers = buildManagedMcpServers(pluginsArray);
|
||||
|
||||
const bootstrapped = await bootstrapDeploymentMode();
|
||||
const meta = await ensureMeta();
|
||||
const configPath = path.join(getWriteConfigDir(), `${meta.appliedId}.json`);
|
||||
@@ -208,10 +317,21 @@ export async function POST(request) {
|
||||
inferenceGatewayBaseUrl: baseUrl,
|
||||
inferenceGatewayApiKey: apiKey,
|
||||
inferenceModels: modelsArray.map((name) => ({ name })),
|
||||
isLocalDevMcpEnabled: true,
|
||||
isDesktopExtensionEnabled: true,
|
||||
};
|
||||
if (managedMcpServers.length > 0) {
|
||||
newConfig.managedMcpServers = managedMcpServers;
|
||||
}
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify(newConfig, null, 2));
|
||||
|
||||
// Plugin folder mount (best-effort, doesn't fail the request)
|
||||
const pluginsResult = await writeOrgPluginsFolder(pluginsArray);
|
||||
// Auto-skip approvals for managed servers
|
||||
let skipResult = null;
|
||||
try { skipResult = await writeSkipApprovals(managedMcpServers); } catch (e) { skipResult = { error: e.message }; }
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
bootstrapped,
|
||||
@@ -219,6 +339,8 @@ export async function POST(request) {
|
||||
? "Cowork enabled (3p mode set). Quit & reopen Claude Desktop."
|
||||
: "Cowork settings applied. Quit & reopen Claude Desktop.",
|
||||
configPath,
|
||||
plugins: pluginsResult,
|
||||
skipApprovals: skipResult,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error applying cowork settings:", error);
|
||||
@@ -238,6 +360,8 @@ export async function DELETE() {
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
}
|
||||
await writeOrgPluginsFolder([]);
|
||||
try { await writeSkipApprovals([]); } catch { /* ignore */ }
|
||||
return NextResponse.json({ success: true, message: "Cowork config reset" });
|
||||
} catch (error) {
|
||||
console.log("Error resetting cowork settings:", error);
|
||||
|
||||
68
src/app/api/v1/audio/voices/route.js
Normal file
68
src/app/api/v1/audio/voices/route.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
|
||||
// Provider → internal voices API. Edge/local-device share the generic endpoint.
|
||||
const PROVIDER_API = {
|
||||
elevenlabs: (origin) => `${origin}/api/media-providers/tts/elevenlabs/voices`,
|
||||
deepgram: (origin) => `${origin}/api/media-providers/tts/deepgram/voices`,
|
||||
inworld: (origin) => `${origin}/api/media-providers/tts/inworld/voices`,
|
||||
"edge-tts": (origin) => `${origin}/api/media-providers/tts/voices?provider=edge-tts`,
|
||||
"local-device": (origin) => `${origin}/api/media-providers/tts/voices?provider=local-device`,
|
||||
};
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS" },
|
||||
});
|
||||
}
|
||||
|
||||
// GET /v1/audio/voices?provider={p}[&lang=xx]
|
||||
// Returns OpenAI-style list with each voice's full model id ready for /v1/audio/speech
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const { searchParams, origin } = new URL(request.url);
|
||||
const provider = searchParams.get("provider");
|
||||
const lang = searchParams.get("lang");
|
||||
|
||||
if (!provider || !PROVIDER_API[provider]) {
|
||||
return Response.json(
|
||||
{ error: { message: `provider must be one of: ${Object.keys(PROVIDER_API).join(", ")}`, type: "invalid_request_error" } },
|
||||
{ status: 400, headers: { "Access-Control-Allow-Origin": "*" } },
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = PROVIDER_API[provider](origin);
|
||||
const url = lang ? `${baseUrl}${baseUrl.includes("?") ? "&" : "?"}lang=${encodeURIComponent(lang)}` : baseUrl;
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) {
|
||||
return Response.json(
|
||||
{ error: { message: data.error || `Upstream ${res.status}`, type: "server_error" } },
|
||||
{ status: res.status, headers: { "Access-Control-Allow-Origin": "*" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Internal API shape: { voices } when lang filter, else { byLang, languages }
|
||||
const rawVoices = lang
|
||||
? (data.voices || [])
|
||||
: Object.values(data.byLang || {}).flatMap((l) => l.voices || []);
|
||||
|
||||
// Use provider alias for /v1/audio/speech model param (matches skill convention e.g. el/, dg/, edge-tts/)
|
||||
const alias = AI_PROVIDERS[provider]?.alias || provider;
|
||||
const data_out = rawVoices.map((v) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
lang: v.lang || "",
|
||||
gender: v.gender || "",
|
||||
model: `${alias}/${v.id}`,
|
||||
}));
|
||||
|
||||
return Response.json({ object: "list", data: data_out }, {
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: { message: err.message || "Failed", type: "server_error" } },
|
||||
{ status: 502, headers: { "Access-Control-Allow-Origin": "*" } },
|
||||
);
|
||||
}
|
||||
}
|
||||
110
src/app/api/v1/models/info/route.js
Normal file
110
src/app/api/v1/models/info/route.js
Normal file
@@ -0,0 +1,110 @@
|
||||
import { PROVIDER_MODELS } from "open-sse/config/providerModels.js";
|
||||
import { AI_PROVIDERS, ALIAS_TO_ID } from "@/shared/constants/providers";
|
||||
|
||||
const KIND_ENDPOINT = {
|
||||
llm: "/v1/chat/completions",
|
||||
image: "/v1/images/generations",
|
||||
tts: "/v1/audio/speech",
|
||||
stt: "/v1/audio/transcriptions",
|
||||
embedding: "/v1/embeddings",
|
||||
imageToText: "/v1/chat/completions",
|
||||
webSearch: "/v1/search",
|
||||
webFetch: "/v1/fetch",
|
||||
};
|
||||
|
||||
const TTS_VOICES_API = new Set(["elevenlabs", "edge-tts", "deepgram", "inworld", "local-device"]);
|
||||
|
||||
function buildInfo({ alias, providerId, model, kind, providerInfo }) {
|
||||
const out = {
|
||||
id: `${alias}/${model.id}`,
|
||||
name: model.name || model.id,
|
||||
kind,
|
||||
owned_by: alias,
|
||||
endpoint: KIND_ENDPOINT[kind] || null,
|
||||
};
|
||||
if (model.params) out.params = model.params;
|
||||
if (model.capabilities) out.capabilities = model.capabilities;
|
||||
if (model.options) out.options = model.options;
|
||||
if (model.dimensions) out.dimensions = model.dimensions;
|
||||
if (model.contextWindow) out.contextWindow = model.contextWindow;
|
||||
if (kind === "tts" && TTS_VOICES_API.has(providerId)) {
|
||||
out.voicesUrl = `/v1/audio/voices?provider=${providerId}`;
|
||||
}
|
||||
if (kind === "webSearch" && providerInfo?.searchConfig) {
|
||||
const cfg = providerInfo.searchConfig;
|
||||
if (cfg.searchTypes) out.searchTypes = cfg.searchTypes;
|
||||
if (cfg.maxMaxResults) out.maxResults = cfg.maxMaxResults;
|
||||
if (cfg.requiredOptions) out.required = cfg.requiredOptions;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// id format: "{alias}/{modelId}" - alias may also be providerId
|
||||
function lookup(fullId) {
|
||||
if (!fullId || !fullId.includes("/")) return null;
|
||||
const slash = fullId.indexOf("/");
|
||||
const alias = fullId.slice(0, slash);
|
||||
const modelId = fullId.slice(slash + 1);
|
||||
const providerId = ALIAS_TO_ID[alias] || alias;
|
||||
const providerInfo = AI_PROVIDERS[providerId];
|
||||
|
||||
// PROVIDER_MODELS lookup (by alias key, fallback to providerId)
|
||||
const list = PROVIDER_MODELS[alias] || PROVIDER_MODELS[providerId] || [];
|
||||
const m = list.find((x) => x.id === modelId);
|
||||
if (m) {
|
||||
const kind = m.type || "llm";
|
||||
return buildInfo({ alias, providerId, model: m, kind, providerInfo });
|
||||
}
|
||||
|
||||
// Sub-configs (TTS/STT/embedding only-in-config)
|
||||
const subs = [
|
||||
["tts", providerInfo?.ttsConfig],
|
||||
["stt", providerInfo?.sttConfig],
|
||||
["embedding", providerInfo?.embeddingConfig],
|
||||
];
|
||||
for (const [kind, cfg] of subs) {
|
||||
const sm = cfg?.models?.find((x) => x.id === modelId);
|
||||
if (sm) return buildInfo({ alias, providerId, model: sm, kind, providerInfo });
|
||||
}
|
||||
|
||||
// Web search/fetch — virtual model id "search" / "fetch"
|
||||
if (modelId === "search" && providerInfo?.searchConfig) {
|
||||
return buildInfo({
|
||||
alias, providerId, kind: "webSearch", providerInfo,
|
||||
model: { id: "search", name: `${providerInfo.name} Search`, params: ["query", "max_results", "country", "language", "time_range", "domain_filter", "search_type"] },
|
||||
});
|
||||
}
|
||||
if (modelId === "fetch" && providerInfo?.fetchConfig) {
|
||||
return buildInfo({
|
||||
alias, providerId, kind: "webFetch", providerInfo,
|
||||
model: { id: "fetch", name: `${providerInfo.name} Fetch`, params: ["url", "format", "max_characters"] },
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS" },
|
||||
});
|
||||
}
|
||||
|
||||
// GET /v1/models/info?id={alias}/{modelId} — metadata for a single model
|
||||
export async function GET(request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get("id");
|
||||
if (!id) {
|
||||
return Response.json(
|
||||
{ error: { message: "Missing required query param: id (e.g. ?id=openai/dall-e-3)", type: "invalid_request_error" } },
|
||||
{ status: 400, headers: { "Access-Control-Allow-Origin": "*" } },
|
||||
);
|
||||
}
|
||||
const info = lookup(id);
|
||||
if (!info) {
|
||||
return Response.json(
|
||||
{ error: { message: `Model not found: ${id}`, type: "not_found" } },
|
||||
{ status: 404, headers: { "Access-Control-Allow-Origin": "*" } },
|
||||
);
|
||||
}
|
||||
return Response.json(info, { headers: { "Access-Control-Allow-Origin": "*" } });
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
isOpenAICompatibleProvider,
|
||||
} from "@/shared/constants/providers";
|
||||
import { getProviderConnections, getCombos, getCustomModels, getModelAliases } from "@/lib/localDb";
|
||||
import { getDisabledModels } from "@/lib/disabledModelsDb";
|
||||
|
||||
const parseOpenAIStyleModels = (data) => {
|
||||
if (Array.isArray(data)) return data;
|
||||
@@ -151,6 +152,14 @@ export async function buildModelsList(kindFilter) {
|
||||
console.log("Could not fetch model aliases");
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const activeConnectionByProvider = new Map();
|
||||
for (const conn of connections) {
|
||||
if (!activeConnectionByProvider.has(conn.provider)) {
|
||||
@@ -159,7 +168,6 @@ export async function buildModelsList(kindFilter) {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -167,7 +175,6 @@ export async function buildModelsList(kindFilter) {
|
||||
const entry = {
|
||||
id: combo.name,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: "combo",
|
||||
};
|
||||
if (combo.kind === "webSearch" || combo.kind === "webFetch") {
|
||||
@@ -186,10 +193,10 @@ export async function buildModelsList(kindFilter) {
|
||||
if (!providerMatchesKinds(providerId, kindFilter)) continue;
|
||||
for (const model of providerModels) {
|
||||
if (!kindFilter.includes(modelKind(model))) continue;
|
||||
if (isDisabled(alias, model.id)) continue;
|
||||
models.push({
|
||||
id: `${alias}/${model.id}`,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: alias,
|
||||
});
|
||||
}
|
||||
@@ -208,7 +215,6 @@ export async function buildModelsList(kindFilter) {
|
||||
models.push({
|
||||
id: `${providerAlias}/${modelId}`,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: providerAlias,
|
||||
});
|
||||
}
|
||||
@@ -301,11 +307,11 @@ export async function buildModelsList(kindFilter) {
|
||||
// Resolve kind: prefer static metadata, otherwise infer from ID heuristics
|
||||
const kind = staticModelKindById.get(modelId) || inferKindFromUnknownModelId(modelId);
|
||||
if (!kindFilter.includes(kind)) continue;
|
||||
if (isDisabled(outputAlias, modelId) || isDisabled(staticAlias, modelId)) continue;
|
||||
|
||||
models.push({
|
||||
id: `${outputAlias}/${modelId}`,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: outputAlias,
|
||||
});
|
||||
}
|
||||
@@ -324,10 +330,10 @@ export async function buildModelsList(kindFilter) {
|
||||
}
|
||||
}
|
||||
for (const subId of subConfigModels) {
|
||||
if (isDisabled(outputAlias, subId) || isDisabled(staticAlias, subId)) continue;
|
||||
models.push({
|
||||
id: `${outputAlias}/${subId}`,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: outputAlias,
|
||||
});
|
||||
}
|
||||
@@ -338,7 +344,6 @@ export async function buildModelsList(kindFilter) {
|
||||
id: `${outputAlias}/search`,
|
||||
object: "model",
|
||||
kind: "webSearch",
|
||||
created: timestamp,
|
||||
owned_by: outputAlias,
|
||||
});
|
||||
}
|
||||
@@ -347,7 +352,6 @@ export async function buildModelsList(kindFilter) {
|
||||
id: `${outputAlias}/fetch`,
|
||||
object: "model",
|
||||
kind: "webFetch",
|
||||
created: timestamp,
|
||||
owned_by: outputAlias,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user