# v0.4.30 (2026-05-11)
## Features - MCP stdio→SSE bridge: expose local stdio MCP plugins over SSE (api/mcp/[plugin]/sse, /message) - Dynamic Linux cert resolution + NSS DB injection (Debian/Arch/Fedora/openSUSE, Chrome/Chromium/Firefox incl. snap) (#1010) - Cowork tool: expanded settings UI & API - GitBook docs (DocsContent, DocsLayout) ## Fixes - OAuth callback postMessage scoped to expected origins (CWE-1385) (#998) - Re-enable TLS verification on DNS-bypass fetch (CWE-295) (#998) - Normalize `developer` role → `system` for OpenAI-format providers (Deepseek, Groq, …) (#1011, closes #773) - Respect `PORT` env in internal model-test fetch (#1014) - Dropdown text readability in dark theme on usage page (#997) ## Improvements - Refactor Claude CLI spoof headers into shared constant - Tool deduper utility in open-sse handlers
This commit is contained in:
parent
76f3d4b74e
commit
8f4d29caa4
23 changed files with 1198 additions and 155 deletions
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -1,3 +1,22 @@
|
||||||
|
# v0.4.30 (2026-05-11)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- MCP stdio→SSE bridge: expose local stdio MCP plugins over SSE (api/mcp/[plugin]/sse, /message)
|
||||||
|
- Dynamic Linux cert resolution + NSS DB injection (Debian/Arch/Fedora/openSUSE, Chrome/Chromium/Firefox incl. snap) (#1010)
|
||||||
|
- Cowork tool: expanded settings UI & API
|
||||||
|
- GitBook docs (DocsContent, DocsLayout)
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
- OAuth callback postMessage scoped to expected origins (CWE-1385) (#998)
|
||||||
|
- Re-enable TLS verification on DNS-bypass fetch (CWE-295) (#998)
|
||||||
|
- Normalize `developer` role → `system` for OpenAI-format providers (Deepseek, Groq, …) (#1011, closes #773)
|
||||||
|
- Respect `PORT` env in internal model-test fetch (#1014)
|
||||||
|
- Dropdown text readability in dark theme on usage page (#997)
|
||||||
|
|
||||||
|
## Improvements
|
||||||
|
- Refactor Claude CLI spoof headers into shared constant
|
||||||
|
- Tool deduper utility in open-sse handlers
|
||||||
|
|
||||||
# v0.4.29 (2026-05-10)
|
# v0.4.29 (2026-05-10)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import { MarkdownRenderer } from "@/utils/markdown";
|
||||||
|
|
||||||
export default function DocsContent({ content }) {
|
export default function DocsContent({ content }) {
|
||||||
return (
|
return (
|
||||||
<main className="flex-1 overflow-y-auto">
|
<main className="flex-1 min-w-0 overflow-x-hidden overflow-y-auto">
|
||||||
<article className="max-w-4xl mx-auto px-6 py-8">
|
<article className="max-w-4xl mx-auto px-4 sm:px-6 py-8">
|
||||||
<MarkdownRenderer content={content} />
|
<MarkdownRenderer content={content} />
|
||||||
</article>
|
</article>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,16 @@ export default function DocsLayout({ children, headings = [], lang = DEFAULT_LAN
|
||||||
<div className="min-h-screen flex flex-col bg-[#FCFBF9]">
|
<div className="min-h-screen flex flex-col bg-[#FCFBF9]">
|
||||||
<DocsHeader lang={lang} />
|
<DocsHeader lang={lang} />
|
||||||
<div className="flex-1 flex">
|
<div className="flex-1 flex">
|
||||||
{/* Desktop sidebar */}
|
|
||||||
<div className="hidden lg:block">
|
<div className="hidden lg:block">
|
||||||
<DocsSidebar lang={lang} />
|
<DocsSidebar lang={lang} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1 flex min-w-0">
|
||||||
<div className="flex-1 flex">
|
|
||||||
{children}
|
{children}
|
||||||
|
<div className="hidden lg:block">
|
||||||
<DocsToc headings={headings} lang={lang} />
|
<DocsToc headings={headings} lang={lang} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { dirname } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
|
|
||||||
const projectRoot = dirname(fileURLToPath(import.meta.url));
|
const projectRoot = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const monorepoRoot = resolve(projectRoot, "..");
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
|
@ -10,9 +11,9 @@ const nextConfig = {
|
||||||
turbopack: {
|
turbopack: {
|
||||||
root: projectRoot
|
root: projectRoot
|
||||||
},
|
},
|
||||||
outputFileTracingRoot: projectRoot,
|
outputFileTracingRoot: monorepoRoot,
|
||||||
outputFileTracingExcludes: {
|
outputFileTracingExcludes: {
|
||||||
"*": ["./gitbook/**/*"]
|
"*": ["./app/gitbook/**/*", "./gitbook/**/*"]
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
unoptimized: true
|
unoptimized: true
|
||||||
|
|
|
||||||
|
|
@ -601,6 +601,152 @@ export const PROVIDER_MODELS = {
|
||||||
{ id: "openai/whisper-large-v3", name: "Whisper Large v3 (HF)", type: "stt", params: ["language"] },
|
{ id: "openai/whisper-large-v3", name: "Whisper Large v3 (HF)", type: "stt", params: ["language"] },
|
||||||
{ id: "openai/whisper-small", name: "Whisper Small (HF)", type: "stt", params: ["language"] },
|
{ id: "openai/whisper-small", name: "Whisper Small (HF)", type: "stt", params: ["language"] },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// === Free-tier providers (synced from OmniRoute) ===
|
||||||
|
agentrouter: [
|
||||||
|
{ id: "claude-opus-4-6", name: "Claude 4.6 Opus" },
|
||||||
|
{ id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" },
|
||||||
|
{ id: "glm-5.1", name: "GLM 5.1" },
|
||||||
|
{ id: "deepseek-v3.2", name: "DeepSeek V3.2" },
|
||||||
|
],
|
||||||
|
aimlapi: [
|
||||||
|
{ id: "gpt-4o", name: "GPT-4o" },
|
||||||
|
{ id: "gpt-4o-mini", name: "GPT-4o Mini" },
|
||||||
|
{ id: "claude-3-5-sonnet-20241022", name: "Claude 3.5 Sonnet" },
|
||||||
|
{ id: "gemini-2.0-flash-exp", name: "Gemini 2.0 Flash" },
|
||||||
|
{ id: "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo", name: "Llama 3.1 70B" },
|
||||||
|
],
|
||||||
|
novita: [
|
||||||
|
{ id: "deepseek/deepseek-r1", name: "DeepSeek R1" },
|
||||||
|
{ id: "deepseek/deepseek-v3", name: "DeepSeek V3" },
|
||||||
|
{ id: "meta-llama/llama-3.3-70b-instruct", name: "Llama 3.3 70B" },
|
||||||
|
{ id: "qwen/qwen-2.5-72b-instruct", name: "Qwen 2.5 72B" },
|
||||||
|
],
|
||||||
|
modal: [
|
||||||
|
{ id: "auto", name: "Auto (User-hosted)" },
|
||||||
|
],
|
||||||
|
reka: [
|
||||||
|
{ id: "reka-flash-3", name: "Reka Flash 3" },
|
||||||
|
{ id: "reka-edge-2603", name: "Reka Edge 2603" },
|
||||||
|
],
|
||||||
|
nlpcloud: [
|
||||||
|
{ id: "chatdolphin", name: "ChatDolphin" },
|
||||||
|
{ id: "dolphin", name: "Dolphin" },
|
||||||
|
{ id: "finetuned-llama-3-70b", name: "Llama 3 70B (Finetuned)" },
|
||||||
|
],
|
||||||
|
bazaarlink: [
|
||||||
|
{ id: "auto:free", name: "Auto Free (Zero Cost)" },
|
||||||
|
{ id: "auto", name: "Auto (Best Model)" },
|
||||||
|
],
|
||||||
|
completions: [
|
||||||
|
{ id: "claude-opus-4", name: "Claude Opus 4" },
|
||||||
|
{ id: "claude-sonnet-4", name: "Claude Sonnet 4" },
|
||||||
|
{ id: "gpt-4o", name: "GPT-4o" },
|
||||||
|
{ id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" },
|
||||||
|
],
|
||||||
|
enally: [
|
||||||
|
{ id: "gpt-4o", name: "GPT-4o" },
|
||||||
|
{ id: "gpt-4o-mini", name: "GPT-4o Mini" },
|
||||||
|
{ id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet" },
|
||||||
|
],
|
||||||
|
freetheai: [
|
||||||
|
{ id: "gpt-4o", name: "GPT-4o" },
|
||||||
|
{ id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet" },
|
||||||
|
{ id: "gemini-1.5-pro", name: "Gemini 1.5 Pro" },
|
||||||
|
{ id: "deepseek-chat", name: "DeepSeek Chat" },
|
||||||
|
],
|
||||||
|
llm7: [
|
||||||
|
{ id: "gpt-4o-mini", name: "GPT-4o Mini" },
|
||||||
|
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini" },
|
||||||
|
{ id: "gemini-1.5-flash", name: "Gemini 1.5 Flash" },
|
||||||
|
],
|
||||||
|
lepton: [
|
||||||
|
{ id: "llama3-1-405b", name: "Llama 3.1 405B" },
|
||||||
|
{ id: "llama3-1-70b", name: "Llama 3.1 70B" },
|
||||||
|
{ id: "llama3-1-8b", name: "Llama 3.1 8B" },
|
||||||
|
{ id: "mixtral-8x7b", name: "Mixtral 8x7B" },
|
||||||
|
],
|
||||||
|
kluster: [
|
||||||
|
{ id: "deepseek-ai/DeepSeek-R1", name: "DeepSeek R1" },
|
||||||
|
{ id: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", name: "Llama 4 Maverick" },
|
||||||
|
{ id: "meta-llama/Llama-4-Scout-17B-16E-Instruct", name: "Llama 4 Scout" },
|
||||||
|
{ id: "Qwen/Qwen3-235B-A22B-Instruct", name: "Qwen3 235B" },
|
||||||
|
],
|
||||||
|
ai21: [
|
||||||
|
{ id: "jamba-large", name: "Jamba 1.5 Large" },
|
||||||
|
{ id: "jamba-mini", name: "Jamba 1.5 Mini" },
|
||||||
|
],
|
||||||
|
"inference-net": [
|
||||||
|
{ id: "meta-llama/llama-3.3-70b-instruct/fp-16", name: "Llama 3.3 70B" },
|
||||||
|
{ id: "deepseek/deepseek-v3-0324", name: "DeepSeek V3" },
|
||||||
|
{ id: "mistralai/mistral-nemo-12b-instruct/fp-16", name: "Mistral Nemo 12B" },
|
||||||
|
],
|
||||||
|
predibase: [
|
||||||
|
{ id: "llama-3-2-3b-instruct", name: "Llama 3.2 3B" },
|
||||||
|
{ id: "llama-3-1-8b-instruct", name: "Llama 3.1 8B" },
|
||||||
|
{ id: "qwen2-5-7b-instruct", name: "Qwen 2.5 7B" },
|
||||||
|
],
|
||||||
|
bytez: [
|
||||||
|
{ id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B" },
|
||||||
|
{ id: "mistralai/Mistral-7B-Instruct-v0.3", name: "Mistral 7B v0.3" },
|
||||||
|
{ id: "Qwen/Qwen2.5-72B-Instruct", name: "Qwen 2.5 72B" },
|
||||||
|
],
|
||||||
|
morph: [
|
||||||
|
{ id: "morph-v3-large", name: "Morph V3 Large" },
|
||||||
|
{ id: "morph-v3-fast", name: "Morph V3 Fast" },
|
||||||
|
],
|
||||||
|
longcat: [
|
||||||
|
{ id: "LongCat-Flash-Chat", name: "LongCat Flash Chat" },
|
||||||
|
{ id: "LongCat-Flash-Thinking", name: "LongCat Flash Thinking" },
|
||||||
|
{ id: "LongCat-Flash-Lite", name: "LongCat Flash Lite" },
|
||||||
|
],
|
||||||
|
puter: [
|
||||||
|
{ id: "gpt-5", name: "GPT-5" },
|
||||||
|
{ id: "claude-opus-4", name: "Claude Opus 4" },
|
||||||
|
{ id: "gemini-3-pro-preview", name: "Gemini 3 Pro" },
|
||||||
|
{ id: "grok-4", name: "Grok 4" },
|
||||||
|
{ id: "deepseek-chat", name: "DeepSeek V3" },
|
||||||
|
],
|
||||||
|
uncloseai: [
|
||||||
|
{ id: "auto", name: "Auto (Free)" },
|
||||||
|
{ id: "gpt-4o-mini", name: "GPT-4o Mini" },
|
||||||
|
],
|
||||||
|
scaleway: [
|
||||||
|
{ id: "qwen3-235b-a22b-instruct-2507", name: "Qwen3 235B" },
|
||||||
|
{ id: "llama-3.3-70b-instruct", name: "Llama 3.3 70B" },
|
||||||
|
{ id: "mistral-small-3.1-24b-instruct-2503", name: "Mistral Small 3.1" },
|
||||||
|
],
|
||||||
|
deepinfra: [
|
||||||
|
{ id: "meta-llama/Meta-Llama-3.1-70B-Instruct", name: "Llama 3.1 70B" },
|
||||||
|
{ id: "deepseek-ai/DeepSeek-V3", name: "DeepSeek V3" },
|
||||||
|
{ id: "Qwen/Qwen2.5-72B-Instruct", name: "Qwen 2.5 72B" },
|
||||||
|
],
|
||||||
|
sambanova: [
|
||||||
|
{ id: "Meta-Llama-3.1-405B-Instruct", name: "Llama 3.1 405B" },
|
||||||
|
{ id: "Meta-Llama-3.1-70B-Instruct", name: "Llama 3.1 70B" },
|
||||||
|
{ id: "Meta-Llama-3.1-8B-Instruct", name: "Llama 3.1 8B" },
|
||||||
|
],
|
||||||
|
nscale: [
|
||||||
|
{ id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B" },
|
||||||
|
{ id: "Qwen/Qwen2.5-Coder-32B-Instruct", name: "Qwen 2.5 Coder 32B" },
|
||||||
|
],
|
||||||
|
baseten: [
|
||||||
|
{ id: "deepseek-ai/DeepSeek-R1", name: "DeepSeek R1" },
|
||||||
|
{ id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B" },
|
||||||
|
],
|
||||||
|
publicai: [
|
||||||
|
{ id: "auto", name: "Auto (Community)" },
|
||||||
|
],
|
||||||
|
"nous-research": [
|
||||||
|
{ id: "Hermes-4-405B", name: "Hermes 4 405B" },
|
||||||
|
{ id: "Hermes-4-70B", name: "Hermes 4 70B" },
|
||||||
|
],
|
||||||
|
glhf: [
|
||||||
|
{ id: "hf:meta-llama/Meta-Llama-3.1-405B-Instruct", name: "Llama 3.1 405B" },
|
||||||
|
{ id: "hf:meta-llama/Meta-Llama-3.1-70B-Instruct", name: "Llama 3.1 70B" },
|
||||||
|
{ id: "hf:Qwen/Qwen2.5-72B-Instruct", name: "Qwen 2.5 72B" },
|
||||||
|
],
|
||||||
|
|
||||||
deepgram: [
|
deepgram: [
|
||||||
{ id: "nova-3", name: "Nova 3", type: "stt", params: ["language"] },
|
{ id: "nova-3", name: "Nova 3", type: "stt", params: ["language"] },
|
||||||
{ id: "nova-2", name: "Nova 2", type: "stt", params: ["language"] },
|
{ id: "nova-2", name: "Nova 2", type: "stt", params: ["language"] },
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,8 @@ const CLAUDE_API_HEADERS = {
|
||||||
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14"
|
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14"
|
||||||
};
|
};
|
||||||
|
|
||||||
// Shared baseUrls
|
// Full Claude CLI fingerprint — required by providers that gate on client identity (e.g. agentrouter)
|
||||||
const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/v1/messages";
|
const CLAUDE_CLI_SPOOF_HEADERS = {
|
||||||
|
|
||||||
export const PROVIDERS = {
|
|
||||||
claude: {
|
|
||||||
baseUrl: "https://api.anthropic.com/v1/messages",
|
|
||||||
format: "claude",
|
|
||||||
headers: {
|
|
||||||
"Anthropic-Version": "2023-06-01",
|
"Anthropic-Version": "2023-06-01",
|
||||||
"Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28",
|
"Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28",
|
||||||
"Anthropic-Dangerous-Direct-Browser-Access": "true",
|
"Anthropic-Dangerous-Direct-Browser-Access": "true",
|
||||||
|
|
@ -48,7 +42,16 @@ export const PROVIDERS = {
|
||||||
"X-Stainless-Arch": mapStainlessArch(),
|
"X-Stainless-Arch": mapStainlessArch(),
|
||||||
"X-Stainless-Os": mapStainlessOs(),
|
"X-Stainless-Os": mapStainlessOs(),
|
||||||
"X-Stainless-Timeout": "600"
|
"X-Stainless-Timeout": "600"
|
||||||
},
|
};
|
||||||
|
|
||||||
|
// Shared baseUrls
|
||||||
|
const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/v1/messages";
|
||||||
|
|
||||||
|
export const PROVIDERS = {
|
||||||
|
claude: {
|
||||||
|
baseUrl: "https://api.anthropic.com/v1/messages",
|
||||||
|
format: "claude",
|
||||||
|
headers: { ...CLAUDE_CLI_SPOOF_HEADERS },
|
||||||
clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
||||||
tokenUrl: "https://api.anthropic.com/v1/oauth/token"
|
tokenUrl: "https://api.anthropic.com/v1/oauth/token"
|
||||||
},
|
},
|
||||||
|
|
@ -384,6 +387,39 @@ export const PROVIDERS = {
|
||||||
baseUrl: "https://api.xiaomimimo.com/v1/chat/completions",
|
baseUrl: "https://api.xiaomimimo.com/v1/chat/completions",
|
||||||
format: "openai"
|
format: "openai"
|
||||||
},
|
},
|
||||||
|
// === Free-tier providers (synced from OmniRoute) ===
|
||||||
|
// Claude-format with Claude CLI header spoofing (auth: x-api-key)
|
||||||
|
agentrouter: { baseUrl: "https://agentrouter.org/v1/messages", format: "claude", headers: { ...CLAUDE_CLI_SPOOF_HEADERS } },
|
||||||
|
// OpenAI-compatible (auth: bearer)
|
||||||
|
aimlapi: { baseUrl: "https://api.aimlapi.com/v1/chat/completions", format: "openai" },
|
||||||
|
novita: { baseUrl: "https://api.novita.ai/v3/openai/chat/completions", format: "openai" },
|
||||||
|
modal: { baseUrl: "https://api.modal.com/v1/chat/completions", format: "openai" },
|
||||||
|
reka: { baseUrl: "https://api.reka.ai/v1/chat/completions", format: "openai" },
|
||||||
|
nlpcloud: { baseUrl: "https://api.nlpcloud.io/v1/gpu/chatbot", format: "openai" },
|
||||||
|
bazaarlink: { baseUrl: "https://bazaarlink.ai/api/v1/chat/completions", format: "openai" },
|
||||||
|
completions: { baseUrl: "https://completions.me/api/v1/chat/completions", format: "openai" },
|
||||||
|
// enally uses X-API-Key header (not bearer); handled in validate route
|
||||||
|
enally: { baseUrl: "https://ai.enally.in/v1/chat/completions", format: "openai", authHeader: "x-api-key" },
|
||||||
|
freetheai: { baseUrl: "https://api.freetheai.xyz/v1/chat/completions", format: "openai" },
|
||||||
|
llm7: { baseUrl: "https://api.llm7.io/v1/chat/completions", format: "openai" },
|
||||||
|
lepton: { baseUrl: "https://api.lepton.ai/api/v1/chat/completions", format: "openai" },
|
||||||
|
kluster: { baseUrl: "https://api.kluster.ai/v1/chat/completions", format: "openai" },
|
||||||
|
ai21: { baseUrl: "https://api.ai21.com/studio/v1/chat/completions", format: "openai" },
|
||||||
|
"inference-net": { baseUrl: "https://api.inference.net/v1/chat/completions", format: "openai" },
|
||||||
|
predibase: { baseUrl: "https://serving.app.predibase.com/v1/chat/completions", format: "openai" },
|
||||||
|
bytez: { baseUrl: "https://api.bytez.com/models/v2", format: "openai" },
|
||||||
|
morph: { baseUrl: "https://api.morphllm.com/v1/chat/completions", format: "openai" },
|
||||||
|
longcat: { baseUrl: "https://api.longcat.chat/openai/v1/chat/completions", format: "openai" },
|
||||||
|
puter: { baseUrl: "https://api.puter.com/puterai/openai/v1/chat/completions", format: "openai" },
|
||||||
|
uncloseai: { baseUrl: "https://hermes.ai.unturf.com/v1/chat/completions", format: "openai", noAuth: true },
|
||||||
|
scaleway: { baseUrl: "https://api.scaleway.ai/v1/chat/completions", format: "openai" },
|
||||||
|
deepinfra: { baseUrl: "https://api.deepinfra.com/v1/openai/chat/completions", format: "openai" },
|
||||||
|
sambanova: { baseUrl: "https://api.sambanova.ai/v1/chat/completions", format: "openai" },
|
||||||
|
nscale: { baseUrl: "https://inference.api.nscale.com/v1/chat/completions", format: "openai" },
|
||||||
|
baseten: { baseUrl: "https://inference.baseten.co/v1/chat/completions", format: "openai" },
|
||||||
|
publicai: { baseUrl: "https://api.publicai.co/v1/chat/completions", format: "openai" },
|
||||||
|
"nous-research": { baseUrl: "https://inference-api.nousresearch.com/v1/chat/completions", format: "openai" },
|
||||||
|
glhf: { baseUrl: "https://glhf.chat/api/openai/v1/chat/completions", format: "openai" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const OLLAMA_LOCAL_DEFAULT_HOST = "http://localhost:11434";
|
export const OLLAMA_LOCAL_DEFAULT_HOST = "http://localhost:11434";
|
||||||
|
|
|
||||||
|
|
@ -96,11 +96,9 @@ export class DefaultExecutor extends BaseExecutor {
|
||||||
case "kimi":
|
case "kimi":
|
||||||
case "minimax":
|
case "minimax":
|
||||||
case "minimax-cn":
|
case "minimax-cn":
|
||||||
headers["x-api-key"] = credentials.apiKey || credentials.accessToken;
|
|
||||||
break;
|
|
||||||
case "kimi-coding":
|
case "kimi-coding":
|
||||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
headers["x-api-key"] = credentials.apiKey || credentials.accessToken;
|
||||||
Object.assign(headers, buildKimiHeaders());
|
if (this.provider === "kimi-coding") Object.assign(headers, buildKimiHeaders());
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
if (this.provider?.startsWith?.("anthropic-compatible-")) {
|
if (this.provider?.startsWith?.("anthropic-compatible-")) {
|
||||||
|
|
@ -124,6 +122,10 @@ export class DefaultExecutor extends BaseExecutor {
|
||||||
}
|
}
|
||||||
} else if (this.provider === "cline") {
|
} else if (this.provider === "cline") {
|
||||||
Object.assign(headers, buildClineHeaders(credentials.apiKey || credentials.accessToken));
|
Object.assign(headers, buildClineHeaders(credentials.apiKey || credentials.accessToken));
|
||||||
|
} else if (this.config?.format === "claude") {
|
||||||
|
// Generic claude-format provider (e.g. agentrouter): x-api-key + anthropic-version
|
||||||
|
headers["x-api-key"] = credentials.apiKey || credentials.accessToken;
|
||||||
|
if (!headers["anthropic-version"]) headers["anthropic-version"] = "2023-06-01";
|
||||||
} else {
|
} else {
|
||||||
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
|
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { handleForcedSSEToJson } from "./chatCore/sseToJsonHandler.js";
|
||||||
import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js";
|
import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js";
|
||||||
import { handleStreamingResponse, buildOnStreamComplete } from "./chatCore/streamingHandler.js";
|
import { handleStreamingResponse, buildOnStreamComplete } from "./chatCore/streamingHandler.js";
|
||||||
import { detectClientTool, isNativePassthrough } from "../utils/clientDetector.js";
|
import { detectClientTool, isNativePassthrough } from "../utils/clientDetector.js";
|
||||||
|
import { dedupeTools } from "../utils/toolDeduper.js";
|
||||||
import { injectCaveman } from "../rtk/caveman.js";
|
import { injectCaveman } from "../rtk/caveman.js";
|
||||||
import { compressMessages, formatRtkLog } from "../rtk/index.js";
|
import { compressMessages, formatRtkLog } from "../rtk/index.js";
|
||||||
|
|
||||||
|
|
@ -94,6 +95,15 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||||
translatedBody.model = model;
|
translatedBody.model = model;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dedupe duplicate built-in tools when equivalent MCP tools are present (Claude clients only).
|
||||||
|
if (clientTool === "claude" && Array.isArray(translatedBody.tools)) {
|
||||||
|
const { tools: deduped, stripped } = dedupeTools(translatedBody.tools);
|
||||||
|
if (stripped.length > 0) {
|
||||||
|
translatedBody.tools = deduped;
|
||||||
|
log?.debug?.("TOOLDEDUP", `stripped ${stripped.length}: ${stripped.slice(0, 3).join(", ")}${stripped.length > 3 ? "..." : ""}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Token savers: applied at the final body just before dispatch
|
// Token savers: applied at the final body just before dispatch
|
||||||
// Covers both passthrough (source shape) and translated (target shape) flows
|
// Covers both passthrough (source shape) and translated (target shape) flows
|
||||||
const finalFormat = passthrough ? sourceFormat : targetFormat;
|
const finalFormat = passthrough ? sourceFormat : targetFormat;
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,50 @@ const ALIAS_TO_PROVIDER_ID = {
|
||||||
// TTS
|
// TTS
|
||||||
polly: "aws-polly",
|
polly: "aws-polly",
|
||||||
"aws-polly": "aws-polly",
|
"aws-polly": "aws-polly",
|
||||||
|
// Free-tier providers (synced from OmniRoute)
|
||||||
|
agentrouter: "agentrouter",
|
||||||
|
aimlapi: "aimlapi",
|
||||||
|
aiml: "aimlapi",
|
||||||
|
novita: "novita",
|
||||||
|
modal: "modal",
|
||||||
|
mdl: "modal",
|
||||||
|
reka: "reka",
|
||||||
|
nlpcloud: "nlpcloud",
|
||||||
|
nlpc: "nlpcloud",
|
||||||
|
bazaarlink: "bazaarlink",
|
||||||
|
bzl: "bazaarlink",
|
||||||
|
completions: "completions",
|
||||||
|
cpl: "completions",
|
||||||
|
enally: "enally",
|
||||||
|
enly: "enally",
|
||||||
|
freetheai: "freetheai",
|
||||||
|
fta: "freetheai",
|
||||||
|
llm7: "llm7",
|
||||||
|
lepton: "lepton",
|
||||||
|
kluster: "kluster",
|
||||||
|
ai21: "ai21",
|
||||||
|
"inference-net": "inference-net",
|
||||||
|
inet: "inference-net",
|
||||||
|
predibase: "predibase",
|
||||||
|
bytez: "bytez",
|
||||||
|
morph: "morph",
|
||||||
|
longcat: "longcat",
|
||||||
|
lc: "longcat",
|
||||||
|
puter: "puter",
|
||||||
|
pu: "puter",
|
||||||
|
uncloseai: "uncloseai",
|
||||||
|
unc: "uncloseai",
|
||||||
|
scaleway: "scaleway",
|
||||||
|
scw: "scaleway",
|
||||||
|
deepinfra: "deepinfra",
|
||||||
|
sambanova: "sambanova",
|
||||||
|
samba: "sambanova",
|
||||||
|
nscale: "nscale",
|
||||||
|
baseten: "baseten",
|
||||||
|
publicai: "publicai",
|
||||||
|
"nous-research": "nous-research",
|
||||||
|
nous: "nous-research",
|
||||||
|
glhf: "glhf",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
49
open-sse/utils/toolDeduper.js
Normal file
49
open-sse/utils/toolDeduper.js
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
/**
|
||||||
|
* Strip built-in/duplicate tools when equivalent MCP tools are present.
|
||||||
|
* Goal: reduce tool definitions token bloat for Claude clients.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEDUP_RULES = [
|
||||||
|
{
|
||||||
|
// Exa MCP present → drop built-in web tools (Exa is preferred).
|
||||||
|
triggers: ["mcp__exa__web_search_exa", "mcp__exa__web_fetch_exa"],
|
||||||
|
strip: ["WebSearch", "WebFetch", "mcp__workspace__web_fetch"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Tavily MCP present → drop built-in web tools.
|
||||||
|
triggers: ["mcp__tavily__tavily_search", "mcp__tavily__tavily_extract"],
|
||||||
|
strip: ["WebSearch", "WebFetch", "mcp__workspace__web_fetch"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Browser MCP present → drop Cowork's duplicate Claude_in_Chrome connector.
|
||||||
|
triggers: [/^mcp__browsermcp__/],
|
||||||
|
strip: [/^mcp__Claude_in_Chrome__/],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function getToolName(t) {
|
||||||
|
return t?.name || t?.function?.name || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(name, pattern) {
|
||||||
|
if (typeof pattern === "string") return name === pattern;
|
||||||
|
return pattern instanceof RegExp ? pattern.test(name) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeTools(tools) {
|
||||||
|
if (!Array.isArray(tools) || tools.length === 0) return { tools, stripped: [] };
|
||||||
|
const names = tools.map(getToolName);
|
||||||
|
const toStrip = new Set();
|
||||||
|
for (const rule of DEDUP_RULES) {
|
||||||
|
const hasTrigger = names.some((n) => rule.triggers.some((p) => matches(n, p)));
|
||||||
|
if (!hasTrigger) continue;
|
||||||
|
for (const n of names) {
|
||||||
|
if (rule.strip.some((p) => matches(n, p))) toStrip.add(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (toStrip.size === 0) return { tools, stripped: [] };
|
||||||
|
const out = tools.filter((t) => !toStrip.has(getToolName(t)));
|
||||||
|
return { tools: out, stripped: Array.from(toStrip) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export { dedupeTools };
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "9router-app",
|
"name": "9router-app",
|
||||||
"version": "0.4.29",
|
"version": "0.4.30",
|
||||||
"description": "9Router web dashboard",
|
"description": "9Router web dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,12 @@ export default function CoworkToolCard({
|
||||||
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
||||||
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
||||||
const [plugins, setPlugins] = useState([]);
|
const [plugins, setPlugins] = useState([]);
|
||||||
|
const [localPlugins, setLocalPlugins] = useState([]);
|
||||||
|
const [customPlugins, setCustomPlugins] = useState([]);
|
||||||
const [comboModalOpen, setComboModalOpen] = useState(false);
|
const [comboModalOpen, setComboModalOpen] = useState(false);
|
||||||
const [marketplaceOpen, setMarketplaceOpen] = useState(false);
|
const [marketplaceOpen, setMarketplaceOpen] = useState(false);
|
||||||
|
const [addMcpOpen, setAddMcpOpen] = useState(false);
|
||||||
|
const [addMcpForm, setAddMcpForm] = useState({ type: "url", name: "", url: "", command: "", args: "" });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||||
|
|
@ -71,6 +75,12 @@ export default function CoworkToolCard({
|
||||||
} else if (plugins.length === 0 && Array.isArray(status?.defaultPlugins)) {
|
} else if (plugins.length === 0 && Array.isArray(status?.defaultPlugins)) {
|
||||||
setPlugins(status.defaultPlugins);
|
setPlugins(status.defaultPlugins);
|
||||||
}
|
}
|
||||||
|
if (Array.isArray(status?.cowork?.localPlugins)) {
|
||||||
|
setLocalPlugins(status.cowork.localPlugins);
|
||||||
|
}
|
||||||
|
if (Array.isArray(status?.cowork?.customPlugins) && status.cowork.customPlugins.length > 0) {
|
||||||
|
setCustomPlugins(status.cowork.customPlugins);
|
||||||
|
}
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
const checkStatus = async () => {
|
const checkStatus = async () => {
|
||||||
|
|
@ -120,6 +130,8 @@ export default function CoworkToolCard({
|
||||||
apiKey: keyToUse,
|
apiKey: keyToUse,
|
||||||
models: selectedModels,
|
models: selectedModels,
|
||||||
plugins,
|
plugins,
|
||||||
|
localPlugins,
|
||||||
|
customPlugins,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
@ -168,6 +180,8 @@ export default function CoworkToolCard({
|
||||||
setMessage({ type: "success", text: "Settings reset successfully" });
|
setMessage({ type: "success", text: "Settings reset successfully" });
|
||||||
setSelectedModels([]);
|
setSelectedModels([]);
|
||||||
setPlugins(status?.defaultPlugins || []);
|
setPlugins(status?.defaultPlugins || []);
|
||||||
|
setLocalPlugins([]);
|
||||||
|
setCustomPlugins([]);
|
||||||
checkStatus();
|
checkStatus();
|
||||||
} else {
|
} else {
|
||||||
setMessage({ type: "error", text: data.error || "Failed to reset" });
|
setMessage({ type: "error", text: data.error || "Failed to reset" });
|
||||||
|
|
@ -288,11 +302,11 @@ export default function CoworkToolCard({
|
||||||
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
|
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
|
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
|
||||||
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-1">Models</span>
|
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">Models</span>
|
||||||
<span className="material-symbols-outlined text-text-muted text-[14px] mt-1.5">arrow_forward</span>
|
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
|
||||||
<div className="flex-1 flex flex-col gap-2">
|
<div className="flex-1 flex items-center gap-2">
|
||||||
<div className="flex flex-wrap gap-1.5 min-h-[28px] px-2 py-1.5 bg-surface rounded border border-border">
|
<div className="flex-1 flex flex-wrap gap-1.5 min-h-[28px] px-2 py-1.5 bg-surface rounded border border-border">
|
||||||
{selectedModels.length === 0 ? (
|
{selectedModels.length === 0 ? (
|
||||||
<span className="text-xs text-text-muted">No models selected</span>
|
<span className="text-xs text-text-muted">No models selected</span>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -306,37 +320,145 @@ export default function CoworkToolCard({
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setComboModalOpen(true)} disabled={!hasActiveProviders} className={`self-start px-2 py-1 rounded border text-xs transition-colors ${hasActiveProviders ? "bg-primary/10 border-primary/40 text-primary hover:bg-primary/20 cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>+ Add Combo (claude-)</button>
|
<button onClick={() => setComboModalOpen(true)} disabled={!hasActiveProviders} className={`shrink-0 px-2 py-1.5 rounded border text-xs whitespace-nowrap transition-colors ${hasActiveProviders ? "bg-primary/10 border-primary/40 text-primary hover:bg-primary/20 cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>+ Combo</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
|
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
|
||||||
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-1">Plugins</span>
|
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-2">MCP</span>
|
||||||
<span className="material-symbols-outlined text-text-muted text-[14px] mt-1.5">arrow_forward</span>
|
<span className="material-symbols-outlined text-text-muted text-[14px] mt-2">arrow_forward</span>
|
||||||
<div className="flex-1 flex flex-col gap-2">
|
<div className="flex-1 flex flex-col gap-1">
|
||||||
<div className="flex flex-wrap gap-1.5 min-h-[28px] px-2 py-1.5 bg-surface rounded border border-border">
|
{/* Preset plugins */}
|
||||||
{plugins.filter((p) => p.name !== "exa").length === 0 ? (
|
{plugins.filter((p) => p.name !== "exa").map((p) => (
|
||||||
<span className="text-xs text-text-muted">No plugins</span>
|
<div key={p.name} className="flex items-center gap-2 px-2 py-1 bg-surface rounded border border-border">
|
||||||
) : (
|
<span className="text-xs font-medium min-w-0 truncate flex-shrink-0">{p.title || p.name}</span>
|
||||||
plugins.filter((p) => p.name !== "exa").map((p) => (
|
{p.oauth && <span className="text-[8px] text-amber-600 shrink-0">OAuth</span>}
|
||||||
<span key={p.name} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-black/5 dark:bg-white/5 text-text-muted border border-transparent hover:border-border">
|
<div className="flex-1 flex flex-wrap gap-1 overflow-hidden" style={{ maxHeight: "1.5rem" }}>
|
||||||
{p.title || p.name}
|
{Array.isArray(p.toolNames) && p.toolNames.slice(0, 6).map((t) => (
|
||||||
{p.oauth && <span className="text-[8px] text-amber-600">OAuth</span>}
|
<span key={t} className="text-[9px] px-1 py-0.5 rounded bg-black/5 dark:bg-white/5 text-text-muted whitespace-nowrap">{t}</span>
|
||||||
<button onClick={() => removePlugin(p.name)} className="ml-0.5 hover:text-red-500">
|
))}
|
||||||
<span className="material-symbols-outlined text-[12px]">close</span>
|
{Array.isArray(p.toolNames) && p.toolNames.length > 6 && (
|
||||||
</button>
|
<span className="text-[9px] px-1 py-0.5 rounded bg-black/5 dark:bg-white/5 text-text-muted whitespace-nowrap">+{p.toolNames.length - 6}</span>
|
||||||
</span>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setMarketplaceOpen(true)} className="self-start px-2 py-1 rounded border text-xs bg-primary/10 border-primary/40 text-primary hover:bg-primary/20 cursor-pointer">
|
<button onClick={() => removePlugin(p.name)} className="shrink-0 hover:text-red-500 ml-auto">
|
||||||
+ Browse MCP marketplace
|
<span className="material-symbols-outlined text-[12px]">close</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* Custom plugins */}
|
||||||
|
{customPlugins.map((p) => (
|
||||||
|
<div key={p.name} className="flex items-center gap-2 px-2 py-1 bg-surface rounded border border-border">
|
||||||
|
<span className="text-xs font-medium min-w-0 truncate flex-shrink-0">{p.name}</span>
|
||||||
|
<span className="text-[8px] px-1 py-0.5 rounded bg-blue-500/10 text-blue-500 shrink-0">custom</span>
|
||||||
|
<span className="flex-1 text-[9px] text-text-muted truncate">{p.url || p.command}</span>
|
||||||
|
<button onClick={() => setCustomPlugins(customPlugins.filter((x) => x.name !== p.name))} className="shrink-0 hover:text-red-500 ml-auto">
|
||||||
|
<span className="material-symbols-outlined text-[12px]">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{plugins.filter((p) => p.name !== "exa").length === 0 && customPlugins.length === 0 && (
|
||||||
|
<div className="px-2 py-1.5 bg-surface rounded border border-border text-xs text-text-muted">No MCPs added</div>
|
||||||
|
)}
|
||||||
|
{/* Actions row */}
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<button onClick={() => setMarketplaceOpen(true)} className="px-2 py-1 rounded border text-xs bg-primary/10 border-primary/40 text-primary hover:bg-primary/20 cursor-pointer whitespace-nowrap">
|
||||||
|
+ Browse
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setAddMcpForm({ type: "url", name: "", url: "", command: "", args: "" }); setAddMcpOpen(true); }} className="px-2 py-1 rounded border text-xs bg-surface border-border text-text-muted hover:border-primary hover:text-primary cursor-pointer whitespace-nowrap">
|
||||||
|
+ Custom
|
||||||
|
</button>
|
||||||
|
<a href="https://mcp.so" target="_blank" rel="noopener noreferrer" className="text-[10px] text-text-muted hover:text-primary underline ml-auto">Find MCPs →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
|
||||||
|
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-1">Tools</span>
|
||||||
|
<span className="material-symbols-outlined text-text-muted text-[14px] mt-1.5">arrow_forward</span>
|
||||||
|
<div className="flex-1 flex flex-col gap-1.5">
|
||||||
|
{(() => {
|
||||||
|
const exaEnabled = plugins.some((p) => p.name === "exa");
|
||||||
|
const exaDef = (status?.defaultPlugins || []).find((d) => d.name === "exa");
|
||||||
|
return (
|
||||||
|
<label className="flex items-start gap-2 cursor-pointer px-2 py-1.5 bg-surface rounded border border-border">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={exaEnabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked && exaDef) setPlugins([...plugins.filter((p) => p.name !== "exa"), exaDef]);
|
||||||
|
else setPlugins(plugins.filter((p) => p.name !== "exa"));
|
||||||
|
}}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs font-medium">Web Search & Fetch (Exa)</div>
|
||||||
|
<p className="text-[10px] text-text-muted leading-snug">Replaces built-in WebSearch/WebFetch. Auto-strips duplicates from tool list.</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{(() => {
|
||||||
|
const browserDef = (status?.localStdioPlugins || []).find((p) => p.name === "browsermcp");
|
||||||
|
if (!browserDef) return null;
|
||||||
|
const browserEnabled = localPlugins.includes("browsermcp");
|
||||||
|
return (
|
||||||
|
<label className="flex items-start gap-2 cursor-pointer px-2 py-1.5 bg-surface rounded border border-border">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={browserEnabled}
|
||||||
|
onChange={(e) => setLocalPlugins(e.target.checked ? [...localPlugins, "browsermcp"] : localPlugins.filter((n) => n !== "browsermcp"))}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs font-medium">Browser Control (Browser MCP)</div>
|
||||||
<p className="text-[10px] text-text-muted leading-snug">
|
<p className="text-[10px] text-text-muted leading-snug">
|
||||||
💡 Exa is auto-installed. Prefer <code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/5">web_search_exa</code> for web search and <code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/5">web_fetch_exa</code> for reading pages.
|
Controls your running Chrome. Auto-strips Cowork's built-in browser tools.{" "}
|
||||||
|
<a href={browserDef.extensionUrl} target="_blank" rel="noopener noreferrer" className="text-primary underline">Install Chrome extension</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Array.isArray(status?.localStdioPlugins) && status.localStdioPlugins.filter((p) => p.name !== "browsermcp").length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
|
||||||
|
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-1">Local Plugins</span>
|
||||||
|
<span className="material-symbols-outlined text-text-muted text-[14px] mt-1.5">arrow_forward</span>
|
||||||
|
<div className="flex-1 flex flex-col gap-2">
|
||||||
|
<div className="flex flex-col gap-1.5 px-2 py-1.5 bg-surface rounded border border-border">
|
||||||
|
{status.localStdioPlugins.filter((p) => p.name !== "browsermcp").map((p) => {
|
||||||
|
const enabled = localPlugins.includes(p.name);
|
||||||
|
return (
|
||||||
|
<label key={p.name} className="flex items-start gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) => setLocalPlugins(e.target.checked ? [...localPlugins, p.name] : localPlugins.filter((n) => n !== p.name))}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="text-xs font-medium">{p.title}</span>
|
||||||
|
<span className="text-[8px] text-amber-600">stdio</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-text-muted leading-snug">{p.description}</p>
|
||||||
|
{p.extensionUrl && (
|
||||||
|
<a href={p.extensionUrl} target="_blank" rel="noopener noreferrer" className="text-[10px] text-primary underline">Install Chrome extension</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-text-muted leading-snug">
|
||||||
|
⚠️ Local plugins run as subprocess via <code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/5">npx</code>. Requires Node.js installed.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
|
|
@ -385,6 +507,99 @@ export default function CoworkToolCard({
|
||||||
onAdd={addPlugin}
|
onAdd={addPlugin}
|
||||||
addedNames={plugins.map((p) => p.name)}
|
addedNames={plugins.map((p) => p.name)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Add Custom MCP modal */}
|
||||||
|
{addMcpOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setAddMcpOpen(false)}>
|
||||||
|
<div className="bg-surface border border-border rounded-xl shadow-xl w-full max-w-sm mx-4 p-5 flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-sm">Add Custom MCP</h3>
|
||||||
|
<button onClick={() => setAddMcpOpen(false)} className="text-text-muted hover:text-text-main">
|
||||||
|
<span className="material-symbols-outlined text-[18px]">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setAddMcpForm((f) => ({ ...f, type: "url" }))}
|
||||||
|
className={`flex-1 py-1.5 rounded border text-xs font-medium transition-colors ${addMcpForm.type === "url" ? "bg-primary/10 border-primary/40 text-primary" : "border-border text-text-muted hover:border-primary/40"}`}
|
||||||
|
>URL (SSE)</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setAddMcpForm((f) => ({ ...f, type: "cmd" }))}
|
||||||
|
className={`flex-1 py-1.5 rounded border text-xs font-medium transition-colors ${addMcpForm.type === "cmd" ? "bg-primary/10 border-primary/40 text-primary" : "border-border text-text-muted hover:border-primary/40"}`}
|
||||||
|
>Command (stdio)</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[11px] text-text-muted font-medium">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="my-mcp"
|
||||||
|
value={addMcpForm.name}
|
||||||
|
onChange={(e) => setAddMcpForm((f) => ({ ...f, name: e.target.value.replace(/\s+/g, "-").toLowerCase() }))}
|
||||||
|
className="px-2 py-1.5 rounded border border-border bg-surface text-xs outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{addMcpForm.type === "url" ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[11px] text-text-muted font-medium">SSE URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="https://your-mcp-server.com/sse"
|
||||||
|
value={addMcpForm.url}
|
||||||
|
onChange={(e) => setAddMcpForm((f) => ({ ...f, url: e.target.value }))}
|
||||||
|
className="px-2 py-1.5 rounded border border-border bg-surface text-xs outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[11px] text-text-muted font-medium">Command</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="npx"
|
||||||
|
value={addMcpForm.command}
|
||||||
|
onChange={(e) => setAddMcpForm((f) => ({ ...f, command: e.target.value }))}
|
||||||
|
className="px-2 py-1.5 rounded border border-border bg-surface text-xs outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[11px] text-text-muted font-medium">Args <span className="font-normal">(comma-separated)</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="-y, @some/mcp-package"
|
||||||
|
value={addMcpForm.args}
|
||||||
|
onChange={(e) => setAddMcpForm((f) => ({ ...f, args: e.target.value }))}
|
||||||
|
className="px-2 py-1.5 rounded border border-border bg-surface text-xs outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button onClick={() => setAddMcpOpen(false)} className="px-3 py-1.5 rounded border border-border text-xs text-text-muted hover:bg-surface cursor-pointer">Cancel</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const name = addMcpForm.name.trim();
|
||||||
|
if (!name) return;
|
||||||
|
if (addMcpForm.type === "url") {
|
||||||
|
if (!addMcpForm.url.trim()) return;
|
||||||
|
setCustomPlugins((prev) => [...prev.filter((x) => x.name !== name), { name, url: addMcpForm.url.trim(), transport: "sse", custom: true }]);
|
||||||
|
} else {
|
||||||
|
if (!addMcpForm.command.trim()) return;
|
||||||
|
const args = addMcpForm.args.split(",").map((a) => a.trim()).filter(Boolean);
|
||||||
|
setCustomPlugins((prev) => [...prev.filter((x) => x.name !== name), { name, command: addMcpForm.command.trim(), args, custom: true }]);
|
||||||
|
}
|
||||||
|
setAddMcpOpen(false);
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 rounded bg-primary text-white text-xs font-medium hover:opacity-90 cursor-pointer"
|
||||||
|
>Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -120,12 +120,25 @@ export default function ProvidersPage() {
|
||||||
!searchQuery.trim() ||
|
!searchQuery.trim() ||
|
||||||
name.toLowerCase().includes(searchQuery.trim().toLowerCase());
|
name.toLowerCase().includes(searchQuery.trim().toLowerCase());
|
||||||
|
|
||||||
const sortByConnections = (entries, authType) =>
|
const sortByPriority = (entries, authType) =>
|
||||||
[...entries].sort(
|
[...entries].sort(([ka, a], [kb, b]) => {
|
||||||
(a, b) =>
|
const sa = getProviderStats(ka, authType);
|
||||||
getProviderStats(b[0], authType).total -
|
const sb = getProviderStats(kb, authType);
|
||||||
getProviderStats(a[0], authType).total,
|
const ca = sa.connected > 0 ? 1 : 0;
|
||||||
);
|
const cb = sb.connected > 0 ? 1 : 0;
|
||||||
|
if (ca !== cb) return cb - ca;
|
||||||
|
return (a.name || "").localeCompare(b.name || "");
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortItemsByPriority = (items, authType) =>
|
||||||
|
[...items].sort((a, b) => {
|
||||||
|
const sa = getProviderStats(a.id, authType);
|
||||||
|
const sb = getProviderStats(b.id, authType);
|
||||||
|
const ca = sa.connected > 0 ? 1 : 0;
|
||||||
|
const cb = sb.connected > 0 ? 1 : 0;
|
||||||
|
if (ca !== cb) return cb - ca;
|
||||||
|
return (a.name || "").localeCompare(b.name || "");
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
|
|
@ -239,7 +252,8 @@ export default function ProvidersPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const compatibleProviders = providerNodes
|
const compatibleProviders = sortItemsByPriority(
|
||||||
|
providerNodes
|
||||||
.filter((node) => node.type === "openai-compatible")
|
.filter((node) => node.type === "openai-compatible")
|
||||||
.map((node) => ({
|
.map((node) => ({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
|
|
@ -248,9 +262,12 @@ export default function ProvidersPage() {
|
||||||
textIcon: "OC",
|
textIcon: "OC",
|
||||||
apiType: node.apiType,
|
apiType: node.apiType,
|
||||||
}))
|
}))
|
||||||
.filter((p) => matchSearch(p.name));
|
.filter((p) => matchSearch(p.name)),
|
||||||
|
"apikey",
|
||||||
|
);
|
||||||
|
|
||||||
const anthropicCompatibleProviders = providerNodes
|
const anthropicCompatibleProviders = sortItemsByPriority(
|
||||||
|
providerNodes
|
||||||
.filter((node) => node.type === "anthropic-compatible")
|
.filter((node) => node.type === "anthropic-compatible")
|
||||||
.map((node) => ({
|
.map((node) => ({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
|
|
@ -258,18 +275,25 @@ export default function ProvidersPage() {
|
||||||
color: "#D97757",
|
color: "#D97757",
|
||||||
textIcon: "AC",
|
textIcon: "AC",
|
||||||
}))
|
}))
|
||||||
.filter((p) => matchSearch(p.name));
|
.filter((p) => matchSearch(p.name)),
|
||||||
|
"apikey",
|
||||||
|
);
|
||||||
|
|
||||||
const oauthEntries = Object.entries(OAUTH_PROVIDERS).filter(([, info]) =>
|
const oauthEntries = sortByPriority(
|
||||||
|
Object.entries(OAUTH_PROVIDERS).filter(([, info]) => matchSearch(info.name)),
|
||||||
|
"oauth",
|
||||||
|
);
|
||||||
|
const freeEntries = sortByPriority(
|
||||||
|
Object.entries(FREE_PROVIDERS).filter(([, info]) => matchSearch(info.name)),
|
||||||
|
"oauth",
|
||||||
|
);
|
||||||
|
const freeTierEntries = sortByPriority(
|
||||||
|
Object.entries(FREE_TIER_PROVIDERS).filter(([, info]) =>
|
||||||
matchSearch(info.name),
|
matchSearch(info.name),
|
||||||
|
),
|
||||||
|
"apikey",
|
||||||
);
|
);
|
||||||
const freeEntries = Object.entries(FREE_PROVIDERS).filter(([, info]) =>
|
const apikeyEntries = sortByPriority(
|
||||||
matchSearch(info.name),
|
|
||||||
);
|
|
||||||
const freeTierEntries = Object.entries(FREE_TIER_PROVIDERS).filter(
|
|
||||||
([, info]) => matchSearch(info.name),
|
|
||||||
);
|
|
||||||
const apikeyEntries = sortByConnections(
|
|
||||||
Object.entries(APIKEY_PROVIDERS).filter(
|
Object.entries(APIKEY_PROVIDERS).filter(
|
||||||
([, info]) =>
|
([, info]) =>
|
||||||
(info.serviceKinds ?? ["llm"]).includes("llm") && matchSearch(info.name),
|
(info.serviceKinds ?? ["llm"]).includes("llm") && matchSearch(info.name),
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,11 @@ import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { DEFAULT_PLUGINS, buildManagedMcpServers } from "@/shared/constants/coworkPlugins";
|
import { DEFAULT_PLUGINS, LOCAL_STDIO_PLUGINS, buildManagedMcpServers } from "@/shared/constants/coworkPlugins";
|
||||||
|
import { UPDATER_CONFIG } from "@/shared/constants/config";
|
||||||
|
import { DATA_DIR } from "@/lib/dataDir";
|
||||||
|
|
||||||
|
const APP_PORT = UPDATER_CONFIG.appPort;
|
||||||
|
|
||||||
const PROVIDER = "gateway";
|
const PROVIDER = "gateway";
|
||||||
|
|
||||||
|
|
@ -89,21 +93,87 @@ const get1pRoot = () => {
|
||||||
return path.join(os.homedir(), ".config", "Claude");
|
return path.join(os.homedir(), ".config", "Claude");
|
||||||
};
|
};
|
||||||
|
|
||||||
const bootstrapDeploymentMode = async () => {
|
const get1pConfigPath = () => path.join(get1pRoot(), "claude_desktop_config.json");
|
||||||
const cfgPath = path.join(get1pRoot(), "claude_desktop_config.json");
|
|
||||||
let cfg = {};
|
const read1pConfig = async () => {
|
||||||
try {
|
try { return JSON.parse(await fs.readFile(get1pConfigPath(), "utf-8")) || {}; }
|
||||||
cfg = JSON.parse(await fs.readFile(cfgPath, "utf-8"));
|
catch (error) {
|
||||||
} catch (error) {
|
if (error.code === "ENOENT") return {};
|
||||||
if (error.code !== "ENOENT") throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const write1pConfig = async (cfg) => {
|
||||||
|
await fs.mkdir(get1pRoot(), { recursive: true });
|
||||||
|
await fs.writeFile(get1pConfigPath(), JSON.stringify(cfg, null, 2));
|
||||||
|
};
|
||||||
|
|
||||||
|
const bootstrapDeploymentMode = async () => {
|
||||||
|
const cfg = await read1pConfig();
|
||||||
if (cfg.deploymentMode === "3p") return false;
|
if (cfg.deploymentMode === "3p") return false;
|
||||||
cfg.deploymentMode = "3p";
|
cfg.deploymentMode = "3p";
|
||||||
await fs.mkdir(get1pRoot(), { recursive: true });
|
await write1pConfig(cfg);
|
||||||
await fs.writeFile(cfgPath, JSON.stringify(cfg, null, 2));
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Remove any legacy stdio entries previously written into 1p claude_desktop_config.json.
|
||||||
|
const cleanup1pLegacy = async () => {
|
||||||
|
const cfg = await read1pConfig();
|
||||||
|
if (!cfg.mcpServers || typeof cfg.mcpServers !== "object") return;
|
||||||
|
const managedNames = new Set(LOCAL_STDIO_PLUGINS.map((p) => p.name));
|
||||||
|
for (const k of Object.keys(cfg.mcpServers)) {
|
||||||
|
if (managedNames.has(k)) delete cfg.mcpServers[k];
|
||||||
|
}
|
||||||
|
if (Object.keys(cfg.mcpServers).length === 0) delete cfg.mcpServers;
|
||||||
|
await write1pConfig(cfg);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build SSE bridge entries pointing at this app's inline /api/mcp/{name} endpoint.
|
||||||
|
const buildLocalBridgeEntries = (localPluginNames) => {
|
||||||
|
const names = Array.isArray(localPluginNames) ? localPluginNames : [];
|
||||||
|
const out = [];
|
||||||
|
for (const n of names) {
|
||||||
|
const def = LOCAL_STDIO_PLUGINS.find((p) => p.name === n);
|
||||||
|
if (!def) continue;
|
||||||
|
const entry = {
|
||||||
|
name: def.name,
|
||||||
|
url: `http://localhost:${APP_PORT}/api/mcp/${def.name}/sse`,
|
||||||
|
transport: "sse",
|
||||||
|
};
|
||||||
|
if (Array.isArray(def.toolNames) && def.toolNames.length > 0) {
|
||||||
|
const prefix = `${def.name}-`;
|
||||||
|
const policy = {};
|
||||||
|
for (const t of def.toolNames) {
|
||||||
|
policy[t] = "allow";
|
||||||
|
policy[`${prefix}${t}`] = "allow";
|
||||||
|
}
|
||||||
|
entry.toolPolicy = policy;
|
||||||
|
}
|
||||||
|
out.push(entry);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build entries for user-defined custom MCP plugins (URL or stdio command).
|
||||||
|
const buildCustomEntries = (customPlugins) => {
|
||||||
|
if (!Array.isArray(customPlugins)) return [];
|
||||||
|
const out = [];
|
||||||
|
for (const p of customPlugins) {
|
||||||
|
if (!p?.name) continue;
|
||||||
|
if (p.url) {
|
||||||
|
out.push({ name: p.name, url: p.url, transport: p.transport || "sse", custom: true });
|
||||||
|
} else if (p.command) {
|
||||||
|
out.push({
|
||||||
|
name: p.name,
|
||||||
|
url: `http://localhost:${APP_PORT}/api/mcp/${encodeURIComponent(p.name)}/sse`,
|
||||||
|
transport: "sse",
|
||||||
|
custom: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
const checkInstalled = async () => {
|
const checkInstalled = async () => {
|
||||||
for (const dir of [...getCandidateRoots(), ...getAppInstallPaths()]) {
|
for (const dir of [...getCandidateRoots(), ...getAppInstallPaths()]) {
|
||||||
try { await fs.access(dir); return true; } catch { /* try next */ }
|
try { await fs.access(dir); return true; } catch { /* try next */ }
|
||||||
|
|
@ -171,6 +241,17 @@ export async function GET() {
|
||||||
const managedMcp = Array.isArray(config?.managedMcpServers) ? config.managedMcpServers : [];
|
const managedMcp = Array.isArray(config?.managedMcpServers) ? config.managedMcpServers : [];
|
||||||
const has9Router = !!(config?.inferenceProvider === PROVIDER && baseUrl);
|
const has9Router = !!(config?.inferenceProvider === PROVIDER && baseUrl);
|
||||||
|
|
||||||
|
// Active local plugins = managedMcp entries whose URL points at our inline bridge.
|
||||||
|
const stdioNames = new Set(LOCAL_STDIO_PLUGINS.map((p) => p.name));
|
||||||
|
const activeLocalNames = managedMcp
|
||||||
|
.filter((m) => stdioNames.has(m.name) && typeof m.url === "string" && m.url.includes("/api/mcp/"))
|
||||||
|
.map((m) => m.name);
|
||||||
|
|
||||||
|
// Custom plugins = bridge entries not in preset LOCAL_STDIO_PLUGINS (custom:true or unknown name).
|
||||||
|
const activeCustomPlugins = managedMcp
|
||||||
|
.filter((m) => m.custom || (!stdioNames.has(m.name) && typeof m.url === "string" && m.url.includes("/api/mcp/")))
|
||||||
|
.map((m) => ({ name: m.name, url: m.url, transport: m.transport, custom: true }));
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
installed: true,
|
installed: true,
|
||||||
config,
|
config,
|
||||||
|
|
@ -181,7 +262,7 @@ export async function GET() {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
models,
|
models,
|
||||||
provider: config?.inferenceProvider || null,
|
provider: config?.inferenceProvider || null,
|
||||||
plugins: managedMcp.map((m) => {
|
plugins: managedMcp.filter((m) => !m.custom && !(stdioNames.has(m.name) && typeof m.url === "string" && m.url.includes("/api/mcp/"))).map((m) => {
|
||||||
// Strip "{name}-" prefix and dedupe so re-applies don't multiply entries.
|
// Strip "{name}-" prefix and dedupe so re-applies don't multiply entries.
|
||||||
const keys = m.toolPolicy ? Object.keys(m.toolPolicy) : [];
|
const keys = m.toolPolicy ? Object.keys(m.toolPolicy) : [];
|
||||||
const prefix = `${m.name}-`;
|
const prefix = `${m.name}-`;
|
||||||
|
|
@ -196,8 +277,11 @@ export async function GET() {
|
||||||
const toolNames = def && Array.isArray(def.toolNames) ? def.toolNames : Array.from(bare);
|
const toolNames = def && Array.isArray(def.toolNames) ? def.toolNames : Array.from(bare);
|
||||||
return { name: m.name, url: m.url, transport: m.transport, oauth: !!m.oauth, toolNames };
|
return { name: m.name, url: m.url, transport: m.transport, oauth: !!m.oauth, toolNames };
|
||||||
}),
|
}),
|
||||||
|
localPlugins: activeLocalNames,
|
||||||
|
customPlugins: activeCustomPlugins,
|
||||||
},
|
},
|
||||||
defaultPlugins: DEFAULT_PLUGINS,
|
defaultPlugins: DEFAULT_PLUGINS,
|
||||||
|
localStdioPlugins: LOCAL_STDIO_PLUGINS,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error reading cowork settings:", error);
|
console.log("Error reading cowork settings:", error);
|
||||||
|
|
@ -207,7 +291,7 @@ export async function GET() {
|
||||||
|
|
||||||
export async function POST(request) {
|
export async function POST(request) {
|
||||||
try {
|
try {
|
||||||
const { baseUrl, apiKey, models, plugins } = await request.json();
|
const { baseUrl, apiKey, models, plugins, localPlugins, customPlugins } = await request.json();
|
||||||
|
|
||||||
if (!baseUrl || !apiKey) {
|
if (!baseUrl || !apiKey) {
|
||||||
return NextResponse.json({ error: "baseUrl and apiKey are required" }, { status: 400 });
|
return NextResponse.json({ error: "baseUrl and apiKey are required" }, { status: 400 });
|
||||||
|
|
@ -217,9 +301,26 @@ export async function POST(request) {
|
||||||
return NextResponse.json({ error: "At least one model is required" }, { status: 400 });
|
return NextResponse.json({ error: "At least one model is required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plugins: array of {name, url, transport?, oauth?}. Default to DEFAULT_PLUGINS if absent.
|
// Respect empty array (user toggled all off); fallback to defaults only when undefined.
|
||||||
const pluginsArray = Array.isArray(plugins) && plugins.length > 0 ? plugins : DEFAULT_PLUGINS;
|
const pluginsArray = Array.isArray(plugins) ? plugins : DEFAULT_PLUGINS;
|
||||||
const managedMcpServers = buildManagedMcpServers(pluginsArray);
|
const localPluginNames = Array.isArray(localPlugins) ? localPlugins : [];
|
||||||
|
const customPluginsArray = Array.isArray(customPlugins) ? customPlugins : [];
|
||||||
|
|
||||||
|
// Register custom stdio plugins into bridge + persist for restart survival.
|
||||||
|
if (customPluginsArray.length > 0) {
|
||||||
|
const { registerCustomPlugin } = require("@/lib/mcp/stdioSseBridge");
|
||||||
|
const stdioCustoms = customPluginsArray.filter((p) => p.command).map((p) => ({ name: p.name, command: p.command, args: p.args || [] }));
|
||||||
|
for (const p of stdioCustoms) registerCustomPlugin(p);
|
||||||
|
try {
|
||||||
|
const dir = path.join(DATA_DIR, "mcp");
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(dir, "customPlugins.json"), JSON.stringify(stdioCustoms, null, 2));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const bridgeEntries = buildLocalBridgeEntries(localPluginNames);
|
||||||
|
const customEntries = buildCustomEntries(customPluginsArray);
|
||||||
|
const managedMcpServers = [...buildManagedMcpServers(pluginsArray), ...bridgeEntries, ...customEntries];
|
||||||
|
|
||||||
const bootstrapped = await bootstrapDeploymentMode();
|
const bootstrapped = await bootstrapDeploymentMode();
|
||||||
const meta = await ensureMeta();
|
const meta = await ensureMeta();
|
||||||
|
|
@ -239,6 +340,10 @@ export async function POST(request) {
|
||||||
let skipResult = null;
|
let skipResult = null;
|
||||||
try { skipResult = await writeSkipApprovals(managedMcpServers); } catch (e) { skipResult = { error: e.message }; }
|
try { skipResult = await writeSkipApprovals(managedMcpServers); } catch (e) { skipResult = { error: e.message }; }
|
||||||
|
|
||||||
|
// Best-effort cleanup of legacy 1p mcpServers entries written by earlier versions.
|
||||||
|
let localMcpResult = { applied: localPluginNames, via: "3p-sse-bridge" };
|
||||||
|
try { await cleanup1pLegacy(); } catch { /* ignore */ }
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
bootstrapped,
|
bootstrapped,
|
||||||
|
|
@ -247,6 +352,7 @@ export async function POST(request) {
|
||||||
: "Cowork settings applied. Quit & reopen Claude Desktop.",
|
: "Cowork settings applied. Quit & reopen Claude Desktop.",
|
||||||
configPath,
|
configPath,
|
||||||
skipApprovals: skipResult,
|
skipApprovals: skipResult,
|
||||||
|
localMcp: localMcpResult,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error applying cowork settings:", error);
|
console.log("Error applying cowork settings:", error);
|
||||||
|
|
@ -264,6 +370,7 @@ export async function DELETE() {
|
||||||
try { await fs.writeFile(configPath, JSON.stringify({}, null, 2)); }
|
try { await fs.writeFile(configPath, JSON.stringify({}, null, 2)); }
|
||||||
catch (error) { if (error.code !== "ENOENT") throw error; }
|
catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||||
try { await writeSkipApprovals([]); } catch { /* ignore */ }
|
try { await writeSkipApprovals([]); } catch { /* ignore */ }
|
||||||
|
try { await cleanup1pLegacy(); } catch { /* ignore */ }
|
||||||
return NextResponse.json({ success: true, message: "Cowork config reset" });
|
return NextResponse.json({ success: true, message: "Cowork config reset" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error resetting cowork settings:", error);
|
console.log("Error resetting cowork settings:", error);
|
||||||
|
|
|
||||||
19
src/app/api/mcp/[plugin]/message/route.js
Normal file
19
src/app/api/mcp/[plugin]/message/route.js
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { sendToChild, findPlugin } from "@/lib/mcp/stdioSseBridge";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function POST(request, { params }) {
|
||||||
|
const { plugin } = await params;
|
||||||
|
if (!findPlugin(plugin)) {
|
||||||
|
return NextResponse.json({ error: `Unknown plugin: ${plugin}` }, { status: 404 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
sendToChild(plugin, body);
|
||||||
|
return new Response(null, { status: 202 });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ error: e.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/app/api/mcp/[plugin]/sse/route.js
Normal file
35
src/app/api/mcp/[plugin]/sse/route.js
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { registerSession, unregisterSession, findPlugin } from "@/lib/mcp/stdioSseBridge";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(request, { params }) {
|
||||||
|
const { plugin } = await params;
|
||||||
|
if (!findPlugin(plugin)) {
|
||||||
|
return new Response(`Unknown plugin: ${plugin}`, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
let sid;
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
const send = (chunk) => controller.enqueue(encoder.encode(chunk));
|
||||||
|
sid = registerSession(plugin, send);
|
||||||
|
// MCP SSE handshake: tell client where to POST messages.
|
||||||
|
send(`event: endpoint\ndata: /api/mcp/${plugin}/message?sessionId=${sid}\n\n`);
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
if (sid) unregisterSession(plugin, sid);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -286,48 +286,34 @@ export async function POST(request) {
|
||||||
case "minimax":
|
case "minimax":
|
||||||
case "minimax-cn":
|
case "minimax-cn":
|
||||||
case "alicode-intl":
|
case "alicode-intl":
|
||||||
case "alicode": {
|
case "alicode":
|
||||||
const claudeBaseUrls = {
|
case "agentrouter": {
|
||||||
glm: "https://api.z.ai/api/anthropic/v1/messages",
|
// Use baseUrl from PROVIDERS (DRY); separate openai-format vs claude-format flow
|
||||||
"glm-cn": "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions",
|
const cfg = PROVIDERS[provider];
|
||||||
kimi: "https://api.kimi.com/coding/v1/messages",
|
const isOpenAiFormat = provider === "glm-cn" || provider === "alicode" || provider === "alicode-intl";
|
||||||
minimax: "https://api.minimax.io/anthropic/v1/messages",
|
|
||||||
"minimax-cn": "https://api.minimaxi.com/anthropic/v1/messages",
|
|
||||||
alicode: "https://coding.dashscope.aliyuncs.com/v1/chat/completions",
|
|
||||||
"alicode-intl": "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions",
|
|
||||||
};
|
|
||||||
|
|
||||||
// glm-cn, alicode and alicode-intl use OpenAI format
|
if (isOpenAiFormat) {
|
||||||
if (provider === "glm-cn" || provider === "alicode" || provider === "alicode-intl") {
|
|
||||||
const testModel = getDefaultModel(provider);
|
const testModel = getDefaultModel(provider);
|
||||||
const glmCnRes = await fetch(claudeBaseUrls[provider], {
|
const res = await fetch(cfg.baseUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Authorization": `Bearer ${apiKey}`, "content-type": "application/json" },
|
||||||
"Authorization": `Bearer ${apiKey}`,
|
body: JSON.stringify({ model: testModel, max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
|
||||||
"content-type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: testModel,
|
|
||||||
max_tokens: 1,
|
|
||||||
messages: [{ role: "user", content: "test" }],
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
isValid = glmCnRes.status !== 401 && glmCnRes.status !== 403;
|
isValid = res.status !== 401 && res.status !== 403;
|
||||||
} else {
|
} else {
|
||||||
const claudeRes = await fetch(claudeBaseUrls[provider], {
|
const testModel = getDefaultModel(provider) || "claude-sonnet-4-20250514";
|
||||||
|
const res = await fetch(cfg.baseUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"x-api-key": apiKey,
|
"x-api-key": apiKey,
|
||||||
"anthropic-version": "2023-06-01",
|
"anthropic-version": "2023-06-01",
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
|
...(cfg.headers || {}),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ model: testModel, max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
|
||||||
model: "claude-sonnet-4-20250514",
|
|
||||||
max_tokens: 1,
|
|
||||||
messages: [{ role: "user", content: "test" }],
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
isValid = claudeRes.status !== 401;
|
// 400 = model resolution error but auth passed (e.g. agentrouter "no available channel")
|
||||||
|
isValid = res.status !== 401 && res.status !== 403;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -588,9 +574,44 @@ export async function POST(request) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default: {
|
||||||
|
// Generic probe for OpenAI-compatible providers (config-driven from PROVIDERS)
|
||||||
|
const cfg = PROVIDERS[provider];
|
||||||
|
if (!cfg || cfg.format !== "openai" || !cfg.baseUrl) {
|
||||||
return NextResponse.json({ error: "Provider validation not supported" }, { status: 400 });
|
return NextResponse.json({ error: "Provider validation not supported" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
if (cfg.noAuth) {
|
||||||
|
isValid = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Build auth headers based on cfg.authHeader (default: bearer)
|
||||||
|
const headers = { "Content-Type": "application/json", ...(cfg.headers || {}) };
|
||||||
|
if (cfg.authHeader === "x-api-key") headers["X-API-Key"] = apiKey;
|
||||||
|
else headers["Authorization"] = `Bearer ${apiKey}`;
|
||||||
|
// Try /models first (fast GET), fallback to chat probe on ambiguous response
|
||||||
|
const modelsUrl = cfg.baseUrl.replace(/\/chat\/completions$/, "/models").replace(/\/chatbot$/, "/models");
|
||||||
|
let probeOk = null;
|
||||||
|
try {
|
||||||
|
const probeRes = await fetch(modelsUrl, { headers, signal: AbortSignal.timeout(8000) });
|
||||||
|
if (probeRes.status === 401 || probeRes.status === 403) probeOk = false;
|
||||||
|
else if (probeRes.ok) probeOk = true;
|
||||||
|
} catch { /* fallback to chat */ }
|
||||||
|
if (probeOk !== null) {
|
||||||
|
isValid = probeOk;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Fallback: minimal chat probe
|
||||||
|
const defaultModel = getDefaultModel(provider) || "test";
|
||||||
|
const chatRes = await fetch(cfg.baseUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ model: defaultModel, messages: [{ role: "user", content: "ping" }], max_tokens: 1 }),
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
isValid = chatRes.status !== 401 && chatRes.status !== 403;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message;
|
error = err.message;
|
||||||
isValid = false;
|
isValid = false;
|
||||||
|
|
|
||||||
190
src/lib/mcp/stdioSseBridge.js
Normal file
190
src/lib/mcp/stdioSseBridge.js
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
// Inline stdio<->SSE bridge for MCP. Spawns one child per plugin on demand,
|
||||||
|
// broadcasts JSON-RPC frames over SSE, accepts client messages via HTTP POST.
|
||||||
|
|
||||||
|
const { spawn } = require("child_process");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const { LOCAL_STDIO_PLUGINS } = require("@/shared/constants/coworkPlugins");
|
||||||
|
const { DATA_DIR } = require("@/lib/dataDir");
|
||||||
|
|
||||||
|
const CUSTOM_FILE = path.join(DATA_DIR, "mcp", "customPlugins.json");
|
||||||
|
|
||||||
|
const G_KEY = "__9routerMcpBridges";
|
||||||
|
const MAX_TEXT_CHARS = 50000;
|
||||||
|
const COLLAPSE_THRESHOLD = 30;
|
||||||
|
const COLLAPSE_KEEP_HEAD = 10;
|
||||||
|
const COLLAPSE_KEEP_TAIL = 5;
|
||||||
|
|
||||||
|
// Drop noise nodes, collapse repeated siblings, hard-truncate. Preserve [ref=eXX].
|
||||||
|
function smartFilterText(text) {
|
||||||
|
if (typeof text !== "string" || text.length < 2000) return text;
|
||||||
|
let out = text;
|
||||||
|
out = out.replace(/^\s*-\s*generic:?\s*$/gm, "");
|
||||||
|
out = out.replace(/^\s*-\s*text:\s*""\s*$/gm, "");
|
||||||
|
out = collapseRepeated(out);
|
||||||
|
if (out.length > MAX_TEXT_CHARS) {
|
||||||
|
const head = out.slice(0, MAX_TEXT_CHARS - 300);
|
||||||
|
out = `${head}\n\n... [truncated ${text.length - head.length} chars by 9router bridge. Page is large; ask user to scroll/navigate to a specific section, or click an element with the refs shown above]`;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group consecutive lines sharing the same leading indent + role prefix; collapse if >= COLLAPSE_THRESHOLD.
|
||||||
|
function collapseRepeated(text) {
|
||||||
|
const lines = text.split("\n");
|
||||||
|
const out = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i];
|
||||||
|
const m = line.match(/^(\s*)-\s*([a-zA-Z]+)\b/);
|
||||||
|
if (!m) { out.push(line); i++; continue; }
|
||||||
|
const indent = m[1];
|
||||||
|
const role = m[2];
|
||||||
|
let j = i;
|
||||||
|
while (j < lines.length) {
|
||||||
|
const ln = lines[j];
|
||||||
|
const mm = ln.match(/^(\s*)-\s*([a-zA-Z]+)\b/);
|
||||||
|
if (mm && mm[1] === indent && mm[2] === role) { j++; continue; }
|
||||||
|
if (ln.startsWith(`${indent} `) || ln.startsWith(`${indent}\t`)) { j++; continue; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const groupLen = j - i;
|
||||||
|
if (groupLen >= COLLAPSE_THRESHOLD) {
|
||||||
|
const headEnd = findNthSiblingEnd(lines, i, indent, role, COLLAPSE_KEEP_HEAD);
|
||||||
|
const tailStart = findLastNSiblingStart(lines, j, indent, role, COLLAPSE_KEEP_TAIL);
|
||||||
|
for (let k = i; k < headEnd; k++) out.push(lines[k]);
|
||||||
|
out.push(`${indent}... [${groupLen - COLLAPSE_KEEP_HEAD - COLLAPSE_KEEP_TAIL} similar "${role}" items omitted by 9router bridge]`);
|
||||||
|
for (let k = tailStart; k < j; k++) out.push(lines[k]);
|
||||||
|
} else {
|
||||||
|
for (let k = i; k < j; k++) out.push(lines[k]);
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
return out.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNthSiblingEnd(lines, start, indent, role, n) {
|
||||||
|
let count = 0;
|
||||||
|
for (let k = start; k < lines.length; k++) {
|
||||||
|
const mm = lines[k].match(/^(\s*)-\s*([a-zA-Z]+)\b/);
|
||||||
|
if (mm && mm[1] === indent && mm[2] === role) {
|
||||||
|
count++;
|
||||||
|
if (count > n) return k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLastNSiblingStart(lines, end, indent, role, n) {
|
||||||
|
const positions = [];
|
||||||
|
for (let k = 0; k < end; k++) {
|
||||||
|
const mm = lines[k].match(/^(\s*)-\s*([a-zA-Z]+)\b/);
|
||||||
|
if (mm && mm[1] === indent && mm[2] === role) positions.push(k);
|
||||||
|
}
|
||||||
|
return positions.length > n ? positions[positions.length - n] : end;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filter to JSON-RPC tool/result content text blocks only.
|
||||||
|
function filterFrame(line) {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(line);
|
||||||
|
const content = msg?.result?.content;
|
||||||
|
if (!Array.isArray(content)) return line;
|
||||||
|
let mutated = false;
|
||||||
|
for (const item of content) {
|
||||||
|
if (item?.type === "text" && typeof item.text === "string") {
|
||||||
|
const filtered = smartFilterText(item.text);
|
||||||
|
if (filtered !== item.text) { item.text = filtered; mutated = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mutated ? JSON.stringify(msg) : line;
|
||||||
|
} catch { return line; }
|
||||||
|
}
|
||||||
|
const getStore = () => {
|
||||||
|
if (!globalThis[G_KEY]) globalThis[G_KEY] = new Map();
|
||||||
|
return globalThis[G_KEY];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCustomStore = () => {
|
||||||
|
if (!globalThis.__9routerCustomPlugins) globalThis.__9routerCustomPlugins = new Map();
|
||||||
|
return globalThis.__9routerCustomPlugins;
|
||||||
|
};
|
||||||
|
|
||||||
|
function registerCustomPlugin(def) {
|
||||||
|
getCustomStore().set(def.name, def);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPlugin(name) {
|
||||||
|
const fromMem = getCustomStore().get(name) || LOCAL_STDIO_PLUGINS.find((p) => p.name === name);
|
||||||
|
if (fromMem) return fromMem;
|
||||||
|
// Lazy-load custom plugins from disk (survives app restart).
|
||||||
|
try {
|
||||||
|
const list = JSON.parse(fs.readFileSync(CUSTOM_FILE, "utf-8"));
|
||||||
|
const def = Array.isArray(list) ? list.find((p) => p.name === name && p.command) : null;
|
||||||
|
if (def) { getCustomStore().set(def.name, def); return def; }
|
||||||
|
} catch { /* file missing or invalid */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrSpawn(name) {
|
||||||
|
const store = getStore();
|
||||||
|
let entry = store.get(name);
|
||||||
|
if (entry?.proc && !entry.proc.killed && entry.proc.exitCode === null) return entry;
|
||||||
|
|
||||||
|
const plugin = findPlugin(name);
|
||||||
|
if (!plugin) throw new Error(`Unknown local plugin: ${name}`);
|
||||||
|
|
||||||
|
const proc = spawn(plugin.command, plugin.args, { stdio: ["pipe", "pipe", "pipe"], env: process.env });
|
||||||
|
entry = { proc, sessions: new Map(), buffer: "" };
|
||||||
|
store.set(name, entry);
|
||||||
|
|
||||||
|
// Parse newline-delimited JSON-RPC from child stdout, broadcast to all sessions.
|
||||||
|
proc.stdout.on("data", (chunk) => {
|
||||||
|
entry.buffer += chunk.toString("utf8");
|
||||||
|
let idx;
|
||||||
|
while ((idx = entry.buffer.indexOf("\n")) >= 0) {
|
||||||
|
const raw = entry.buffer.slice(0, idx).trim();
|
||||||
|
entry.buffer = entry.buffer.slice(idx + 1);
|
||||||
|
if (!raw) continue;
|
||||||
|
const line = filterFrame(raw);
|
||||||
|
for (const send of entry.sessions.values()) {
|
||||||
|
try { send(`event: message\ndata: ${line}\n\n`); } catch { /* ignore broken pipe */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.stderr.on("data", (d) => console.log(`[mcp:${name}]`, d.toString().trim()));
|
||||||
|
proc.on("exit", (code) => {
|
||||||
|
console.log(`[mcp:${name}] exited`, code);
|
||||||
|
store.delete(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerSession(name, sendFn) {
|
||||||
|
const entry = getOrSpawn(name);
|
||||||
|
const sid = crypto.randomUUID();
|
||||||
|
entry.sessions.set(sid, sendFn);
|
||||||
|
return sid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unregisterSession(name, sid) {
|
||||||
|
const entry = getStore().get(name);
|
||||||
|
if (!entry) return;
|
||||||
|
entry.sessions.delete(sid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendToChild(name, jsonRpc) {
|
||||||
|
const entry = getStore().get(name);
|
||||||
|
if (!entry?.proc?.stdin?.writable) throw new Error(`Bridge not running: ${name}`);
|
||||||
|
entry.proc.stdin.write(`${JSON.stringify(jsonRpc)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRunning(name) {
|
||||||
|
const entry = getStore().get(name);
|
||||||
|
return !!(entry?.proc && !entry.proc.killed && entry.proc.exitCode === null);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getOrSpawn, registerSession, unregisterSession, sendToChild, isRunning, findPlugin, registerCustomPlugin };
|
||||||
|
|
@ -12,7 +12,7 @@ export const INTERNET_CHECK = {
|
||||||
timeoutMs: 3000,
|
timeoutMs: 3000,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RESTART_COOLDOWN_MS = 60000;
|
export const RESTART_COOLDOWN_MS = 180000;
|
||||||
export const NETWORK_SETTLE_MS = 2500;
|
export const NETWORK_SETTLE_MS = 2500;
|
||||||
export const WATCHDOG_INTERVAL_MS = 60000;
|
export const WATCHDOG_INTERVAL_MS = 60000;
|
||||||
export const NETWORK_CHECK_INTERVAL_MS = 5000;
|
export const NETWORK_CHECK_INTERVAL_MS = 5000;
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,86 @@ export const CLI_TOOLS = {
|
||||||
"model": "{{model}}",
|
"model": "{{model}}",
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"apiKey": "{{apiKey}}"
|
"apiKey": "{{apiKey}}"
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
amp: {
|
||||||
|
id: "amp",
|
||||||
|
name: "Amp CLI",
|
||||||
|
icon: "terminal",
|
||||||
|
color: "#F97316",
|
||||||
|
description: "Sourcegraph Amp coding assistant CLI",
|
||||||
|
docsUrl: "/docs?section=cli-tools&tool=amp",
|
||||||
|
configType: "guide",
|
||||||
|
defaultCommand: "amp",
|
||||||
|
modelAliases: ["g25p", "g25f", "cs45", "g54"],
|
||||||
|
notes: [
|
||||||
|
{ type: "info", text: "Use 9Router model aliases to keep Amp shorthand mappings stable across provider updates." },
|
||||||
|
{ type: "warning", text: "Suggested shorthand examples: g25p → gemini/gemini-2.5-pro, g25f → gemini/gemini-2.5-flash, cs45 → cc/claude-sonnet-4-5-20250929." },
|
||||||
|
],
|
||||||
|
guideSteps: [
|
||||||
|
{ step: 1, title: "Install Amp", desc: "Install the Amp CLI using the package manager supported by your environment." },
|
||||||
|
{ step: 2, title: "API Key", type: "apiKeySelector" },
|
||||||
|
{ step: 3, title: "Base URL", value: "{{baseUrl}}", copyable: true },
|
||||||
|
{ step: 4, title: "Select Model", type: "modelSelector" },
|
||||||
|
{ step: 5, title: "Add Shorthands", desc: "Map Amp shorthand names such as g25p or cs45 to 9Router aliases in your local config." },
|
||||||
|
],
|
||||||
|
codeBlock: {
|
||||||
|
language: "bash",
|
||||||
|
code: `export OPENAI_API_KEY="{{apiKey}}"
|
||||||
|
export OPENAI_BASE_URL="{{baseUrl}}"
|
||||||
|
amp --model "{{model}}"
|
||||||
|
# Example shorthand aliases you can map locally:
|
||||||
|
# g25p -> gemini/gemini-2.5-pro
|
||||||
|
# cs45 -> cc/claude-sonnet-4-5-20250929`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
qwen: {
|
||||||
|
id: "qwen",
|
||||||
|
name: "Qwen Code",
|
||||||
|
icon: "psychology",
|
||||||
|
color: "#10B981",
|
||||||
|
description: "Alibaba Qwen Code CLI — supports OpenAI, Anthropic & Gemini providers via 9Router",
|
||||||
|
docsUrl: "https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/",
|
||||||
|
configType: "guide",
|
||||||
|
defaultCommand: "qwen",
|
||||||
|
notes: [
|
||||||
|
{ type: "info", text: "Qwen Code supports multiple provider types (openai, anthropic, gemini) via modelProviders in settings.json. 9Router works as an OpenAI-compatible endpoint." },
|
||||||
|
{ type: "info", text: "Any model available in 9Router can be used — not just Qwen models. Select from Qwen, Claude, Gemini, GPT, and more." },
|
||||||
|
{ type: "warning", text: "Config path: Linux/macOS ~/.qwen/settings.json • Windows %USERPROFILE%\\.qwen\\settings.json" },
|
||||||
|
{ type: "error", text: "Qwen OAuth free tier was discontinued on 2026-04-15. Use 9Router with alicode/openrouter/anthropic/gemini providers instead." },
|
||||||
|
],
|
||||||
|
modelAliases: ["coder-model", "qwen3-coder-plus", "qwen3-coder-flash", "vision-model", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gemini-3-flash", "gemini-3.1-pro-high"],
|
||||||
|
defaultModels: [
|
||||||
|
{ id: "coder-model", name: "Coder Model (Qwen 3.6 Plus)", alias: "coder-model", envKey: "OPENAI_MODEL", defaultValue: "coder-model", isTopLevel: true },
|
||||||
|
{ id: "qwen3-coder-plus", name: "Qwen 3 Coder Plus", alias: "qwen3-coder-plus", envKey: "OPENAI_MODEL", defaultValue: "qwen3-coder-plus" },
|
||||||
|
{ id: "qwen3-coder-flash", name: "Qwen 3 Coder Flash", alias: "qwen3-coder-flash", envKey: "OPENAI_MODEL", defaultValue: "qwen3-coder-flash" },
|
||||||
|
{ id: "vision-model", name: "Vision Model (Multimodal)", alias: "vision-model", envKey: "OPENAI_MODEL", defaultValue: "vision-model" },
|
||||||
|
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", alias: "claude-sonnet-4-6", envKey: "OPENAI_MODEL", defaultValue: "claude-sonnet-4-6" },
|
||||||
|
{ id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking", alias: "claude-opus-4-6-thinking", envKey: "OPENAI_MODEL", defaultValue: "claude-opus-4-6-thinking" },
|
||||||
|
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High", alias: "gemini-3.1-pro-high", envKey: "OPENAI_MODEL", defaultValue: "gemini-3.1-pro-high" },
|
||||||
|
{ id: "gemini-3-flash", name: "Gemini 3 Flash", alias: "gemini-3-flash", envKey: "OPENAI_MODEL", defaultValue: "gemini-3-flash" },
|
||||||
|
],
|
||||||
|
guideSteps: [
|
||||||
|
{ step: 1, title: "Install Qwen Code", desc: "npm install -g @qwen-code/qwen-code" },
|
||||||
|
{ step: 2, title: "API Key", type: "apiKeySelector" },
|
||||||
|
{ step: 3, title: "Base URL", value: "{{baseUrl}}", copyable: true },
|
||||||
|
{ step: 4, title: "Select Model", type: "modelSelector" },
|
||||||
|
{ step: 5, title: "Save Config", desc: "Copy the JSON below to your ~/.qwen/settings.json file." },
|
||||||
|
],
|
||||||
|
codeBlock: {
|
||||||
|
language: "json",
|
||||||
|
code: `{
|
||||||
|
"security": {
|
||||||
|
"auth": {
|
||||||
|
"selectedType": "openai",
|
||||||
|
"apiKey": "{{apiKey}}",
|
||||||
|
"baseUrl": "{{baseUrl}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"name": "{{model}}"
|
||||||
|
}
|
||||||
}`,
|
}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
// Default plugins auto-installed for Claude Cowork (3p mode).
|
// Default remote plugins for Claude Cowork (3p managedMcpServers, HTTPS only).
|
||||||
// Exa works without auth; Tavily uses OAuth (DCR auto-flow).
|
|
||||||
const DEFAULT_PLUGINS = [
|
const DEFAULT_PLUGINS = [
|
||||||
{
|
{
|
||||||
name: "exa",
|
name: "exa",
|
||||||
|
|
@ -21,20 +20,24 @@ const DEFAULT_PLUGINS = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Build managedMcpServers entries from plugin objects.
|
// Local stdio plugins bridged via inline SSE endpoint on the app's port.
|
||||||
// Schema: [{name, url, transport, oauth?, toolPolicy?}]
|
const LOCAL_STDIO_PLUGINS = [
|
||||||
// toolPolicy maps each tool to "allow" so Claude doesn't prompt.
|
{
|
||||||
// Plugin name that's force-installed regardless of user selection.
|
name: "browsermcp",
|
||||||
const ALWAYS_ON = "exa";
|
title: "Browser MCP",
|
||||||
|
description: "Control your running Chrome (requires Chrome extension)",
|
||||||
|
extensionUrl: "https://chromewebstore.google.com/detail/browser-mcp-automate-your/bjfgambnhccakkhmkepdoekmckoijdlc",
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", "@browsermcp/mcp@latest"],
|
||||||
|
toolNames: ["browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_screenshot", "browser_get_console_logs", "browser_wait", "browser_press_key", "browser_go_back", "browser_go_forward"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function buildManagedMcpServers(plugins) {
|
function buildManagedMcpServers(plugins) {
|
||||||
const list = Array.isArray(plugins) ? plugins : [];
|
const list = Array.isArray(plugins) ? plugins : [];
|
||||||
// Force Exa always-on at the front; drop any duplicate from user list.
|
|
||||||
const exaDefault = DEFAULT_PLUGINS.find((p) => p.name === ALWAYS_ON);
|
|
||||||
const merged = exaDefault ? [exaDefault, ...list.filter((p) => p?.name !== ALWAYS_ON)] : list;
|
|
||||||
const out = [];
|
const out = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
for (const p of merged) {
|
for (const p of list) {
|
||||||
if (!p?.name || !p?.url || seen.has(p.name)) continue;
|
if (!p?.name || !p?.url || seen.has(p.name)) continue;
|
||||||
seen.add(p.name);
|
seen.add(p.name);
|
||||||
const entry = {
|
const entry = {
|
||||||
|
|
@ -66,4 +69,4 @@ function buildManagedMcpServers(plugins) {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { DEFAULT_PLUGINS, buildManagedMcpServers, ALWAYS_ON };
|
module.exports = { DEFAULT_PLUGINS, LOCAL_STDIO_PLUGINS, buildManagedMcpServers };
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,37 @@ export const APIKEY_PROVIDERS = {
|
||||||
huggingface: { id: "huggingface", alias: "hf", name: "HuggingFace", icon: "face", color: "#FFD21E", textIcon: "HF", website: "https://huggingface.co", notice: { apiKeyUrl: "https://huggingface.co/settings/tokens" }, serviceKinds: ["image", "imageToText", "tts", "stt"], hiddenKinds: ["tts"], ttsConfig: { baseUrl: "https://api-inference.huggingface.co/models", authType: "apikey", authHeader: "bearer", format: "huggingface-tts", models: [{ id: "facebook/mms-tts-eng", name: "MMS TTS English" }, { id: "microsoft/speecht5_tts", name: "SpeechT5 TTS" }] }, sttConfig: { baseUrl: "https://api-inference.huggingface.co/models", authType: "apikey", authHeader: "bearer", format: "huggingface-asr", models: [{ id: "openai/whisper-large-v3", name: "Whisper Large v3 (HF)" }, { id: "openai/whisper-small", name: "Whisper Small (HF)" }] } },
|
huggingface: { id: "huggingface", alias: "hf", name: "HuggingFace", icon: "face", color: "#FFD21E", textIcon: "HF", website: "https://huggingface.co", notice: { apiKeyUrl: "https://huggingface.co/settings/tokens" }, serviceKinds: ["image", "imageToText", "tts", "stt"], hiddenKinds: ["tts"], ttsConfig: { baseUrl: "https://api-inference.huggingface.co/models", authType: "apikey", authHeader: "bearer", format: "huggingface-tts", models: [{ id: "facebook/mms-tts-eng", name: "MMS TTS English" }, { id: "microsoft/speecht5_tts", name: "SpeechT5 TTS" }] }, sttConfig: { baseUrl: "https://api-inference.huggingface.co/models", authType: "apikey", authHeader: "bearer", format: "huggingface-asr", models: [{ id: "openai/whisper-large-v3", name: "Whisper Large v3 (HF)" }, { id: "openai/whisper-small", name: "Whisper Small (HF)" }] } },
|
||||||
blackbox: { id: "blackbox", alias: "bb", name: "Blackbox AI", icon: "smart_toy", color: "#5B5FEF", textIcon: "BB", website: "https://blackbox.ai", notice: { apiKeyUrl: "https://www.blackbox.ai/api-management" }, serviceKinds: ["llm"] },
|
blackbox: { id: "blackbox", alias: "bb", name: "Blackbox AI", icon: "smart_toy", color: "#5B5FEF", textIcon: "BB", website: "https://blackbox.ai", notice: { apiKeyUrl: "https://www.blackbox.ai/api-management" }, serviceKinds: ["llm"] },
|
||||||
chutes: { id: "chutes", alias: "ch", name: "Chutes AI", icon: "water_drop", color: "#ffffffff", textIcon: "CH", website: "https://chutes.ai", notice: { apiKeyUrl: "https://chutes.ai/app/api" } },
|
chutes: { id: "chutes", alias: "ch", name: "Chutes AI", icon: "water_drop", color: "#ffffffff", textIcon: "CH", website: "https://chutes.ai", notice: { apiKeyUrl: "https://chutes.ai/app/api" } },
|
||||||
|
// === Free-tier LLM providers (synced from OmniRoute) — DISABLED in UI ===
|
||||||
|
// Uncomment to re-enable. Backend config (PROVIDERS, PROVIDER_MODELS, ALIAS_TO_PROVIDER_ID) remains active.
|
||||||
|
// agentrouter: { id: "agentrouter", alias: "agentrouter", name: "AgentRouter", icon: "router", color: "#10B981", textIcon: "AR", website: "https://agentrouter.org", notice: { text: "$200 free credits on signup - multi-model routing gateway.", apiKeyUrl: "https://agentrouter.org/register" }, passthroughModels: true, serviceKinds: ["llm"] },
|
||||||
|
// aimlapi: { id: "aimlapi", alias: "aiml", name: "AI/ML API", icon: "hub", color: "#6366F1", textIcon: "AI", website: "https://aimlapi.com", notice: { text: "$0.025/day free — 200+ models (GPT-4o, Claude, Gemini, Llama) via single endpoint.", apiKeyUrl: "https://aimlapi.com/app/keys" }, passthroughModels: true, serviceKinds: ["llm", "image"] },
|
||||||
|
// novita: { id: "novita", alias: "novita", name: "Novita AI", icon: "auto_awesome", color: "#FF4081", textIcon: "NV", website: "https://novita.ai", notice: { text: "$0.50 trial credits on signup (valid ~1 year).", apiKeyUrl: "https://novita.ai/settings/key-management" }, passthroughModels: true, serviceKinds: ["llm", "image"] },
|
||||||
|
// modal: { id: "modal", alias: "mdl", name: "Modal", icon: "cloud_queue", color: "#7C3AED", textIcon: "MDL", website: "https://modal.com", notice: { text: "$30/month free credits for new accounts. Self-hosted OpenAI-compatible apps on /v1.", apiKeyUrl: "https://modal.com/settings/tokens" }, passthroughModels: true, serviceKinds: ["llm"], hasProviderSpecificData: true },
|
||||||
|
// reka: { id: "reka", alias: "reka", name: "Reka", icon: "auto_awesome", color: "#111827", textIcon: "RK", website: "https://docs.reka.ai", notice: { text: "$10/month recurring free API credits.", apiKeyUrl: "https://platform.reka.ai/apikeys" }, serviceKinds: ["llm"] },
|
||||||
|
// nlpcloud: { id: "nlpcloud", alias: "nlpc", name: "NLP Cloud", icon: "psychology", color: "#2196F3", textIcon: "NLPC", website: "https://docs.nlpcloud.com", notice: { text: "Trial credits for new accounts.", apiKeyUrl: "https://nlpcloud.com/home/token" }, serviceKinds: ["llm"] },
|
||||||
|
// bazaarlink: { id: "bazaarlink", alias: "bzl", name: "BazaarLink", icon: "storefront", color: "#6366F1", textIcon: "BZ", website: "https://bazaarlink.ai", notice: { text: "Use model 'auto:free' for zero-cost inference. OpenAI-compatible.", apiKeyUrl: "https://bazaarlink.ai" }, serviceKinds: ["llm"] },
|
||||||
|
// completions: { id: "completions", alias: "cpl", name: "Completions.me", icon: "bolt", color: "#F59E0B", textIcon: "CP", website: "https://completions.me", notice: { text: "Free unlimited access to Claude, GPT, Gemini.", apiKeyUrl: "https://completions.me" }, serviceKinds: ["llm"] },
|
||||||
|
// enally: { id: "enally", alias: "enly", name: "Enally AI", icon: "school", color: "#8B5CF6", textIcon: "EN", website: "https://ai.enally.in", notice: { text: "Free for students and developers — OTP verification.", apiKeyUrl: "https://ai.enally.in/api" }, serviceKinds: ["llm"] },
|
||||||
|
// freetheai: { id: "freetheai", alias: "fta", name: "FreeTheAi", icon: "lock_open", color: "#10B981", textIcon: "FT", website: "https://freetheai.xyz", notice: { text: "Community-run free tier — 16,000+ models, OpenAI-compatible.", apiKeyUrl: "https://freetheai.xyz" }, serviceKinds: ["llm"] },
|
||||||
|
// llm7: { id: "llm7", alias: "llm7", name: "LLM7.io", icon: "hub", color: "#6366F1", textIcon: "LM", website: "https://llm7.io", notice: { text: "Works without API key (use 'unused'). 2 req/s, 100 req/hr free.", apiKeyUrl: "https://token.llm7.io" }, serviceKinds: ["llm"] },
|
||||||
|
// lepton: { id: "lepton", alias: "lepton", name: "Lepton AI", icon: "bolt", color: "#10B981", textIcon: "LP", website: "https://lepton.ai", notice: { apiKeyUrl: "https://dashboard.lepton.ai/credentials" }, serviceKinds: ["llm"] },
|
||||||
|
// kluster: { id: "kluster", alias: "kluster", name: "Kluster AI", icon: "hub", color: "#8B5CF6", textIcon: "KL", website: "https://kluster.ai", notice: { text: "$5 free credits on signup — DeepSeek R1, Llama 4, Qwen3 235B.", apiKeyUrl: "https://kluster.ai/dashboard/api-keys" }, serviceKinds: ["llm"] },
|
||||||
|
// ai21: { id: "ai21", alias: "ai21", name: "AI21 Labs", icon: "psychology_alt", color: "#0284C7", textIcon: "AI21", website: "https://www.ai21.com", notice: { text: "$10 trial credits on signup (valid 3 months).", apiKeyUrl: "https://studio.ai21.com/account/api-key" }, serviceKinds: ["llm"] },
|
||||||
|
// "inference-net": { id: "inference-net", alias: "inet", name: "Inference.net", icon: "dns", color: "#2563EB", textIcon: "IN", website: "https://inference.net", notice: { text: "$25 free credits on signup.", apiKeyUrl: "https://inference.net/dashboard/api-keys" }, serviceKinds: ["llm"] },
|
||||||
|
// predibase: { id: "predibase", alias: "predibase", name: "Predibase", icon: "deployed_code_history", color: "#0F766E", textIcon: "PB", website: "https://predibase.com", notice: { text: "$25 free trial credits (30-day validity).", apiKeyUrl: "https://app.predibase.com/settings" }, serviceKinds: ["llm"] },
|
||||||
|
// bytez: { id: "bytez", alias: "bytez", name: "Bytez", icon: "api", color: "#6366F1", textIcon: "BZ", website: "https://bytez.com", notice: { text: "$1 free credits, refreshes every 4 weeks.", apiKeyUrl: "https://bytez.com/dashboard/api" }, serviceKinds: ["llm"] },
|
||||||
|
// morph: { id: "morph", alias: "morph", name: "Morph", icon: "auto_fix_high", color: "#2563EB", textIcon: "MP", website: "https://morphllm.com", notice: { text: "Free tier: 250K credits/month.", apiKeyUrl: "https://morphllm.com/dashboard/api-keys" }, serviceKinds: ["llm"] },
|
||||||
|
// longcat: { id: "longcat", alias: "lc", name: "LongCat AI", icon: "auto_awesome", color: "#FF6B9D", textIcon: "LC", website: "https://longcat.chat/platform/docs", notice: { text: "50M tokens/day (Flash-Lite) + 500K/day (Chat/Thinking) — free in public beta.", apiKeyUrl: "https://longcat.chat/platform/api_keys" }, serviceKinds: ["llm"] },
|
||||||
|
// puter: { id: "puter", alias: "pu", name: "Puter AI", icon: "cloud_circle", color: "#6366F1", textIcon: "PU", website: "https://puter.com", notice: { text: "500+ models (GPT-5, Claude Opus 4, Gemini 3 Pro, Grok 4, DeepSeek V3).", apiKeyUrl: "https://puter.com/dashboard" }, passthroughModels: true, serviceKinds: ["llm"] },
|
||||||
|
// uncloseai: { id: "uncloseai", alias: "unc", name: "UncloseAI", icon: "auto_awesome", color: "#8B5CF6", textIcon: "UN", website: "https://uncloseai.com", notice: { text: "Free forever — no signup, no credit card. OpenAI-compatible." }, passthroughModels: true, noAuth: true, serviceKinds: ["llm"] },
|
||||||
|
// scaleway: { id: "scaleway", alias: "scw", name: "Scaleway AI", icon: "cloud", color: "#4F0599", textIcon: "SCW", website: "https://www.scaleway.com/en/ai/generative-apis", notice: { text: "1M free tokens — EU/GDPR compliant (Paris), Qwen3 235B & Llama 70B.", apiKeyUrl: "https://console.scaleway.com/iam/api-keys" }, serviceKinds: ["llm"] },
|
||||||
|
// deepinfra: { id: "deepinfra", alias: "deepinfra", name: "DeepInfra", icon: "hub", color: "#2563EB", textIcon: "DI", website: "https://deepinfra.com", notice: { text: "Free signup credits for API testing.", apiKeyUrl: "https://deepinfra.com/dash/api_keys" }, serviceKinds: ["llm"] },
|
||||||
|
// sambanova: { id: "sambanova", alias: "samba", name: "SambaNova", icon: "memory", color: "#DC2626", textIcon: "SN", website: "https://sambanova.ai", notice: { text: "$5 free credits on signup (30-day validity).", apiKeyUrl: "https://cloud.sambanova.ai/apis" }, serviceKinds: ["llm"] },
|
||||||
|
// nscale: { id: "nscale", alias: "nscale", name: "nScale", icon: "token", color: "#0891B2", textIcon: "NS", website: "https://nscale.com", notice: { text: "$5 free credits on signup.", apiKeyUrl: "https://console.nscale.com/api-keys" }, serviceKinds: ["llm"] },
|
||||||
|
// baseten: { id: "baseten", alias: "baseten", name: "Baseten", icon: "deployed_code", color: "#111827", textIcon: "BT", website: "https://baseten.co", notice: { text: "$30 free trial credits for GPU inference.", apiKeyUrl: "https://app.baseten.co/settings/api_keys" }, serviceKinds: ["llm"] },
|
||||||
|
// publicai: { id: "publicai", alias: "publicai", name: "PublicAI", icon: "public", color: "#059669", textIcon: "PA", website: "https://publicai.co", notice: { text: "Free community inference tier.", apiKeyUrl: "https://publicai.co" }, serviceKinds: ["llm"] },
|
||||||
|
// "nous-research": { id: "nous-research", alias: "nous", name: "Nous Research", icon: "hub", color: "#2563EB", textIcon: "NO", website: "https://portal.nousresearch.com", notice: { text: "Free tier: 50 RPM, 500K TPM — no credit card.", apiKeyUrl: "https://portal.nousresearch.com" }, serviceKinds: ["llm"] },
|
||||||
|
// glhf: { id: "glhf", alias: "glhf", name: "GLHF Chat", icon: "hub", color: "#10B981", textIcon: "GH", website: "https://glhf.chat", notice: { text: "Free tier for open-source model inference.", apiKeyUrl: "https://glhf.chat/users/settings/api" }, passthroughModels: true, serviceKinds: ["llm"] },
|
||||||
"ollama-local": { id: "ollama-local", alias: "ollama-local", name: "Ollama Local", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com" },
|
"ollama-local": { id: "ollama-local", alias: "ollama-local", name: "Ollama Local", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com" },
|
||||||
"vertex-partner": { id: "vertex-partner", alias: "vxp", name: "Vertex Partner", icon: "cloud", color: "#34A853", textIcon: "VP", website: "https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-partner-models", notice: { apiKeyUrl: "https://console.cloud.google.com/iam-admin/serviceaccounts" } },
|
"vertex-partner": { id: "vertex-partner", alias: "vxp", name: "Vertex Partner", icon: "cloud", color: "#34A853", textIcon: "VP", website: "https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-partner-models", notice: { apiKeyUrl: "https://console.cloud.google.com/iam-admin/serviceaccounts" } },
|
||||||
tavily: { id: "tavily", alias: "tavily", name: "Tavily", icon: "search", color: "#5B21B6", textIcon: "TV", website: "https://tavily.com", notice: { apiKeyUrl: "https://app.tavily.com/home" }, serviceKinds: ["webSearch", "webFetch"], searchConfig: { baseUrl: "https://api.tavily.com/search", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.008, freeMonthlyQuota: 1000, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 20, timeoutMs: 10000, cacheTTLMs: 300000 }, fetchConfig: { baseUrl: "https://api.tavily.com/extract", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.008, freeMonthlyQuota: 1000, formats: ["markdown", "text"], maxCharacters: 100000, timeoutMs: 15000 } },
|
tavily: { id: "tavily", alias: "tavily", name: "Tavily", icon: "search", color: "#5B21B6", textIcon: "TV", website: "https://tavily.com", notice: { apiKeyUrl: "https://app.tavily.com/home" }, serviceKinds: ["webSearch", "webFetch"], searchConfig: { baseUrl: "https://api.tavily.com/search", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.008, freeMonthlyQuota: 1000, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 20, timeoutMs: 10000, cacheTTLMs: 300000 }, fetchConfig: { baseUrl: "https://api.tavily.com/extract", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.008, freeMonthlyQuota: 1000, formats: ["markdown", "text"], maxCharacters: 100000, timeoutMs: 15000 } },
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ const g = global.__appSingleton ??= {
|
||||||
networkMonitorInterval: null,
|
networkMonitorInterval: null,
|
||||||
lastNetworkFingerprint: null,
|
lastNetworkFingerprint: null,
|
||||||
lastWatchdogTick: Date.now(),
|
lastWatchdogTick: Date.now(),
|
||||||
|
lastOnline: null,
|
||||||
mitmStartInProgress: false,
|
mitmStartInProgress: false,
|
||||||
tunnelAutoResumed: false,
|
tunnelAutoResumed: false,
|
||||||
tailscaleAutoResumed: false,
|
tailscaleAutoResumed: false,
|
||||||
|
|
@ -209,6 +210,7 @@ function startNetworkMonitor() {
|
||||||
|
|
||||||
g.lastNetworkFingerprint = getNetworkFingerprint();
|
g.lastNetworkFingerprint = getNetworkFingerprint();
|
||||||
g.lastWatchdogTick = Date.now();
|
g.lastWatchdogTick = Date.now();
|
||||||
|
g.lastOnline = null;
|
||||||
|
|
||||||
g.networkMonitorInterval = setInterval(async () => {
|
g.networkMonitorInterval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -218,15 +220,24 @@ function startNetworkMonitor() {
|
||||||
|
|
||||||
const currentFingerprint = getNetworkFingerprint();
|
const currentFingerprint = getNetworkFingerprint();
|
||||||
const networkChanged = currentFingerprint !== g.lastNetworkFingerprint;
|
const networkChanged = currentFingerprint !== g.lastNetworkFingerprint;
|
||||||
const wasSleep = elapsed > NETWORK_CHECK_INTERVAL_MS * 3;
|
const wasSleep = elapsed > NETWORK_CHECK_INTERVAL_MS * 6;
|
||||||
|
|
||||||
if (networkChanged) g.lastNetworkFingerprint = currentFingerprint;
|
if (networkChanged) g.lastNetworkFingerprint = currentFingerprint;
|
||||||
if (!networkChanged && !wasSleep) return;
|
|
||||||
|
// Real reachability check (TCP 1.1.1.1:443) — not just interface presence
|
||||||
|
const online = await checkInternet();
|
||||||
|
const wasOffline = g.lastOnline === false;
|
||||||
|
g.lastOnline = online;
|
||||||
|
|
||||||
|
if (!online) return; // no internet → idle, don't restart
|
||||||
|
|
||||||
|
const onlineEdge = wasOffline; // offline → online transition
|
||||||
|
if (!networkChanged && !wasSleep && !onlineEdge) return;
|
||||||
|
|
||||||
// Wait for DHCP/DNS to settle before probing
|
// Wait for DHCP/DNS to settle before probing
|
||||||
await new Promise((r) => setTimeout(r, NETWORK_SETTLE_MS));
|
await new Promise((r) => setTimeout(r, NETWORK_SETTLE_MS));
|
||||||
|
|
||||||
const reason = wasSleep && networkChanged ? "sleep+netchange"
|
const reason = onlineEdge ? "online"
|
||||||
|
: wasSleep && networkChanged ? "sleep+netchange"
|
||||||
: wasSleep ? "sleep" : "netchange";
|
: wasSleep ? "sleep" : "netchange";
|
||||||
safeRestartTunnel(reason).catch(() => {});
|
safeRestartTunnel(reason).catch(() => {});
|
||||||
safeRestartTailscale(reason).catch(() => {});
|
safeRestartTailscale(reason).catch(() => {});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue