From 8f4d29caa4e36a1fa906e20ba7ebf380e021e6a3 Mon Sep 17 00:00:00 2001 From: decolua Date: Tue, 12 May 2026 09:19:50 +0700 Subject: [PATCH] =?UTF-8?q?#=20v0.4.30=20(2026-05-11)=20##=20Features=20-?= =?UTF-8?q?=20MCP=20stdio=E2=86=92SSE=20bridge:=20expose=20local=20stdio?= =?UTF-8?q?=20MCP=20plugins=20over=20SSE=20(api/mcp/[plugin]/sse,=20/messa?= =?UTF-8?q?ge)=20-=20Dynamic=20Linux=20cert=20resolution=20+=20NSS=20DB=20?= =?UTF-8?q?injection=20(Debian/Arch/Fedora/openSUSE,=20Chrome/Chromium/Fir?= =?UTF-8?q?efox=20incl.=20snap)=20(#1010)=20-=20Cowork=20tool:=20expanded?= =?UTF-8?q?=20settings=20UI=20&=20API=20-=20GitBook=20docs=20(DocsContent,?= =?UTF-8?q?=20DocsLayout)=20##=20Fixes=20-=20OAuth=20callback=20postMessag?= =?UTF-8?q?e=20scoped=20to=20expected=20origins=20(CWE-1385)=20(#998)=20-?= =?UTF-8?q?=20Re-enable=20TLS=20verification=20on=20DNS-bypass=20fetch=20(?= =?UTF-8?q?CWE-295)=20(#998)=20-=20Normalize=20`developer`=20role=20?= =?UTF-8?q?=E2=86=92=20`system`=20for=20OpenAI-format=20providers=20(Deeps?= =?UTF-8?q?eek,=20Groq,=20=E2=80=A6)=20(#1011,=20closes=20#773)=20-=20Resp?= =?UTF-8?q?ect=20`PORT`=20env=20in=20internal=20model-test=20fetch=20(#101?= =?UTF-8?q?4)=20-=20Dropdown=20text=20readability=20in=20dark=20theme=20on?= =?UTF-8?q?=20usage=20page=20(#997)=20##=20Improvements=20-=20Refactor=20C?= =?UTF-8?q?laude=20CLI=20spoof=20headers=20into=20shared=20constant=20-=20?= =?UTF-8?q?Tool=20deduper=20utility=20in=20open-sse=20handlers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 19 ++ gitbook/components/DocsContent.js | 4 +- gitbook/components/DocsLayout.js | 8 +- next.config.mjs | 7 +- open-sse/config/providerModels.js | 146 ++++++++++ open-sse/config/providers.js | 68 ++++- open-sse/executors/default.js | 10 +- open-sse/handlers/chatCore.js | 10 + open-sse/services/model.js | 44 +++ open-sse/utils/toolDeduper.js | 49 ++++ package.json | 2 +- .../cli-tools/components/CoworkToolCard.js | 273 ++++++++++++++++-- .../(dashboard)/dashboard/providers/page.js | 88 ++++-- .../api/cli-tools/cowork-settings/route.js | 137 ++++++++- src/app/api/mcp/[plugin]/message/route.js | 19 ++ src/app/api/mcp/[plugin]/sse/route.js | 35 +++ src/app/api/providers/validate/route.js | 85 ++++-- src/lib/mcp/stdioSseBridge.js | 190 ++++++++++++ src/lib/tunnel/tunnelConfig.js | 2 +- src/shared/constants/cliTools.js | 80 +++++ src/shared/constants/coworkPlugins.js | 27 +- src/shared/constants/providers.js | 31 ++ src/shared/services/initializeApp.js | 19 +- 23 files changed, 1198 insertions(+), 155 deletions(-) create mode 100644 open-sse/utils/toolDeduper.js create mode 100644 src/app/api/mcp/[plugin]/message/route.js create mode 100644 src/app/api/mcp/[plugin]/sse/route.js create mode 100644 src/lib/mcp/stdioSseBridge.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 51ca928..f392f31 100644 --- a/CHANGELOG.md +++ b/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) ## Features diff --git a/gitbook/components/DocsContent.js b/gitbook/components/DocsContent.js index fccc817..1924593 100644 --- a/gitbook/components/DocsContent.js +++ b/gitbook/components/DocsContent.js @@ -4,8 +4,8 @@ import { MarkdownRenderer } from "@/utils/markdown"; export default function DocsContent({ content }) { return ( -
-
+
+
diff --git a/gitbook/components/DocsLayout.js b/gitbook/components/DocsLayout.js index 128991f..0cf74b4 100644 --- a/gitbook/components/DocsLayout.js +++ b/gitbook/components/DocsLayout.js @@ -10,14 +10,14 @@ export default function DocsLayout({ children, headings = [], lang = DEFAULT_LAN
- {/* Desktop sidebar */}
- -
+
{children} - +
+ +
diff --git a/next.config.mjs b/next.config.mjs index de5874f..daa4e17 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,7 +1,8 @@ import { fileURLToPath } from "node:url"; -import { dirname } from "node:path"; +import { dirname, resolve } from "node:path"; const projectRoot = dirname(fileURLToPath(import.meta.url)); +const monorepoRoot = resolve(projectRoot, ".."); /** @type {import('next').NextConfig} */ const nextConfig = { @@ -10,9 +11,9 @@ const nextConfig = { turbopack: { root: projectRoot }, - outputFileTracingRoot: projectRoot, + outputFileTracingRoot: monorepoRoot, outputFileTracingExcludes: { - "*": ["./gitbook/**/*"] + "*": ["./app/gitbook/**/*", "./gitbook/**/*"] }, images: { unoptimized: true diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index 14c5a92..b98edb5 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -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-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: [ { id: "nova-3", name: "Nova 3", type: "stt", params: ["language"] }, { id: "nova-2", name: "Nova 2", type: "stt", params: ["language"] }, diff --git a/open-sse/config/providers.js b/open-sse/config/providers.js index 4c844c5..0997be7 100644 --- a/open-sse/config/providers.js +++ b/open-sse/config/providers.js @@ -26,6 +26,24 @@ const CLAUDE_API_HEADERS = { "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" }; +// Full Claude CLI fingerprint — required by providers that gate on client identity (e.g. agentrouter) +const CLAUDE_CLI_SPOOF_HEADERS = { + "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-Dangerous-Direct-Browser-Access": "true", + "User-Agent": "claude-cli/2.1.92 (external, sdk-cli)", + "X-App": "cli", + "X-Stainless-Helper-Method": "stream", + "X-Stainless-Retry-Count": "0", + "X-Stainless-Runtime-Version": "v24.14.0", + "X-Stainless-Package-Version": "0.80.0", + "X-Stainless-Runtime": "node", + "X-Stainless-Lang": "js", + "X-Stainless-Arch": mapStainlessArch(), + "X-Stainless-Os": mapStainlessOs(), + "X-Stainless-Timeout": "600" +}; + // Shared baseUrls const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/v1/messages"; @@ -33,22 +51,7 @@ export const PROVIDERS = { claude: { baseUrl: "https://api.anthropic.com/v1/messages", format: "claude", - headers: { - "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-Dangerous-Direct-Browser-Access": "true", - "User-Agent": "claude-cli/2.1.92 (external, sdk-cli)", - "X-App": "cli", - "X-Stainless-Helper-Method": "stream", - "X-Stainless-Retry-Count": "0", - "X-Stainless-Runtime-Version": "v24.14.0", - "X-Stainless-Package-Version": "0.80.0", - "X-Stainless-Runtime": "node", - "X-Stainless-Lang": "js", - "X-Stainless-Arch": mapStainlessArch(), - "X-Stainless-Os": mapStainlessOs(), - "X-Stainless-Timeout": "600" - }, + headers: { ...CLAUDE_CLI_SPOOF_HEADERS }, clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", tokenUrl: "https://api.anthropic.com/v1/oauth/token" }, @@ -384,6 +387,39 @@ export const PROVIDERS = { baseUrl: "https://api.xiaomimimo.com/v1/chat/completions", 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"; diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js index 5e29ef7..47bc9b8 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.js @@ -96,11 +96,9 @@ export class DefaultExecutor extends BaseExecutor { case "kimi": case "minimax": case "minimax-cn": - headers["x-api-key"] = credentials.apiKey || credentials.accessToken; - break; case "kimi-coding": - headers["Authorization"] = `Bearer ${credentials.accessToken}`; - Object.assign(headers, buildKimiHeaders()); + headers["x-api-key"] = credentials.apiKey || credentials.accessToken; + if (this.provider === "kimi-coding") Object.assign(headers, buildKimiHeaders()); break; default: if (this.provider?.startsWith?.("anthropic-compatible-")) { @@ -124,6 +122,10 @@ export class DefaultExecutor extends BaseExecutor { } } else if (this.provider === "cline") { 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 { headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`; } diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index ca59cb9..d706e24 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -16,6 +16,7 @@ import { handleForcedSSEToJson } from "./chatCore/sseToJsonHandler.js"; import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js"; import { handleStreamingResponse, buildOnStreamComplete } from "./chatCore/streamingHandler.js"; import { detectClientTool, isNativePassthrough } from "../utils/clientDetector.js"; +import { dedupeTools } from "../utils/toolDeduper.js"; import { injectCaveman } from "../rtk/caveman.js"; import { compressMessages, formatRtkLog } from "../rtk/index.js"; @@ -94,6 +95,15 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred 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 // Covers both passthrough (source shape) and translated (target shape) flows const finalFormat = passthrough ? sourceFormat : targetFormat; diff --git a/open-sse/services/model.js b/open-sse/services/model.js index 234ed56..c791b5e 100644 --- a/open-sse/services/model.js +++ b/open-sse/services/model.js @@ -86,6 +86,50 @@ const ALIAS_TO_PROVIDER_ID = { // TTS 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", }; /** diff --git a/open-sse/utils/toolDeduper.js b/open-sse/utils/toolDeduper.js new file mode 100644 index 0000000..20c4740 --- /dev/null +++ b/open-sse/utils/toolDeduper.js @@ -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 }; diff --git a/package.json b/package.json index 70f46c8..96bff70 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.4.29", + "version": "0.4.30", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js index db4e251..3e6eb44 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js @@ -41,8 +41,12 @@ export default function CoworkToolCard({ const [showManualConfigModal, setShowManualConfigModal] = useState(false); const [customBaseUrl, setCustomBaseUrl] = useState(""); const [plugins, setPlugins] = useState([]); + const [localPlugins, setLocalPlugins] = useState([]); + const [customPlugins, setCustomPlugins] = useState([]); const [comboModalOpen, setComboModalOpen] = useState(false); const [marketplaceOpen, setMarketplaceOpen] = useState(false); + const [addMcpOpen, setAddMcpOpen] = useState(false); + const [addMcpForm, setAddMcpForm] = useState({ type: "url", name: "", url: "", command: "", args: "" }); useEffect(() => { if (apiKeys?.length > 0 && !selectedApiKey) { @@ -71,6 +75,12 @@ export default function CoworkToolCard({ } else if (plugins.length === 0 && Array.isArray(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]); const checkStatus = async () => { @@ -120,6 +130,8 @@ export default function CoworkToolCard({ apiKey: keyToUse, models: selectedModels, plugins, + localPlugins, + customPlugins, }), }); const data = await res.json(); @@ -168,6 +180,8 @@ export default function CoworkToolCard({ setMessage({ type: "success", text: "Settings reset successfully" }); setSelectedModels([]); setPlugins(status?.defaultPlugins || []); + setLocalPlugins([]); + setCustomPlugins([]); checkStatus(); } else { setMessage({ type: "error", text: data.error || "Failed to reset" }); @@ -288,11 +302,11 @@ export default function CoworkToolCard({
-
- Models - arrow_forward -
-
+
+ Models + arrow_forward +
+
{selectedModels.length === 0 ? ( No models selected ) : ( @@ -306,37 +320,145 @@ export default function CoworkToolCard({ )) )}
- +
- Plugins - arrow_forward -
-
- {plugins.filter((p) => p.name !== "exa").length === 0 ? ( - No plugins - ) : ( - plugins.filter((p) => p.name !== "exa").map((p) => ( - - {p.title || p.name} - {p.oauth && OAuth} - - - )) - )} + MCP + arrow_forward +
+ {/* Preset plugins */} + {plugins.filter((p) => p.name !== "exa").map((p) => ( +
+ {p.title || p.name} + {p.oauth && OAuth} +
+ {Array.isArray(p.toolNames) && p.toolNames.slice(0, 6).map((t) => ( + {t} + ))} + {Array.isArray(p.toolNames) && p.toolNames.length > 6 && ( + +{p.toolNames.length - 6} + )} +
+ +
+ ))} + {/* Custom plugins */} + {customPlugins.map((p) => ( +
+ {p.name} + custom + {p.url || p.command} + +
+ ))} + {plugins.filter((p) => p.name !== "exa").length === 0 && customPlugins.length === 0 && ( +
No MCPs added
+ )} + {/* Actions row */} +
+ + + Find MCPs →
- -

- 💡 Exa is auto-installed. Prefer web_search_exa for web search and web_fetch_exa for reading pages. -

+ +
+ Tools + arrow_forward +
+ {(() => { + const exaEnabled = plugins.some((p) => p.name === "exa"); + const exaDef = (status?.defaultPlugins || []).find((d) => d.name === "exa"); + return ( + + ); + })()} + {(() => { + const browserDef = (status?.localStdioPlugins || []).find((p) => p.name === "browsermcp"); + if (!browserDef) return null; + const browserEnabled = localPlugins.includes("browsermcp"); + return ( + + ); + })()} +
+
+ + {Array.isArray(status?.localStdioPlugins) && status.localStdioPlugins.filter((p) => p.name !== "browsermcp").length > 0 && ( +
+ Local Plugins + arrow_forward +
+
+ {status.localStdioPlugins.filter((p) => p.name !== "browsermcp").map((p) => { + const enabled = localPlugins.includes(p.name); + return ( + + ); + })} +
+

+ ⚠️ Local plugins run as subprocess via npx. Requires Node.js installed. +

+
+
+ )}
{message && ( @@ -385,6 +507,99 @@ export default function CoworkToolCard({ onAdd={addPlugin} addedNames={plugins.map((p) => p.name)} /> + + {/* Add Custom MCP modal */} + {addMcpOpen && ( +
setAddMcpOpen(false)}> +
e.stopPropagation()}> +
+

Add Custom MCP

+ +
+ +
+ + +
+ +
+
+ + 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" + /> +
+ {addMcpForm.type === "url" ? ( +
+ + 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" + /> +
+ ) : ( + <> +
+ + 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" + /> +
+
+ + 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" + /> +
+ + )} +
+ +
+ + +
+
+
+ )} ); } diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index 4f77a8b..24eb93b 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -120,12 +120,25 @@ export default function ProvidersPage() { !searchQuery.trim() || name.toLowerCase().includes(searchQuery.trim().toLowerCase()); - const sortByConnections = (entries, authType) => - [...entries].sort( - (a, b) => - getProviderStats(b[0], authType).total - - getProviderStats(a[0], authType).total, - ); + const sortByPriority = (entries, authType) => + [...entries].sort(([ka, a], [kb, b]) => { + const sa = getProviderStats(ka, authType); + const sb = getProviderStats(kb, 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 || ""); + }); + + 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(() => { const fetchData = async () => { @@ -239,37 +252,48 @@ export default function ProvidersPage() { } }; - const compatibleProviders = providerNodes - .filter((node) => node.type === "openai-compatible") - .map((node) => ({ - id: node.id, - name: node.name || "OpenAI Compatible", - color: "#10A37F", - textIcon: "OC", - apiType: node.apiType, - })) - .filter((p) => matchSearch(p.name)); + const compatibleProviders = sortItemsByPriority( + providerNodes + .filter((node) => node.type === "openai-compatible") + .map((node) => ({ + id: node.id, + name: node.name || "OpenAI Compatible", + color: "#10A37F", + textIcon: "OC", + apiType: node.apiType, + })) + .filter((p) => matchSearch(p.name)), + "apikey", + ); - const anthropicCompatibleProviders = providerNodes - .filter((node) => node.type === "anthropic-compatible") - .map((node) => ({ - id: node.id, - name: node.name || "Anthropic Compatible", - color: "#D97757", - textIcon: "AC", - })) - .filter((p) => matchSearch(p.name)); + const anthropicCompatibleProviders = sortItemsByPriority( + providerNodes + .filter((node) => node.type === "anthropic-compatible") + .map((node) => ({ + id: node.id, + name: node.name || "Anthropic Compatible", + color: "#D97757", + textIcon: "AC", + })) + .filter((p) => matchSearch(p.name)), + "apikey", + ); - const oauthEntries = Object.entries(OAUTH_PROVIDERS).filter(([, info]) => - matchSearch(info.name), + const oauthEntries = sortByPriority( + Object.entries(OAUTH_PROVIDERS).filter(([, info]) => matchSearch(info.name)), + "oauth", ); - const freeEntries = Object.entries(FREE_PROVIDERS).filter(([, info]) => - matchSearch(info.name), + const freeEntries = sortByPriority( + Object.entries(FREE_PROVIDERS).filter(([, info]) => matchSearch(info.name)), + "oauth", ); - const freeTierEntries = Object.entries(FREE_TIER_PROVIDERS).filter( - ([, info]) => matchSearch(info.name), + const freeTierEntries = sortByPriority( + Object.entries(FREE_TIER_PROVIDERS).filter(([, info]) => + matchSearch(info.name), + ), + "apikey", ); - const apikeyEntries = sortByConnections( + const apikeyEntries = sortByPriority( Object.entries(APIKEY_PROVIDERS).filter( ([, info]) => (info.serviceKinds ?? ["llm"]).includes("llm") && matchSearch(info.name), diff --git a/src/app/api/cli-tools/cowork-settings/route.js b/src/app/api/cli-tools/cowork-settings/route.js index 68313c3..2ed1fe7 100644 --- a/src/app/api/cli-tools/cowork-settings/route.js +++ b/src/app/api/cli-tools/cowork-settings/route.js @@ -5,7 +5,11 @@ import fs from "fs/promises"; import path from "path"; import os from "os"; 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"; @@ -89,21 +93,87 @@ const get1pRoot = () => { return path.join(os.homedir(), ".config", "Claude"); }; -const bootstrapDeploymentMode = async () => { - const cfgPath = path.join(get1pRoot(), "claude_desktop_config.json"); - let cfg = {}; - try { - cfg = JSON.parse(await fs.readFile(cfgPath, "utf-8")); - } catch (error) { - if (error.code !== "ENOENT") throw error; +const get1pConfigPath = () => path.join(get1pRoot(), "claude_desktop_config.json"); + +const read1pConfig = async () => { + try { return JSON.parse(await fs.readFile(get1pConfigPath(), "utf-8")) || {}; } + catch (error) { + if (error.code === "ENOENT") return {}; + 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; cfg.deploymentMode = "3p"; - await fs.mkdir(get1pRoot(), { recursive: true }); - await fs.writeFile(cfgPath, JSON.stringify(cfg, null, 2)); + await write1pConfig(cfg); 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 () => { for (const dir of [...getCandidateRoots(), ...getAppInstallPaths()]) { 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 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({ installed: true, config, @@ -181,7 +262,7 @@ export async function GET() { baseUrl, models, 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. const keys = m.toolPolicy ? Object.keys(m.toolPolicy) : []; const prefix = `${m.name}-`; @@ -196,8 +277,11 @@ export async function GET() { 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 }; }), + localPlugins: activeLocalNames, + customPlugins: activeCustomPlugins, }, defaultPlugins: DEFAULT_PLUGINS, + localStdioPlugins: LOCAL_STDIO_PLUGINS, }); } catch (error) { console.log("Error reading cowork settings:", error); @@ -207,7 +291,7 @@ export async function GET() { export async function POST(request) { try { - const { baseUrl, apiKey, models, plugins } = await request.json(); + const { baseUrl, apiKey, models, plugins, localPlugins, customPlugins } = await request.json(); if (!baseUrl || !apiKey) { 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 }); } - // Plugins: array of {name, url, transport?, oauth?}. Default to DEFAULT_PLUGINS if absent. - const pluginsArray = Array.isArray(plugins) && plugins.length > 0 ? plugins : DEFAULT_PLUGINS; - const managedMcpServers = buildManagedMcpServers(pluginsArray); + // Respect empty array (user toggled all off); fallback to defaults only when undefined. + const pluginsArray = Array.isArray(plugins) ? plugins : DEFAULT_PLUGINS; + 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 meta = await ensureMeta(); @@ -239,6 +340,10 @@ export async function POST(request) { let skipResult = null; 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({ success: true, bootstrapped, @@ -247,6 +352,7 @@ export async function POST(request) { : "Cowork settings applied. Quit & reopen Claude Desktop.", configPath, skipApprovals: skipResult, + localMcp: localMcpResult, }); } catch (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)); } catch (error) { if (error.code !== "ENOENT") throw error; } try { await writeSkipApprovals([]); } catch { /* ignore */ } + try { await cleanup1pLegacy(); } catch { /* ignore */ } return NextResponse.json({ success: true, message: "Cowork config reset" }); } catch (error) { console.log("Error resetting cowork settings:", error); diff --git a/src/app/api/mcp/[plugin]/message/route.js b/src/app/api/mcp/[plugin]/message/route.js new file mode 100644 index 0000000..86df051 --- /dev/null +++ b/src/app/api/mcp/[plugin]/message/route.js @@ -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 }); + } +} diff --git a/src/app/api/mcp/[plugin]/sse/route.js b/src/app/api/mcp/[plugin]/sse/route.js new file mode 100644 index 0000000..8f9d67d --- /dev/null +++ b/src/app/api/mcp/[plugin]/sse/route.js @@ -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", + }, + }); +} diff --git a/src/app/api/providers/validate/route.js b/src/app/api/providers/validate/route.js index 7c5f184..5b396c1 100644 --- a/src/app/api/providers/validate/route.js +++ b/src/app/api/providers/validate/route.js @@ -286,48 +286,34 @@ export async function POST(request) { case "minimax": case "minimax-cn": case "alicode-intl": - case "alicode": { - const claudeBaseUrls = { - glm: "https://api.z.ai/api/anthropic/v1/messages", - "glm-cn": "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions", - kimi: "https://api.kimi.com/coding/v1/messages", - 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", - }; + case "alicode": + case "agentrouter": { + // Use baseUrl from PROVIDERS (DRY); separate openai-format vs claude-format flow + const cfg = PROVIDERS[provider]; + const isOpenAiFormat = provider === "glm-cn" || provider === "alicode" || provider === "alicode-intl"; - // glm-cn, alicode and alicode-intl use OpenAI format - if (provider === "glm-cn" || provider === "alicode" || provider === "alicode-intl") { + if (isOpenAiFormat) { const testModel = getDefaultModel(provider); - const glmCnRes = await fetch(claudeBaseUrls[provider], { + const res = await fetch(cfg.baseUrl, { method: "POST", - headers: { - "Authorization": `Bearer ${apiKey}`, - "content-type": "application/json", - }, - body: JSON.stringify({ - model: testModel, - max_tokens: 1, - messages: [{ role: "user", content: "test" }], - }), + headers: { "Authorization": `Bearer ${apiKey}`, "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 { - const claudeRes = await fetch(claudeBaseUrls[provider], { + const testModel = getDefaultModel(provider) || "claude-sonnet-4-20250514"; + const res = await fetch(cfg.baseUrl, { method: "POST", headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json", + ...(cfg.headers || {}), }, - body: JSON.stringify({ - model: "claude-sonnet-4-20250514", - max_tokens: 1, - messages: [{ role: "user", content: "test" }], - }), + body: JSON.stringify({ model: testModel, 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; } @@ -588,8 +574,43 @@ export async function POST(request) { break; } - default: - return NextResponse.json({ error: "Provider validation not supported" }, { status: 400 }); + 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 }); + } + 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) { error = err.message; diff --git a/src/lib/mcp/stdioSseBridge.js b/src/lib/mcp/stdioSseBridge.js new file mode 100644 index 0000000..9554353 --- /dev/null +++ b/src/lib/mcp/stdioSseBridge.js @@ -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 }; diff --git a/src/lib/tunnel/tunnelConfig.js b/src/lib/tunnel/tunnelConfig.js index 2b8f0b8..0acbd22 100644 --- a/src/lib/tunnel/tunnelConfig.js +++ b/src/lib/tunnel/tunnelConfig.js @@ -12,7 +12,7 @@ export const INTERNET_CHECK = { timeoutMs: 3000, }; -export const RESTART_COOLDOWN_MS = 60000; +export const RESTART_COOLDOWN_MS = 180000; export const NETWORK_SETTLE_MS = 2500; export const WATCHDOG_INTERVAL_MS = 60000; export const NETWORK_CHECK_INTERVAL_MS = 5000; diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index 00939ab..5541390 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -211,6 +211,86 @@ export const CLI_TOOLS = { "model": "{{model}}", "provider": "openai", "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}}" + } }`, }, }, diff --git a/src/shared/constants/coworkPlugins.js b/src/shared/constants/coworkPlugins.js index 1e09389..b32f120 100644 --- a/src/shared/constants/coworkPlugins.js +++ b/src/shared/constants/coworkPlugins.js @@ -1,5 +1,4 @@ -// Default plugins auto-installed for Claude Cowork (3p mode). -// Exa works without auth; Tavily uses OAuth (DCR auto-flow). +// Default remote plugins for Claude Cowork (3p managedMcpServers, HTTPS only). const DEFAULT_PLUGINS = [ { name: "exa", @@ -21,20 +20,24 @@ const DEFAULT_PLUGINS = [ }, ]; -// Build managedMcpServers entries from plugin objects. -// Schema: [{name, url, transport, oauth?, toolPolicy?}] -// toolPolicy maps each tool to "allow" so Claude doesn't prompt. -// Plugin name that's force-installed regardless of user selection. -const ALWAYS_ON = "exa"; +// Local stdio plugins bridged via inline SSE endpoint on the app's port. +const LOCAL_STDIO_PLUGINS = [ + { + name: "browsermcp", + 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) { 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 seen = new Set(); - for (const p of merged) { + for (const p of list) { if (!p?.name || !p?.url || seen.has(p.name)) continue; seen.add(p.name); const entry = { @@ -66,4 +69,4 @@ function buildManagedMcpServers(plugins) { return out; } -module.exports = { DEFAULT_PLUGINS, buildManagedMcpServers, ALWAYS_ON }; +module.exports = { DEFAULT_PLUGINS, LOCAL_STDIO_PLUGINS, buildManagedMcpServers }; diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index 6706e8d..d20df3c 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -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)" }] } }, 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" } }, + // === 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" }, "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 } }, diff --git a/src/shared/services/initializeApp.js b/src/shared/services/initializeApp.js index 229501f..59d9551 100644 --- a/src/shared/services/initializeApp.js +++ b/src/shared/services/initializeApp.js @@ -41,6 +41,7 @@ const g = global.__appSingleton ??= { networkMonitorInterval: null, lastNetworkFingerprint: null, lastWatchdogTick: Date.now(), + lastOnline: null, mitmStartInProgress: false, tunnelAutoResumed: false, tailscaleAutoResumed: false, @@ -209,6 +210,7 @@ function startNetworkMonitor() { g.lastNetworkFingerprint = getNetworkFingerprint(); g.lastWatchdogTick = Date.now(); + g.lastOnline = null; g.networkMonitorInterval = setInterval(async () => { try { @@ -218,15 +220,24 @@ function startNetworkMonitor() { const currentFingerprint = getNetworkFingerprint(); 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 && !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 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"; safeRestartTunnel(reason).catch(() => {}); safeRestartTailscale(reason).catch(() => {});