diff --git a/.npmignore b/.npmignore new file mode 100644 index 00000000..a7afccc8 --- /dev/null +++ b/.npmignore @@ -0,0 +1,14 @@ +app/.next +app/.next/** +app/.next-cli-build/cache +app/.next-cli-build/cache/** +app/.next-cli-build/trace +app/.next-cli-build/trace-build +app/cli/.build-home +app/cli/.build-home/** +app/node_modules +app/node_modules/** +*.tgz +.DS_Store +.serena +.serena/** diff --git a/README.md b/README.md index 5611d4d9..3855fbfa 100644 --- a/README.md +++ b/README.md @@ -45,14 +45,14 @@ ## Quick Start ```bash -npm install -g xscope0-modifed-router +npm install -g xscope0 xscope0-router ``` Or run directly: ```bash -npx xscope0-modifed-router +npx xscope0 ``` | Endpoint | URL | @@ -129,6 +129,8 @@ npx xscope0-modifed-router - Caveman mode (ultra-compressed prompts) - Ponytail mode (minimal output) - Visible request logs for Caveman / Ponytail in dashboard +- Adaptive style injection option: choose Old or Adaptive placement in Token Saver. +- Terse, Caveman, and Ponytail style prompts are mutually exclusive in the shared injection path. **Account Pool** - Bulk import API keys (appends, never replaces) @@ -141,6 +143,7 @@ npx xscope0-modifed-router - Custom branding and provider icons - Pi provider endpoint: `http://localhost:20128/v1` - Donate / Remote UI removed +- CLI interactive menu removed; launcher starts the local server and tray directly. --- @@ -159,7 +162,7 @@ xScope0-Router/ │ ├── sqliteRuntime.js # SQLite native modules │ └── trayRuntime.js # System tray binary ├── scripts/ -│ └── build-cli.js # esbuild bundler +│ └── build-cli.mjs # Next.js CLI build packager ├── assets/ │ ├── logo.png │ └── preview/ diff --git a/app/.npmignore b/app/.npmignore new file mode 100644 index 00000000..6ea12cb5 --- /dev/null +++ b/app/.npmignore @@ -0,0 +1,10 @@ +.next +.next/** +node_modules +node_modules/** +cli/.build-home +cli/.build-home/** +.next-cli-build/cache +.next-cli-build/cache/** +.next-cli-build/trace +.next-cli-build/trace-build diff --git a/app/custom-server.js b/app/custom-server.js index 6e39683f..a0cb26e2 100644 --- a/app/custom-server.js +++ b/app/custom-server.js @@ -1,6 +1,90 @@ +const fs = require("fs"); const http = require("http"); +const path = require("path"); const origCreate = http.createServer.bind(http); +const staticDirs = [ + path.join(__dirname, ".next-cli-build", "static"), + path.join(__dirname, ".next", "static"), +]; + +const mimeTypes = { + ".js": "application/javascript; charset=utf-8", + ".mjs": "application/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".txt": "text/plain; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", +}; + +function sendStatic(req, res) { + if (req.method !== "GET" && req.method !== "HEAD") return false; + + let pathname; + try { + pathname = new URL(req.url, "http://localhost").pathname; + } catch { + return false; + } + + const prefix = "/_next/static/"; + if (!pathname.startsWith(prefix)) return false; + + let rel; + try { + rel = decodeURIComponent(pathname.slice(prefix.length)); + } catch { + res.statusCode = 400; + res.end("Bad Request"); + return true; + } + + if (!rel || rel.includes("\0") || path.isAbsolute(rel)) { + res.statusCode = 400; + res.end("Bad Request"); + return true; + } + + for (const dir of staticDirs) { + const file = path.resolve(dir, rel); + const root = path.resolve(dir); + if (file !== root && !file.startsWith(root + path.sep)) continue; + let stat; + try { + stat = fs.statSync(file); + } catch { + continue; + } + if (!stat.isFile()) continue; + + res.statusCode = 200; + res.setHeader("Content-Type", mimeTypes[path.extname(file).toLowerCase()] || "application/octet-stream"); + res.setHeader("Content-Length", String(stat.size)); + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + res.setHeader("Last-Modified", stat.mtime.toUTCString()); + if (req.method === "HEAD") { + res.end(); + return true; + } + fs.createReadStream(file).on("error", () => { + if (!res.headersSent) res.statusCode = 500; + res.end(); + }).pipe(res); + return true; + } + + return false; +} // Wrap Next standalone HTTP server: derive client IP from the TCP socket // (unspoofable) and strip client-supplied forwarding headers so downstream @@ -24,6 +108,7 @@ http.createServer = (...args) => { delete req.headers["x-9r-via-proxy"]; req.headers["x-9r-real-ip"] = ip; if (viaProxy) req.headers["x-9r-via-proxy"] = "1"; + if (sendStatic(req, res)) return; return handler(req, res); }; return origCreate(...rest, wrapped); diff --git a/app/open-sse/executors/github.js b/app/open-sse/executors/github.js index 2f4d68ba..3453d1c7 100644 --- a/app/open-sse/executors/github.js +++ b/app/open-sse/executors/github.js @@ -43,10 +43,13 @@ export class GithubExecutor extends BaseExecutor { // The endpoint only accepts 'text' and 'image_url' content part types. // Tool-related content (tool_use, tool_result, thinking) must be serialized as text. sanitizeMessagesForChatCompletions(body) { - if (!body?.messages) return body; + if (!Array.isArray(body?.messages)) return body; + + const sanitized = { + ...body, + messages: body.messages.map((message) => ({ ...message })), + }; - const sanitized = { ...body }; - // Handle response_format for Claude models via GitHub // GitHub's internal translation doesn't respect response_format, so we inject it as a system prompt // AND prepend a reminder to the last user message for maximum effectiveness @@ -60,23 +63,23 @@ export class GithubExecutor extends BaseExecutor { } if (systemInstruction) { // Add to system message - const systemIdx = body.messages.findIndex(m => m.role === 'system'); + const systemIdx = sanitized.messages.findIndex(m => m.role === 'system'); if (systemIdx >= 0) { - body.messages[systemIdx].content = systemInstruction + '\n\n' + body.messages[systemIdx].content; + sanitized.messages[systemIdx].content = systemInstruction + '\n\n' + sanitized.messages[systemIdx].content; } else { - body.messages.unshift({ role: 'system', content: systemInstruction }); + sanitized.messages.unshift({ role: 'system', content: systemInstruction }); } // Also prepend to the last user message as a reminder - const lastUserIdx = body.messages.map((m, i) => m.role === 'user' ? i : -1).filter(i => i >= 0).pop(); + const lastUserIdx = sanitized.messages.map((m, i) => m.role === 'user' ? i : -1).filter(i => i >= 0).pop(); if (lastUserIdx >= 0) { - const userMsg = body.messages[lastUserIdx]; + const userMsg = sanitized.messages[lastUserIdx]; const userContent = typeof userMsg.content === 'string' ? userMsg.content : JSON.stringify(userMsg.content); userMsg.content = 'Respond with ONLY raw JSON (no markdown, no backticks, no code blocks): ' + userContent; } } } - sanitized.messages = body.messages.map(msg => { + sanitized.messages = sanitized.messages.map(msg => { // assistant messages with only tool_calls have content: null — leave as-is if (!msg.content) return msg; diff --git a/app/open-sse/handlers/chatCore.js b/app/open-sse/handlers/chatCore.js index a3bddbe0..76625094 100644 --- a/app/open-sse/handlers/chatCore.js +++ b/app/open-sse/handlers/chatCore.js @@ -24,6 +24,7 @@ import { detectLoop } from "../utils/loopGuard.js"; import { injectCaveman } from "../rtk/caveman.js"; import { injectPonytail } from "../rtk/ponytail.js"; import { injectTerse } from "../rtk/terse.js"; +import { applyWebSearchSaver } from "../rtk/webSearchSaver.js"; import { injectTerminationPrompt, injectToolProtocolPrompt } from "../rtk/terminationPrompt.js"; import { compressMessages, formatRtkLog } from "../rtk/index.js"; import { compressWithHeadroom, formatHeadroomLog, formatHeadroomSizeLog, isHeadroomPhantomSavings } from "../rtk/headroom.js"; @@ -92,6 +93,26 @@ export function applyLoopGuard(translatedBody, finalFormat, provider, model, log return true; } +export function applyStylePromptInjection(body, format, settings, log) { + const styleInjectionOptions = { method: settings.styleInjectionMethod }; + if (settings.ponytailEnabled && settings.ponytailLevel) { + injectPonytail(body, format, settings.ponytailLevel, styleInjectionOptions); + log?.info?.("PONYTAIL", `active ${settings.ponytailLevel} | ${format}`); + return "ponytail"; + } + if (settings.cavemanEnabled && settings.cavemanLevel) { + injectCaveman(body, format, settings.cavemanLevel, styleInjectionOptions); + log?.info?.("CAVEMAN", `active ${settings.cavemanLevel} | ${format}`); + return "caveman"; + } + if (settings.terseEnabled && settings.terseLevel) { + injectTerse(body, format, settings.terseLevel, styleInjectionOptions); + log?.info?.("TERSE", `active ${settings.terseLevel} | ${format}`); + return "terse"; + } + return null; +} + /** * Core chat handler - shared between SSE and Worker * @param {object} options.body - Request body @@ -99,7 +120,7 @@ export function applyLoopGuard(translatedBody, finalFormat, provider, model, log * @param {object} options.credentials - Provider credentials * @param {string} options.sourceFormatOverride - Override detected source format (e.g. "openai-responses") */ -export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, terseEnabled, terseLevel, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, sourceFormatOverride, providerThinking }) { +export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, terseEnabled, terseLevel, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, styleInjectionMethod, webSearchSaverEnabled, sourceFormatOverride, providerThinking, externalSignal }) { const { provider, model, accountCount = 0 } = modelInfo; const requestStartTime = Date.now(); @@ -254,23 +275,21 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred } } else if (headroomEnabled) log?.warn?.("HEADROOM", `skipped: ${headroomDiagnostics.reason || "compression unavailable"}${headroomDiagnostics.endpoint ? ` (${headroomDiagnostics.endpoint})` : ""}`); - // Terse: inject concise-output system prompt - if (terseEnabled && terseLevel) { - injectTerse(translatedBody, finalFormat, terseLevel); - log?.info?.("TERSE", `active ${terseLevel} | ${finalFormat}`); + // Web Search Saver: inject search discipline and compact web tool results in the real outbound body. + const webSearchSaverStats = applyWebSearchSaver(translatedBody, finalFormat, webSearchSaverEnabled !== false); + if (webSearchSaverStats) { + log?.info?.("WEBSEARCHSAVER", `active | ${finalFormat} | hits=${webSearchSaverStats.hits}`); } - // Caveman: inject terse-style system prompt - if (cavemanEnabled && cavemanLevel) { - injectCaveman(translatedBody, finalFormat, cavemanLevel); - log?.info?.("CAVEMAN", `active ${cavemanLevel} | ${finalFormat}`); - } - - // Ponytail: inject lazy-senior-dev system prompt - if (ponytailEnabled && ponytailLevel) { - injectPonytail(translatedBody, finalFormat, ponytailLevel); - log?.info?.("PONYTAIL", `active ${ponytailLevel} | ${finalFormat}`); - } + applyStylePromptInjection(translatedBody, finalFormat, { + ponytailEnabled, + ponytailLevel, + cavemanEnabled, + cavemanLevel, + terseEnabled, + terseLevel, + styleInjectionMethod, + }, log); if (TOOL_PROTOCOL_PROMPT_PROVIDERS.has(provider)) { injectToolProtocolPrompt(translatedBody, finalFormat, extractToolNames(translatedBody.tools)); @@ -323,9 +342,18 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred log, provider, model }); + if (externalSignal) { + if (externalSignal.aborted) { + streamController.abort(); + } else { + externalSignal.addEventListener("abort", () => streamController.abort(), { once: true }); + } + } + const proxyOptions = { connectionProxyEnabled: credentials?.providerSpecificData?.connectionProxyEnabled === true, connectionProxyUrl: credentials?.providerSpecificData?.connectionProxyUrl || "", + connectionProxyUrls: Array.isArray(credentials?.providerSpecificData?.connectionProxyUrls) ? credentials.providerSpecificData.connectionProxyUrls : [], connectionNoProxy: credentials?.providerSpecificData?.connectionNoProxy || "", vercelRelayUrl: credentials?.providerSpecificData?.vercelRelayUrl || "", }; diff --git a/app/open-sse/handlers/chatCore/requestDetail.js b/app/open-sse/handlers/chatCore/requestDetail.js index bb0e9000..36236c21 100644 --- a/app/open-sse/handlers/chatCore/requestDetail.js +++ b/app/open-sse/handlers/chatCore/requestDetail.js @@ -1,5 +1,7 @@ -import { saveRequestUsage, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js"; -import { COLORS } from "../../utils/stream.js"; +import { saveRequestUsage, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js"; +import { COLORS } from "../../utils/stream.js"; + +const LOG_USAGE_EVENTS = process.env.USAGE_LOG_REQUESTS === "true"; const OPTIONAL_PARAMS = [ "temperature", "top_p", "top_k", @@ -82,9 +84,13 @@ export function saveUsageStats({ provider, model, tokens, connectionId, apiKey, if (inTokens === 0 && outTokens === 0) return; - const time = new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); - const accountSuffix = connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""; - console.log(`${COLORS.green}[${time}] 📊 [${label}] ${provider.toUpperCase()} | in=${inTokens} | out=${outTokens}${accountSuffix}${COLORS.reset}`); + if (LOG_USAGE_EVENTS) { + const d = new Date(); + const pad = (n) => String(n).padStart(2, "0"); + const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; + const accountSuffix = connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""; + console.log(`${COLORS.green}[${time}] 📊 [${label}] ${provider.toUpperCase()} | in=${inTokens} | out=${outTokens}${accountSuffix}${COLORS.reset}`); + } // Normalize to OpenAI token shape for storage const normalized = { diff --git a/app/open-sse/providers/capabilities.js b/app/open-sse/providers/capabilities.js index 9ae6f2b0..8d3cf5b0 100644 --- a/app/open-sse/providers/capabilities.js +++ b/app/open-sse/providers/capabilities.js @@ -82,6 +82,7 @@ export const MODEL_CAPABILITIES = { "claude-opus-4-8-thinking": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, "claude-sonnet-4.6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, "claude-sonnet-4-6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, + "claude-sonnet-5": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, // Gemini image-gen / OpenAI image / xai image variants "gpt-image-1": { imageOutput: true, tools: false }, @@ -138,6 +139,9 @@ export const PROVIDER_CAPABILITIES = { "deepseek-v4-flash": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 50000 }, "deepseek-v3-2-volc": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 96000, maxOutput: 32000 }, }, + nvidia: { + "minimaxai/minimax-m3": { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 512000, maxOutput: 131072 }, + }, kimi: { "kimi-k2.7": { vision: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: true, contextWindow: 262144, maxOutput: 262144 }, "kimi-k2.6": { vision: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: true, contextWindow: 262144, maxOutput: 262144 }, @@ -157,6 +161,7 @@ export const PATTERN_CAPABILITIES = [ { pattern: "*claude*opus-4.8*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } }, { pattern: "*claude*sonnet-4.6*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } }, { pattern: "*claude*sonnet-4.7*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } }, + { pattern: "*claude*sonnet-5*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 } }, { pattern: "*claude*haiku*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget" } }, { pattern: "*claude*opus*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget" } }, { pattern: "*claude*sonnet*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget" } }, diff --git a/app/open-sse/providers/pricing.js b/app/open-sse/providers/pricing.js index 9e767a80..e60ae53b 100644 --- a/app/open-sse/providers/pricing.js +++ b/app/open-sse/providers/pricing.js @@ -279,7 +279,8 @@ export function calculateCostFromTokens(tokens, pricing) { const inputTokens = tokens.prompt_tokens || tokens.input_tokens || 0; const cachedTokens = tokens.cached_tokens || tokens.cache_read_input_tokens || 0; - const nonCachedInput = Math.max(0, inputTokens - cachedTokens); + const cacheCreationTokens = tokens.cache_creation_input_tokens || 0; + const nonCachedInput = Math.max(0, inputTokens - cachedTokens - cacheCreationTokens); cost += nonCachedInput * (pricing.input / 1000000); @@ -295,7 +296,6 @@ export function calculateCostFromTokens(tokens, pricing) { cost += reasoningTokens * ((pricing.reasoning || pricing.output) / 1000000); } - const cacheCreationTokens = tokens.cache_creation_input_tokens || 0; if (cacheCreationTokens > 0) { cost += cacheCreationTokens * ((pricing.cache_creation || pricing.input) / 1000000); } diff --git a/app/open-sse/providers/registry/clinepass.js b/app/open-sse/providers/registry/clinepass.js new file mode 100644 index 00000000..702054ac --- /dev/null +++ b/app/open-sse/providers/registry/clinepass.js @@ -0,0 +1,57 @@ +export default { + id: "clinepass", + priority: 85, + alias: "clinepass", + uiAlias: "clinepass", + display: { + name: "ClinePass", + icon: "vpn_key", + color: "#5B9BD5", + textIcon: "CP", + website: "https://cline.bot", + notice: { + signupUrl: "https://app.cline.bot", + }, + }, + category: "oauth", + authModes: ["oauth", "apikey"], + hasOAuth: true, + transport: { + baseUrl: "https://api.cline.bot/api/v1/chat/completions", + headers: { + "HTTP-Referer": "https://cline.bot", + "X-Title": "Cline", + }, + auth: { + combined: true, + header: "Authorization", + scheme: "bearer", + hooks: [ + "clineHeaders", + ], + }, + }, + models: [ + { id: "cline-pass/glm-5.2", name: "GLM-5.2 (ClinePass)" }, + { id: "cline-pass/kimi-k2.7-code", name: "Kimi K2.7 Code (ClinePass)" }, + { id: "cline-pass/kimi-k2.6", name: "Kimi K2.6 (ClinePass)" }, + { id: "cline-pass/deepseek-v4-pro", name: "DeepSeek V4 Pro (ClinePass)" }, + { id: "cline-pass/deepseek-v4-flash", name: "DeepSeek V4 Flash (ClinePass)" }, + { id: "cline-pass/mimo-v2.5", name: "MiMo-V2.5 (ClinePass)" }, + { id: "cline-pass/mimo-v2.5-pro", name: "MiMo-V2.5-Pro (ClinePass)" }, + { id: "cline-pass/minimax-m3", name: "MiniMax M3 (ClinePass)" }, + { id: "cline-pass/qwen3.7-max", name: "Qwen3.7 Max (ClinePass)" }, + { id: "cline-pass/qwen3.7-plus", name: "Qwen3.7 Plus (ClinePass)" }, + ], + oauth: { + appBaseUrl: "https://app.cline.bot", + apiBaseUrl: "https://api.cline.bot", + authorizeUrl: "https://api.cline.bot/api/v1/auth/authorize", + tokenUrl: "https://api.cline.bot/api/v1/auth/token", + refreshUrl: "https://api.cline.bot/api/v1/auth/refresh", + }, + thinkingConfig: { + options: ["auto", "on", "off"], + defaultMode: "auto", + }, +}; diff --git a/app/open-sse/providers/registry/index.js b/app/open-sse/providers/registry/index.js index 3b2a69ad..1d08e5d6 100644 --- a/app/open-sse/providers/registry/index.js +++ b/app/open-sse/providers/registry/index.js @@ -15,6 +15,7 @@ import p12 from "./cerebras.js"; import p13 from "./chutes.js"; import p14 from "./claude.js"; import p15 from "./cline.js"; +import p15b from "./clinepass.js"; import p16 from "./cloudflare-ai.js"; import p17 from "./codebuddy-cn.js"; import p98 from "./codebuddy.js"; @@ -116,6 +117,7 @@ export default [ p13, p14, p15, + p15b, p16, p17, p18, diff --git a/app/open-sse/providers/registry/kiro.js b/app/open-sse/providers/registry/kiro.js index fb78a227..12015643 100644 --- a/app/open-sse/providers/registry/kiro.js +++ b/app/open-sse/providers/registry/kiro.js @@ -42,16 +42,20 @@ export default { }, }, models: [ + { id: "claude-sonnet-5", name: "Claude Sonnet 5" }, { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" }, { id: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, { id: "deepseek-3.2", name: "DeepSeek 3.2", strip: ["image","audio"] }, { id: "qwen3-coder-next", name: "Qwen3 Coder Next", strip: ["image","audio"] }, { id: "glm-5", name: "GLM 5" }, { id: "MiniMax-M2.5", name: "MiniMax M2.5" }, + { id: "claude-sonnet-5-thinking", name: "Claude Sonnet 5 (Thinking)" }, { id: "claude-sonnet-4.5-thinking", name: "Claude Sonnet 4.5 (Thinking)" }, { id: "claude-haiku-4.5-thinking", name: "Claude Haiku 4.5 (Thinking)" }, + { id: "claude-sonnet-5-agentic", name: "Claude Sonnet 5 (Agentic)" }, { id: "claude-sonnet-4.5-agentic", name: "Claude Sonnet 4.5 (Agentic)" }, { id: "claude-haiku-4.5-agentic", name: "Claude Haiku 4.5 (Agentic)" }, + { id: "claude-sonnet-5-thinking-agentic", name: "Claude Sonnet 5 (Thinking + Agentic)" }, { id: "claude-sonnet-4.5-thinking-agentic", name: "Claude Sonnet 4.5 (Thinking + Agentic)" }, { id: "claude-haiku-4.5-thinking-agentic", name: "Claude Haiku 4.5 (Thinking + Agentic)" }, ], diff --git a/app/open-sse/rtk/caveman.js b/app/open-sse/rtk/caveman.js index 9c9a2065..cc8580bb 100644 --- a/app/open-sse/rtk/caveman.js +++ b/app/open-sse/rtk/caveman.js @@ -4,6 +4,6 @@ import { injectSystemPrompt } from "./systemInject.js"; import { CAVEMAN_PROMPTS } from "./cavemanPrompts.js"; -export function injectCaveman(body, format, level) { - injectSystemPrompt(body, format, CAVEMAN_PROMPTS[level]); +export function injectCaveman(body, format, level, options = {}) { + injectSystemPrompt(body, format, CAVEMAN_PROMPTS[level], options); } diff --git a/app/open-sse/rtk/ponytail.js b/app/open-sse/rtk/ponytail.js index 54041acf..f12d0b9f 100644 --- a/app/open-sse/rtk/ponytail.js +++ b/app/open-sse/rtk/ponytail.js @@ -4,6 +4,6 @@ import { injectSystemPrompt } from "./systemInject.js"; import { PONYTAIL_PROMPTS } from "./ponytailPrompts.js"; -export function injectPonytail(body, format, level) { - injectSystemPrompt(body, format, PONYTAIL_PROMPTS[level]); +export function injectPonytail(body, format, level, options = {}) { + injectSystemPrompt(body, format, PONYTAIL_PROMPTS[level], options); } diff --git a/app/open-sse/rtk/ponytailPrompts.js b/app/open-sse/rtk/ponytailPrompts.js index 0cc22086..976cf9d8 100644 --- a/app/open-sse/rtk/ponytailPrompts.js +++ b/app/open-sse/rtk/ponytailPrompts.js @@ -17,24 +17,26 @@ const PONYTAIL_LEVELS = { // aggressively unrequested scope is challenged. const SHARED_LADDER = [ "You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.", - "Before writing code, stop at the first rung that holds:", + "Before writing code, understand the real task and stop at the first rung that holds:", "1. Does this need to exist at all? Speculative need = skip it, say so in one line (YAGNI).", - "2. Stdlib does it? Use it.", - "3. Native platform feature covers it? Use it (native input over a picker lib, CSS over JS, DB constraint over app code).", - "4. Already-installed dependency solves it? Use it. Never add a new dependency for what a few lines can do.", - "5. Can it be one line? One line.", - "6. Only then: the minimum code that works.", + "2. Already in this codebase? Reuse the existing helper, utility, type, or pattern before writing another one.", + "3. Stdlib does it? Use it.", + "4. Native platform feature covers it? Use it (native input over a picker lib, CSS over JS, DB constraint over app code).", + "5. Already-installed dependency solves it? Use it. Never add a new dependency for what a few lines can do.", + "6. Can it be one line? One line.", + "7. Only then: the minimum code that works.", + "Bug fix = root cause, not symptom. Fix it once in the shared path all callers route through.", ].join(" "); const SHARED_RULES = [ "No unrequested abstractions (no interface with one implementation, no factory for one product, no config for a value that never changes).", - "No boilerplate or scaffolding 'for later'. Deletion over addition. Boring over clever. Fewest files possible; shortest working diff wins.", + "No boilerplate or scaffolding 'for later'. Deletion over addition. Boring over clever. Fewest files possible; shortest working diff wins — but only after understanding the flow.", "Two stdlib options the same size? Take the one correct on edge cases — lazy means less code, not the flimsier algorithm.", "Mark deliberate simplifications with a `ponytail:` comment naming the ceiling and upgrade path (e.g. `// ponytail: global lock, per-account locks if throughput matters`).", ].join(" "); // Hard boundaries — never simplified away. Mirrors caveman's safety stance. -const SHARED_BOUNDARIES = "Never simplify away: input validation at trust boundaries, error handling that prevents data loss, security, accessibility, or anything explicitly requested. If the user insists on the full version, build it without re-arguing."; +const SHARED_BOUNDARIES = "Never simplify away: input validation at trust boundaries, error handling that prevents data loss, security, accessibility, or anything explicitly requested. Never lazy about understanding the problem: trace the whole affected flow before choosing the small diff. If the user insists on the full version, build it without re-arguing."; // Skeptical verification — no false claims, no assumptions. const SHARED_SKEPTICAL = "now is 2026, Be skeptical: never claim 'fixed', 'working', or 'correct' without concrete proof (test output, diff, reproducible verification). If a test passes, verify it tests what you think it tests. Check for side effects. Never fabricate reports ('all tests pass' without running them). Distinguish pre-existing bugs from ones you caused — run tests BEFORE and AFTER, diff the results. Report honestly: if broken and can't fix, say so. If skipped, explain why. If caveats, state them. Verify before declaring done — run relevant tests AFTER changes, show actual output."; diff --git a/app/open-sse/rtk/systemInject.js b/app/open-sse/rtk/systemInject.js index 0d5af728..0ebce388 100644 --- a/app/open-sse/rtk/systemInject.js +++ b/app/open-sse/rtk/systemInject.js @@ -1,14 +1,33 @@ -// Shared system-prompt injector: appends an instruction into the system message of -// the final request body, dispatching by format so it works for translated and -// native-passthrough flows. Used by caveman.js and ponytail.js. +// Shared style-prompt injector: appends one active instruction into the final +// request body, dispatching by provider format so translated and passthrough +// flows use the safest native shape. import { FORMATS } from "../translator/formats.js"; const SEP = "\n\n"; -export function injectSystemPrompt(body, format, prompt) { +export const STYLE_INJECTION_METHODS = { + OLD: "old", + NEW: "new", +}; + +const LAST_USER_REMINDER_PREFIX = "Style reminder: follow the active style instructions. "; + +export function getStyleInjectionMethod(method) { + return method === STYLE_INJECTION_METHODS.NEW + ? STYLE_INJECTION_METHODS.NEW + : STYLE_INJECTION_METHODS.OLD; +} + +export function injectSystemPrompt(body, format, prompt, options = {}) { if (!body || !prompt) return; + const method = getStyleInjectionMethod(options.method); + if (method === STYLE_INJECTION_METHODS.NEW) { + injectProviderAdapterPrompt(body, format, prompt); + return; + } + switch (format) { case FORMATS.CLAUDE: injectClaudeSystem(body, prompt); @@ -17,15 +36,35 @@ export function injectSystemPrompt(body, format, prompt) { case FORMATS.GEMINI_CLI: case FORMATS.VERTEX: case FORMATS.ANTIGRAVITY: - // Antigravity wraps Gemini shape in body.request → injectGeminiSystem handles it injectGeminiSystem(body, prompt); return; default: - // OpenAI and OpenAI-shaped formats (responses/codex/cursor/kiro/ollama) injectMessagesSystem(body, prompt); } } +function injectProviderAdapterPrompt(body, format, prompt) { + if (format === FORMATS.OPENAI_RESPONSES || format === FORMATS.OPENAI_RESPONSE || Array.isArray(body.input)) { + prependLastUserReminder(body, prompt); + return; + } + if (format === FORMATS.CLAUDE) { + injectClaudeSystem(body, prompt); + return; + } + if ( + format === FORMATS.GEMINI || + format === FORMATS.GEMINI_CLI || + format === FORMATS.VERTEX || + format === FORMATS.ANTIGRAVITY + ) { + injectGeminiSystem(body, prompt); + return; + } + injectMessagesSystemNearestUser(body, prompt); +} + + // OpenAI-shaped: messages[] (chat) or input[] (responses) or instructions (responses string) function injectMessagesSystem(body, prompt) { // OpenAI Responses API: top-level string field @@ -49,17 +88,67 @@ function injectMessagesSystem(body, prompt) { } } +function injectMessagesSystemNearestUser(body, prompt) { + if (typeof body.instructions === "string") { + body.instructions = body.instructions + ? `${body.instructions}${SEP}${prompt}` + : prompt; + return; + } + + if (!Array.isArray(body.messages)) return; + + const lastUserIdx = findLastMessageIndex(body.messages, "user"); + const insertIdx = lastUserIdx >= 0 ? lastUserIdx : body.messages.length; + body.messages.splice(insertIdx, 0, { role: "system", content: prompt }); +} +function prependLastUserReminder(body, prompt) { + const reminder = `${LAST_USER_REMINDER_PREFIX}${prompt}`; + + if (Array.isArray(body.messages)) { + const lastUserIdx = findLastMessageIndex(body.messages, "user"); + if (lastUserIdx >= 0) prependToOpenAIMessage(body.messages[lastUserIdx], reminder, "text"); + return; + } + + if (Array.isArray(body.input)) { + const lastUserIdx = findLastMessageIndex(body.input, "user"); + if (lastUserIdx >= 0) prependToOpenAIMessage(body.input[lastUserIdx], reminder, "input_text"); + } +} + +function findLastMessageIndex(arr, role) { + for (let i = arr.length - 1; i >= 0; i--) { + if (arr[i]?.role === role) return i; + } + return -1; +} + +function prependToOpenAIMessage(msg, prompt, fallbackPartType = "input_text") { + if (typeof msg.content === "string") { + msg.content = `${prompt}${SEP}${msg.content}`; + } else if (Array.isArray(msg.content)) { + msg.content.unshift({ type: getTextPartType(msg.content, fallbackPartType), text: prompt }); + } else { + msg.content = prompt; + } +} + function appendToOpenAIMessage(msg, prompt) { if (typeof msg.content === "string") { msg.content = `${msg.content}${SEP}${prompt}`; } else if (Array.isArray(msg.content)) { - // Responses-style array of parts {type:"input_text"|"text", text} - msg.content.push({ type: "input_text", text: prompt }); + msg.content.push({ type: getTextPartType(msg.content), text: prompt }); } else { msg.content = prompt; } } +function getTextPartType(parts, fallback = "input_text") { + const textPart = parts.find((part) => part?.type === "text" || part?.type === "input_text"); + return textPart?.type || fallback; +} + // Claude shape: body.system as string | array of {type:"text", text} // Insert before the last cache_control block to keep injection inside the cached prefix. function injectClaudeSystem(body, prompt) { diff --git a/app/open-sse/rtk/terse.js b/app/open-sse/rtk/terse.js index e1b528ca..3cb28068 100644 --- a/app/open-sse/rtk/terse.js +++ b/app/open-sse/rtk/terse.js @@ -1,6 +1,6 @@ import { injectSystemPrompt } from "./systemInject.js"; import { TERSE_PROMPTS } from "./tersePrompts.js"; -export function injectTerse(body, format, level) { - injectSystemPrompt(body, format, TERSE_PROMPTS[level]); +export function injectTerse(body, format, level, options = {}) { + injectSystemPrompt(body, format, TERSE_PROMPTS[level], options); } diff --git a/app/open-sse/rtk/tersePrompts.js b/app/open-sse/rtk/tersePrompts.js index 2fb742ec..ccc2d876 100644 --- a/app/open-sse/rtk/tersePrompts.js +++ b/app/open-sse/rtk/tersePrompts.js @@ -1,10 +1,12 @@ +const TERSE_BOUNDARIES = "Do not compress when compression would reduce correctness: planning, architecture, security review, code review, user-facing prose, prompt engineering, destructive actions, legal/security warnings, or multi-step instructions needing full clarity. If in doubt, keep clear full wording."; + export const TERSE_PROMPTS = { light: ` -Respond tersely. Remove filler, ceremony, repetition, and hedging. Keep normal grammar, exact code, exact commands, exact errors, and enough context to avoid ambiguity.`, +Be concise. Skip filler phrases, pleasantries, repetition, and unnecessary hedging. Keep full sentences plus exact code, commands, errors, URLs, and technical terms. ${TERSE_BOUNDARIES}`, medium: ` -Respond terse. Prefer short sentences and fragments. Drop filler, ceremony, repetition, hedging, and obvious explanation. Use bullets only when they reduce words. Keep exact code, exact commands, exact errors, URLs, security warnings, and multi-step instructions clear.`, +CAVEMAN MODE: omit articles, filler, pleasantries. Use short fragments and bare imperatives. Keep code/errors/commands/URLs verbatim. No apologies. No "I". Just signal. ${TERSE_BOUNDARIES}`, aggressive: ` -Max terseness. Telegraphic. Omit articles and filler. Use arrows and fragments when clear. One word when enough. Keep exact code, commands, errors, URLs, security warnings, irreversible actions, and ordered steps unambiguous.`, +ULTRA CAVEMAN: max compress. Labels only when safe. No sentences unless needed for clarity. Keep code, commands, errors, URLs, security warnings, irreversible actions, and ordered steps unambiguous. ${TERSE_BOUNDARIES}`, }; diff --git a/app/open-sse/rtk/webSearchSaver.js b/app/open-sse/rtk/webSearchSaver.js new file mode 100644 index 00000000..9c438c41 --- /dev/null +++ b/app/open-sse/rtk/webSearchSaver.js @@ -0,0 +1,142 @@ +import { injectSystemPrompt } from "./systemInject.js"; + +const MAX_WEB_RESULT_CHARS = 6_000; +const MAX_RESULTS = 5; +const cache = new Map(); + +export const WEB_SEARCH_SAVER_PROMPT = `Web search token saver is active. When using web search or fetch tools: +1. Rewrite broad asks into one focused query before searching. +2. Prefer 3-5 high-signal results; do not fetch every result. +3. Use snippets first. Fetch full pages only when snippets cannot answer. +4. Deduplicate overlapping results and cite the best canonical URL once. +5. Keep search/fetch outputs compact: title, URL, date if present, and only query-relevant facts.`; + +export function applyWebSearchSaver(body, format, enabled = true) { + if (!enabled || !body) return null; + injectSystemPrompt(body, format, WEB_SEARCH_SAVER_PROMPT); + const query = extractLastUserText(body); + const stats = { hits: 0, bytesBefore: 0, bytesAfter: 0 }; + transformToolOutputs(body, query, stats); + return stats; +} + +export function compressWebSearchText(text, query = "") { + if (typeof text !== "string" || text.length < 500) return text; + const key = `${query}\n${text}`; + if (cache.has(key)) return cache.get(key); + + const parsed = parseMaybeJson(text); + const compact = parsed == null + ? compressPlainText(text, query) + : JSON.stringify(compactJson(parsed, query), null, 2); + const out = compact && compact.length < text.length ? compact : text; + cache.set(key, out); + if (cache.size > 100) cache.delete(cache.keys().next().value); + return out; +} + +function transformToolOutputs(body, query, stats) { + const items = Array.isArray(body.messages) ? body.messages + : Array.isArray(body.input) ? body.input + : []; + for (const msg of items) { + if (!isLikelyWebTool(msg)) continue; + mapTextParts(msg, (text) => { + const next = compressWebSearchText(text, query); + stats.hits += next !== text ? 1 : 0; + stats.bytesBefore += text.length; + stats.bytesAfter += next.length; + return next; + }); + } +} + +function isLikelyWebTool(msg) { + const name = `${msg?.name || msg?.tool_name || msg?.recipient_name || msg?.call_id || ""}`.toLowerCase(); + if (/web|search|fetch|exa|brave|tavily|perplexity/.test(name)) return true; + if (msg?.role === "tool" || msg?.type === "function_call_output") { + const text = collectText(msg).slice(0, 1000).toLowerCase(); + return /"url"\s*:|https?:\/\/|"snippet"\s*:|"results"\s*:/.test(text); + } + return false; +} + +function mapTextParts(msg, fn) { + if (typeof msg.content === "string") msg.content = fn(msg.content); + if (typeof msg.output === "string") msg.output = fn(msg.output); + for (const field of ["content", "output"]) { + if (!Array.isArray(msg[field])) continue; + for (const part of msg[field]) { + if (typeof part?.text === "string") part.text = fn(part.text); + if (typeof part?.content === "string") part.content = fn(part.content); + } + } +} + +function collectText(msg) { + const chunks = []; + mapTextParts({ ...msg }, (text) => { chunks.push(text); return text; }); + return chunks.join("\n"); +} + +function compactJson(value, query) { + if (Array.isArray(value)) return rankResults(value, query).slice(0, MAX_RESULTS).map(compactResult); + if (Array.isArray(value?.results)) return { ...pick(value, ["query", "answer"]), results: rankResults(value.results, query).slice(0, MAX_RESULTS).map(compactResult) }; + if (Array.isArray(value?.data)) return { ...pick(value, ["query", "answer"]), data: rankResults(value.data, query).slice(0, MAX_RESULTS).map(compactResult) }; + return compactResult(value); +} + +function compactResult(item) { + if (!item || typeof item !== "object") return item; + const compact = pick(item, ["title", "url", "source", "publishedDate", "date", "snippet", "summary", "text", "content"]); + for (const key of ["snippet", "summary", "text", "content"]) { + if (typeof compact[key] === "string" && compact[key].length > 700) compact[key] = compact[key].slice(0, 700).trimEnd() + "…"; + } + return compact; +} + +function rankResults(results, query) { + const terms = query.toLowerCase().split(/\W+/).filter((term) => term.length > 3).slice(0, 12); + return [...results].sort((a, b) => scoreResult(b, terms) - scoreResult(a, terms)); +} + +function scoreResult(item, terms) { + const text = JSON.stringify(item || {}).toLowerCase(); + const termScore = terms.reduce((sum, term) => sum + (text.includes(term) ? 1 : 0), 0); + const hasUrl = item?.url ? 1 : 0; + const hasSnippet = item?.snippet || item?.summary || item?.content || item?.text ? 1 : 0; + return termScore * 3 + hasUrl + hasSnippet; +} + +function compressPlainText(text, query) { + const lines = text.split("\n").map((line) => line.trim()).filter(Boolean); + const terms = query.toLowerCase().split(/\W+/).filter((term) => term.length > 3).slice(0, 12); + const ranked = lines + .map((line, index) => ({ line, index, score: terms.reduce((sum, term) => sum + (line.toLowerCase().includes(term) ? 1 : 0), 0) + (/https?:\/\//.test(line) ? 1 : 0) })) + .sort((a, b) => b.score - a.score || a.index - b.index) + .slice(0, 40) + .sort((a, b) => a.index - b.index) + .map((item) => item.line); + return ranked.join("\n").slice(0, MAX_WEB_RESULT_CHARS); +} + +function extractLastUserText(body) { + const items = Array.isArray(body?.messages) ? body.messages : Array.isArray(body?.input) ? body.input : []; + for (let i = items.length - 1; i >= 0; i--) { + const msg = items[i]; + if (msg?.role !== "user") continue; + if (typeof msg.content === "string") return msg.content; + if (Array.isArray(msg.content)) return msg.content.map((part) => part?.text || "").join(" "); + } + return ""; +} + +function parseMaybeJson(text) { + try { return JSON.parse(text); } catch { return null; } +} + +function pick(obj, keys) { + const out = {}; + for (const key of keys) if (obj?.[key] != null) out[key] = obj[key]; + return out; +} diff --git a/app/open-sse/utils/debugLog.js b/app/open-sse/utils/debugLog.js index 26a00fbd..b91ba5ba 100644 --- a/app/open-sse/utils/debugLog.js +++ b/app/open-sse/utils/debugLog.js @@ -2,9 +2,11 @@ // Outputs are tagged with [DBG:tag] for easy grep/filter const isDev = process.env.NODE_ENV !== "production"; -function ts() { - return new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); -} +function ts() { + const d = new Date(); + const pad = (n) => String(n).padStart(2, "0"); + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} export function dbg(tag, msg) { if (!isDev) return; diff --git a/app/open-sse/utils/proxyFetch.js b/app/open-sse/utils/proxyFetch.js index 28030c76..618421c7 100644 --- a/app/open-sse/utils/proxyFetch.js +++ b/app/open-sse/utils/proxyFetch.js @@ -1,367 +1,389 @@ -import { Readable } from "stream"; -import { MEMORY_CONFIG } from "../config/runtimeConfig.js"; -import { dbg } from "./debugLog.js"; - -const originalFetch = globalThis.fetch; -const proxyDispatchers = new Map(); - -// ─── TLS fingerprinting via got-scraping (browser-like JA3) ─────────────── -// Disabled: not in use. Kept commented for future re-enable. -// Restore the original block to re-enable per-host JA3 spoofing. -/* -let _gotScraping = null; -let _gotScrapingChecked = false; -const _gotScrapingLoggedHosts = new Set(); - -async function getGotScraping() { - if (_gotScrapingChecked) return _gotScraping; - _gotScrapingChecked = true; - try { - const mod = await import("got-scraping"); - _gotScraping = typeof mod.gotScraping === "function" ? mod.gotScraping : null; - if (_gotScraping) dbg("TLS", "got-scraping loaded (browser-like JA3 enabled)"); - } catch (e) { - console.warn(`[ProxyFetch] got-scraping unavailable, falling back to native fetch: ${e.message}`); - _gotScraping = null; - } - return _gotScraping; -} - -async function gotScrapingFetch(url, options) { - const gs = await getGotScraping(); - if (!gs) return null; - - const method = (options.method || "GET").toUpperCase(); - const headersInit = options.headers || {}; - const headers = headersInit instanceof Headers - ? Object.fromEntries(headersInit.entries()) - : { ...headersInit }; - - return new Promise((resolve, reject) => { - let settled = false; - const stream = gs.stream({ - url, - method, - headers, - body: method === "GET" || method === "HEAD" ? undefined : options.body, - throwHttpErrors: false, - retry: { limit: 0 }, - timeout: { request: undefined }, - followRedirect: false, - decompress: true, - }); - - if (options.signal) { - const onAbort = () => { try { stream.destroy(new Error("aborted")); } catch { } }; - if (options.signal.aborted) onAbort(); - else options.signal.addEventListener("abort", onAbort, { once: true }); - } - - stream.once("response", (res) => { - if (settled) return; - settled = true; - const resHeaders = new Headers(); - for (const [k, v] of Object.entries(res.headers || {})) { - if (Array.isArray(v)) v.forEach((x) => resHeaders.append(k, String(x))); - else if (v != null) resHeaders.set(k, String(v)); - } - const body = Readable.toWeb(stream); - resolve(new Response(body, { status: res.statusCode, statusText: res.statusMessage || "", headers: resHeaders })); - }); - - stream.once("error", (err) => { - if (settled) return; - settled = true; - reject(err); - }); - }); -} - -async function tryGotScrapingFetch(url, options) { - try { - const res = await gotScrapingFetch(url, options); - if (res) { - try { - const host = new URL(typeof url === "string" ? url : url.toString()).hostname; - if (!_gotScrapingLoggedHosts.has(host)) { - _gotScrapingLoggedHosts.add(host); - dbg("TLS", `using got-scraping for ${host}`); - } - } catch { } - } - return res; - } catch (e) { - console.warn(`[ProxyFetch] got-scraping request failed, fallback to native fetch: ${e.message}`); - return null; - } -} -*/ - -// DNS cache — use Map to avoid prototype pollution via malformed hostnames -const DNS_CACHE = new Map(); -const MITM_BYPASS_HOSTS = [ - "cloudcode-pa.googleapis.com", - "daily-cloudcode-pa.googleapis.com", - "api.individual.githubcopilot.com", - "q.us-east-1.amazonaws.com", - "codewhisperer.us-east-1.amazonaws.com", - "api2.cursor.sh", -]; -const GOOGLE_DNS_SERVERS = ["8.8.8.8", "8.8.4.4"]; -const HTTPS_PORT = 443; -const HTTP_SUCCESS_MIN = 200; -const HTTP_SUCCESS_MAX = 300; - -function normalizeString(value) { - if (value === undefined || value === null) return ""; - return String(value).trim(); -} - -/** - * Resolve real IP using Google DNS (bypass system DNS) - */ -async function resolveRealIP(hostname) { - const cached = DNS_CACHE.get(hostname); - if (cached && Date.now() < cached.expiry) return cached.ip; - - try { - const dns = await import("dns"); - const { promisify } = await import("util"); - const resolver = new dns.Resolver(); - resolver.setServers(GOOGLE_DNS_SERVERS); - const resolve4 = promisify(resolver.resolve4.bind(resolver)); - const addresses = await resolve4(hostname); - DNS_CACHE.set(hostname, { ip: addresses[0], expiry: Date.now() + MEMORY_CONFIG.dnsCacheTtlMs }); - return addresses[0]; - } catch (error) { - console.warn(`[ProxyFetch] DNS resolve failed for ${hostname}:`, error.message); - return null; - } -} - -/** - * Check if request should bypass MITM DNS redirect - */ -function shouldBypassMitmDns(url) { - try { - const hostname = new URL(url).hostname; - return MITM_BYPASS_HOSTS.some(host => hostname.includes(host)); - } catch { return false; } -} - -function shouldBypassByNoProxy(targetUrl, noProxyValue) { - const noProxy = normalizeString(noProxyValue); - if (!noProxy) return false; - - let hostname; - try { hostname = new URL(targetUrl).hostname.toLowerCase(); } catch { return false; } - const patterns = noProxy.split(",").flatMap((p) => { const t = p.trim().toLowerCase(); return t ? [t] : []; }); - - return patterns.some((pattern) => { - if (pattern === "*") return true; - if (pattern.startsWith(".")) return hostname.endsWith(pattern) || hostname === pattern.slice(1); - return hostname === pattern || hostname.endsWith(`.${pattern}`); - }); -} - -/** - * Get proxy URL from environment - */ -function getEnvProxyUrl(targetUrl) { - const noProxy = process.env.NO_PROXY || process.env.no_proxy; - if (shouldBypassByNoProxy(targetUrl, noProxy)) return null; - - let protocol; - try { protocol = new URL(targetUrl).protocol; } catch { return null; } - - if (protocol === "https:") { - return process.env.HTTPS_PROXY || process.env.https_proxy || - process.env.ALL_PROXY || process.env.all_proxy; - } - - return process.env.HTTP_PROXY || process.env.http_proxy || - process.env.ALL_PROXY || process.env.all_proxy; -} - -/** - * Normalize proxy URL (allow host:port) - */ -function normalizeProxyUrl(proxyUrl) { - const normalizedInput = normalizeString(proxyUrl); - if (!normalizedInput) return null; - - try { - - new URL(normalizedInput); - return normalizedInput; - } catch { - // Allow "127.0.0.1:7890" style values - return `http://${normalizedInput}`; - } -} - -function resolveConnectionProxyUrl(targetUrl, proxyOptions) { - const enabled = proxyOptions?.enabled === true || proxyOptions?.connectionProxyEnabled === true; - if (!enabled) return null; - - const proxyUrlRaw = normalizeString(proxyOptions?.url ?? proxyOptions?.connectionProxyUrl); - if (!proxyUrlRaw) return null; - - const noProxy = normalizeString(proxyOptions?.noProxy ?? proxyOptions?.connectionNoProxy); - if (noProxy && shouldBypassByNoProxy(targetUrl, noProxy)) return null; - - return normalizeProxyUrl(proxyUrlRaw); -} - -/** - * Create proxy dispatcher lazily (undici-compatible) - */ -async function getDispatcher(proxyUrl) { - const normalized = normalizeProxyUrl(proxyUrl); - if (!normalized) return null; - - if (!proxyDispatchers.has(normalized)) { - // Evict oldest entry if max size reached - if (proxyDispatchers.size >= MEMORY_CONFIG.proxyDispatchersMaxSize) { - proxyDispatchers.delete(proxyDispatchers.keys().next().value); - } - const { ProxyAgent } = await import("undici"); - proxyDispatchers.set(normalized, new ProxyAgent({ uri: normalized })); - } - - return proxyDispatchers.get(normalized); -} - -/** - * Create HTTPS request with manual socket connection (bypass DNS) - */ -async function createBypassRequest(parsedUrl, realIP, options) { - const [httpsModule, netModule] = await Promise.all([import("https"), import("net")]); - // CJS modules expose exports via .default in ESM dynamic import context - const https = httpsModule.default ?? httpsModule; - const net = netModule.default ?? netModule; - - return new Promise((resolve, reject) => { - const socket = new net.Socket(); - - socket.connect(HTTPS_PORT, realIP, () => { - const reqOptions = { - socket, - // SNI + cert hostname are validated against the hostname the caller - // asked for, not the IP we connected to. This keeps the DNS-bypass - // (avoiding /etc/hosts MITM) while still rejecting on-path attackers - // that present a different cert. The MITM_BYPASS_HOSTS targets are - // all public-CA-issued (Google / GitHub / AWS / Cursor) so default - // verification works without any extra trust store. - servername: parsedUrl.hostname, - path: parsedUrl.pathname + parsedUrl.search, - method: options.method || "POST", - headers: { - ...options.headers, - Host: parsedUrl.hostname, - }, - }; - - const req = https.request(reqOptions, (res) => { - const response = { - ok: res.statusCode >= HTTP_SUCCESS_MIN && res.statusCode < HTTP_SUCCESS_MAX, - status: res.statusCode, - statusText: res.statusMessage, - headers: new Map(Object.entries(res.headers)), - body: Readable.toWeb(res), - text: async () => { - const chunks = []; - for await (const chunk of res) chunks.push(chunk); - return Buffer.concat(chunks).toString(); - }, - json: async () => JSON.parse(await response.text()), - }; - resolve(response); - }); - - req.on("error", reject); - if (options.body) { - req.write(typeof options.body === "string" ? options.body : JSON.stringify(options.body)); - } - req.end(); - }); - - socket.on("error", reject); - }); -} - -export async function proxyAwareFetch(url, options = {}, proxyOptions = null) { - const targetUrl = typeof url === "string" ? url : url.toString(); - - // Vercel relay: forward request via relay headers - const vercelRelayUrl = normalizeString(proxyOptions?.vercelRelayUrl); - if (vercelRelayUrl) { - const parsed = new URL(targetUrl); - const relayHeaders = { - ...options.headers, - "x-relay-target": `${parsed.protocol}//${parsed.host}`, - "x-relay-path": `${parsed.pathname}${parsed.search}`, - }; - return originalFetch(vercelRelayUrl, { ...options, headers: relayHeaders }); - } - - const connectionProxyUrl = resolveConnectionProxyUrl(targetUrl, proxyOptions); - const envProxyUrl = connectionProxyUrl ? null : normalizeProxyUrl(getEnvProxyUrl(targetUrl)); - const proxyUrl = connectionProxyUrl || envProxyUrl; - - // MITM DNS bypass: for known MITM-intercepted hosts, resolve real IP to avoid DNS spoof - if (shouldBypassMitmDns(targetUrl)) { - if (proxyUrl) { - // Proxy resolves DNS externally (not affected by /etc/hosts) — use proxy directly - try { - const dispatcher = await getDispatcher(proxyUrl); - return await originalFetch(url, { ...options, dispatcher }); - } catch (proxyError) { - if (proxyOptions?.strictProxy === true) { - throw new Error(`[ProxyFetch] Proxy required but failed (strictProxy=true): ${proxyError.message}`); - } - console.warn(`[ProxyFetch] Proxy failed, falling back to direct bypass: ${proxyError.message}`); - } - } - // No proxy — manually resolve real IP to bypass DNS spoof - try { - const parsedUrl = new URL(targetUrl); - const realIP = await resolveRealIP(parsedUrl.hostname); - if (realIP) return await createBypassRequest(parsedUrl, realIP, options); - } catch (error) { - console.warn(`[ProxyFetch] MITM bypass failed: ${error.message}`); - } - } - - if (proxyUrl) { - try { - const dispatcher = await getDispatcher(proxyUrl); - return await originalFetch(url, { ...options, dispatcher }); - } catch (proxyError) { - // If strictProxy is enabled, fail hard instead of falling back to direct - if (proxyOptions?.strictProxy === true) { - throw new Error(`[ProxyFetch] Proxy required but failed (strictProxy=true): ${proxyError.message}`); - } - console.warn(`[ProxyFetch] Proxy failed, falling back to direct: ${proxyError.message}`); - return originalFetch(url, options); - } - } - - // got-scraping disabled — use native fetch directly - // (Re-enable per-host by wrapping with tryGotScrapingFetch when needed) - return originalFetch(url, options); -} - -/** - * Patched global fetch with env-proxy support and MITM DNS bypass - */ -async function patchedFetch(url, options = {}) { - return proxyAwareFetch(url, options, null); -} - -// Idempotency guard — only patch once to avoid wrapping multiple times -if (globalThis.fetch !== patchedFetch) { - globalThis.fetch = patchedFetch; -} - -export default patchedFetch; +import { Readable } from "stream"; +import { MEMORY_CONFIG } from "../config/runtimeConfig.js"; +import { dbg } from "./debugLog.js"; + +const originalFetch = globalThis.fetch; +const proxyDispatchers = new Map(); + +// ─── TLS fingerprinting via got-scraping (browser-like JA3) ─────────────── +// Disabled: not in use. Kept commented for future re-enable. +// Restore the original block to re-enable per-host JA3 spoofing. +/* +let _gotScraping = null; +let _gotScrapingChecked = false; +const _gotScrapingLoggedHosts = new Set(); + +async function getGotScraping() { + if (_gotScrapingChecked) return _gotScraping; + _gotScrapingChecked = true; + try { + const mod = await import("got-scraping"); + _gotScraping = typeof mod.gotScraping === "function" ? mod.gotScraping : null; + if (_gotScraping) dbg("TLS", "got-scraping loaded (browser-like JA3 enabled)"); + } catch (e) { + console.warn(`[ProxyFetch] got-scraping unavailable, falling back to native fetch: ${e.message}`); + _gotScraping = null; + } + return _gotScraping; +} + +async function gotScrapingFetch(url, options) { + const gs = await getGotScraping(); + if (!gs) return null; + + const method = (options.method || "GET").toUpperCase(); + const headersInit = options.headers || {}; + const headers = headersInit instanceof Headers + ? Object.fromEntries(headersInit.entries()) + : { ...headersInit }; + + return new Promise((resolve, reject) => { + let settled = false; + const stream = gs.stream({ + url, + method, + headers, + body: method === "GET" || method === "HEAD" ? undefined : options.body, + throwHttpErrors: false, + retry: { limit: 0 }, + timeout: { request: undefined }, + followRedirect: false, + decompress: true, + }); + + if (options.signal) { + const onAbort = () => { try { stream.destroy(new Error("aborted")); } catch { } }; + if (options.signal.aborted) onAbort(); + else options.signal.addEventListener("abort", onAbort, { once: true }); + } + + stream.once("response", (res) => { + if (settled) return; + settled = true; + const resHeaders = new Headers(); + for (const [k, v] of Object.entries(res.headers || {})) { + if (Array.isArray(v)) v.forEach((x) => resHeaders.append(k, String(x))); + else if (v != null) resHeaders.set(k, String(v)); + } + const body = Readable.toWeb(stream); + resolve(new Response(body, { status: res.statusCode, statusText: res.statusMessage || "", headers: resHeaders })); + }); + + stream.once("error", (err) => { + if (settled) return; + settled = true; + reject(err); + }); + }); +} + +async function tryGotScrapingFetch(url, options) { + try { + const res = await gotScrapingFetch(url, options); + if (res) { + try { + const host = new URL(typeof url === "string" ? url : url.toString()).hostname; + if (!_gotScrapingLoggedHosts.has(host)) { + _gotScrapingLoggedHosts.add(host); + dbg("TLS", `using got-scraping for ${host}`); + } + } catch { } + } + return res; + } catch (e) { + console.warn(`[ProxyFetch] got-scraping request failed, fallback to native fetch: ${e.message}`); + return null; + } +} +*/ + +// DNS cache — use Map to avoid prototype pollution via malformed hostnames +const DNS_CACHE = new Map(); +const MITM_BYPASS_HOSTS = [ + "cloudcode-pa.googleapis.com", + "daily-cloudcode-pa.googleapis.com", + "api.individual.githubcopilot.com", + "q.us-east-1.amazonaws.com", + "codewhisperer.us-east-1.amazonaws.com", + "api2.cursor.sh", +]; +const GOOGLE_DNS_SERVERS = ["8.8.8.8", "8.8.4.4"]; +const HTTPS_PORT = 443; +const HTTP_SUCCESS_MIN = 200; +const HTTP_SUCCESS_MAX = 300; + +function normalizeString(value) { + if (value === undefined || value === null) return ""; + return String(value).trim(); +} + +/** + * Resolve real IP using Google DNS (bypass system DNS) + */ +async function resolveRealIP(hostname) { + const cached = DNS_CACHE.get(hostname); + if (cached && Date.now() < cached.expiry) return cached.ip; + + try { + const dns = await import("dns"); + const { promisify } = await import("util"); + const resolver = new dns.Resolver(); + resolver.setServers(GOOGLE_DNS_SERVERS); + const resolve4 = promisify(resolver.resolve4.bind(resolver)); + const addresses = await resolve4(hostname); + DNS_CACHE.set(hostname, { ip: addresses[0], expiry: Date.now() + MEMORY_CONFIG.dnsCacheTtlMs }); + return addresses[0]; + } catch (error) { + console.warn(`[ProxyFetch] DNS resolve failed for ${hostname}:`, error.message); + return null; + } +} + +/** + * Check if request should bypass MITM DNS redirect + */ +function shouldBypassMitmDns(url) { + try { + const hostname = new URL(url).hostname; + return MITM_BYPASS_HOSTS.some(host => hostname.includes(host)); + } catch { return false; } +} + +function shouldBypassByNoProxy(targetUrl, noProxyValue) { + const noProxy = normalizeString(noProxyValue); + if (!noProxy) return false; + + let hostname; + try { hostname = new URL(targetUrl).hostname.toLowerCase(); } catch { return false; } + const patterns = noProxy.split(",").flatMap((p) => { const t = p.trim().toLowerCase(); return t ? [t] : []; }); + + return patterns.some((pattern) => { + if (pattern === "*") return true; + if (pattern.startsWith(".")) return hostname.endsWith(pattern) || hostname === pattern.slice(1); + return hostname === pattern || hostname.endsWith(`.${pattern}`); + }); +} + +/** + * Get proxy URL from environment + */ +function getEnvProxyUrl(targetUrl) { + const noProxy = process.env.NO_PROXY || process.env.no_proxy; + if (shouldBypassByNoProxy(targetUrl, noProxy)) return null; + + let protocol; + try { protocol = new URL(targetUrl).protocol; } catch { return null; } + + if (protocol === "https:") { + return process.env.HTTPS_PROXY || process.env.https_proxy || + process.env.ALL_PROXY || process.env.all_proxy; + } + + return process.env.HTTP_PROXY || process.env.http_proxy || + process.env.ALL_PROXY || process.env.all_proxy; +} + +/** + * Normalize proxy URL (allow host:port) + */ +function normalizeProxyUrl(proxyUrl) { + const normalizedInput = normalizeString(proxyUrl); + if (!normalizedInput) return null; + + try { + + new URL(normalizedInput); + return normalizedInput; + } catch { + // Allow "127.0.0.1:7890" style values + return `http://${normalizedInput}`; + } +} + +export function resolveConnectionProxyUrls(targetUrl, proxyOptions) { + const enabled = proxyOptions?.enabled === true || proxyOptions?.connectionProxyEnabled === true; + if (!enabled) return []; + + const rawUrls = Array.isArray(proxyOptions?.connectionProxyUrls) + ? proxyOptions.connectionProxyUrls + : [proxyOptions?.url ?? proxyOptions?.connectionProxyUrl]; + const noProxy = normalizeString(proxyOptions?.noProxy ?? proxyOptions?.connectionNoProxy); + if (noProxy && shouldBypassByNoProxy(targetUrl, noProxy)) return []; + + return rawUrls + .map((value) => normalizeProxyUrl(normalizeString(value))) + .filter(Boolean); +} + +function resolveConnectionProxyUrl(targetUrl, proxyOptions) { + return resolveConnectionProxyUrls(targetUrl, proxyOptions)[0] || null; +} + +export function shouldRetryProxyResponse(response) { + if (!response) return false; + return response.status === 429 || response.status >= 500; +} + +/** + * Create proxy dispatcher lazily (undici-compatible) + */ +async function getDispatcher(proxyUrl) { + const normalized = normalizeProxyUrl(proxyUrl); + if (!normalized) return null; + + if (!proxyDispatchers.has(normalized)) { + // Evict oldest entry if max size reached + if (proxyDispatchers.size >= MEMORY_CONFIG.proxyDispatchersMaxSize) { + proxyDispatchers.delete(proxyDispatchers.keys().next().value); + } + const { ProxyAgent } = await import("undici"); + proxyDispatchers.set(normalized, new ProxyAgent({ uri: normalized })); + } + + return proxyDispatchers.get(normalized); +} + +/** + * Create HTTPS request with manual socket connection (bypass DNS) + */ +async function createBypassRequest(parsedUrl, realIP, options) { + const [httpsModule, netModule] = await Promise.all([import("https"), import("net")]); + // CJS modules expose exports via .default in ESM dynamic import context + const https = httpsModule.default ?? httpsModule; + const net = netModule.default ?? netModule; + + return new Promise((resolve, reject) => { + const socket = new net.Socket(); + + socket.connect(HTTPS_PORT, realIP, () => { + const reqOptions = { + socket, + // SNI + cert hostname are validated against the hostname the caller + // asked for, not the IP we connected to. This keeps the DNS-bypass + // (avoiding /etc/hosts MITM) while still rejecting on-path attackers + // that present a different cert. The MITM_BYPASS_HOSTS targets are + // all public-CA-issued (Google / GitHub / AWS / Cursor) so default + // verification works without any extra trust store. + servername: parsedUrl.hostname, + path: parsedUrl.pathname + parsedUrl.search, + method: options.method || "POST", + headers: { + ...options.headers, + Host: parsedUrl.hostname, + }, + }; + + const req = https.request(reqOptions, (res) => { + const response = { + ok: res.statusCode >= HTTP_SUCCESS_MIN && res.statusCode < HTTP_SUCCESS_MAX, + status: res.statusCode, + statusText: res.statusMessage, + headers: new Map(Object.entries(res.headers)), + body: Readable.toWeb(res), + text: async () => { + const chunks = []; + for await (const chunk of res) chunks.push(chunk); + return Buffer.concat(chunks).toString(); + }, + json: async () => JSON.parse(await response.text()), + }; + resolve(response); + }); + + req.on("error", reject); + if (options.body) { + req.write(typeof options.body === "string" ? options.body : JSON.stringify(options.body)); + } + req.end(); + }); + + socket.on("error", reject); + }); +} + +export async function proxyAwareFetch(url, options = {}, proxyOptions = null) { + const targetUrl = typeof url === "string" ? url : url.toString(); + + // Vercel relay: forward request via relay headers + const vercelRelayUrl = normalizeString(proxyOptions?.vercelRelayUrl); + if (vercelRelayUrl) { + const parsed = new URL(targetUrl); + const relayHeaders = { + ...options.headers, + "x-relay-target": `${parsed.protocol}//${parsed.host}`, + "x-relay-path": `${parsed.pathname}${parsed.search}`, + }; + return originalFetch(vercelRelayUrl, { ...options, headers: relayHeaders }); + } + + const connectionProxyUrls = resolveConnectionProxyUrls(targetUrl, proxyOptions); + const envProxyUrl = connectionProxyUrls.length > 0 ? null : normalizeProxyUrl(getEnvProxyUrl(targetUrl)); + const proxyUrls = connectionProxyUrls.length > 0 ? connectionProxyUrls : (envProxyUrl ? [envProxyUrl] : []); + + // MITM DNS bypass: for known MITM-intercepted hosts, resolve real IP to avoid DNS spoof + if (shouldBypassMitmDns(targetUrl)) { + if (proxyUrls.length > 0) { + let lastProxyError = null; + for (const proxyUrl of proxyUrls) { + try { + const dispatcher = await getDispatcher(proxyUrl); + const response = await originalFetch(url, { ...options, dispatcher }); + if (!shouldRetryProxyResponse(response)) return response; + } catch (proxyError) { + lastProxyError = proxyError; + if (proxyUrl === proxyUrls.at(-1)) break; + } + } + if (proxyOptions?.strictProxy === true) { + throw new Error(`[ProxyFetch] Proxy required but failed (strictProxy=true): ${lastProxyError?.message || "all proxies returned retryable responses"}`); + } + console.warn(`[ProxyFetch] Proxy failed, falling back to direct bypass: ${lastProxyError?.message || "all proxies returned retryable responses"}`); + } + // No proxy — manually resolve real IP to bypass DNS spoof + try { + const parsedUrl = new URL(targetUrl); + const realIP = await resolveRealIP(parsedUrl.hostname); + if (realIP) return await createBypassRequest(parsedUrl, realIP, options); + } catch (error) { + console.warn(`[ProxyFetch] MITM bypass failed: ${error.message}`); + } + } + + if (proxyUrls.length > 0) { + let lastProxyError = null; + for (const proxyUrl of proxyUrls) { + try { + const dispatcher = await getDispatcher(proxyUrl); + const response = await originalFetch(url, { ...options, dispatcher }); + if (!shouldRetryProxyResponse(response)) return response; + } catch (proxyError) { + lastProxyError = proxyError; + if (proxyUrl === proxyUrls.at(-1)) break; + } + } + + if (proxyOptions?.strictProxy === true) { + throw new Error(`[ProxyFetch] Proxy required but failed (strictProxy=true): ${lastProxyError?.message || "all proxies returned retryable responses"}`); + } + console.warn(`[ProxyFetch] Proxy failed, falling back to direct: ${lastProxyError?.message || "all proxies returned retryable responses"}`); + return originalFetch(url, options); + } + + // got-scraping disabled — use native fetch directly + // (Re-enable per-host by wrapping with tryGotScrapingFetch when needed) + return originalFetch(url, options); +} + +/** + * Patched global fetch with env-proxy support and MITM DNS bypass + */ +async function patchedFetch(url, options = {}) { + return proxyAwareFetch(url, options, null); +} + +// Idempotency guard — only patch once to avoid wrapping multiple times +if (globalThis.fetch !== patchedFetch) { + globalThis.fetch = patchedFetch; +} + +export default patchedFetch; diff --git a/app/open-sse/utils/stream.js b/app/open-sse/utils/stream.js index 8ae714ec..e18ac0a5 100644 --- a/app/open-sse/utils/stream.js +++ b/app/open-sse/utils/stream.js @@ -53,6 +53,25 @@ export function emitKimiToolCallsChunk(controller, toolCalls, state, modelName, // sharedEncoder is stateless — safe to share across streams const sharedEncoder = new TextEncoder(); +const MAX_ACCUMULATED_STREAM_CHARS = Math.max( + 1024, + Number.parseInt(process.env.STREAM_CAPTURE_MAX_CHARS || "16384", 10) || 16384 +); + +function appendBoundedText(current, next) { + if (!next || typeof next !== "string") return current; + const combined = `${current || ""}${next}`; + if (combined.length <= MAX_ACCUMULATED_STREAM_CHARS) return combined; + const omitted = combined.length - MAX_ACCUMULATED_STREAM_CHARS; + const marker = `[truncated ${omitted} chars]\n`; + const keep = Math.max(0, MAX_ACCUMULATED_STREAM_CHARS - marker.length); + return `${marker}${combined.slice(-keep)}`; +} + +function removeTrailingText(current, suffix) { + if (!current || !suffix || typeof suffix !== "string") return current; + return current.endsWith(suffix) ? current.slice(0, -suffix.length) : current; +} /** * Stream modes @@ -179,6 +198,10 @@ export function createSSEStream(options = {}) { delete choice.content_filter_results; fieldsInjected = true; } + if (Array.isArray(choice.delta?.tool_calls) && choice.delta.tool_calls.length === 0) { + delete choice.delta.tool_calls; + fieldsInjected = true; + } } } @@ -210,11 +233,11 @@ export function createSSEStream(options = {}) { const reasoning = delta?.reasoning_content; if (content && typeof content === "string") { totalContentLength += content.length; - accumulatedContent += content; + accumulatedContent = appendBoundedText(accumulatedContent, content); } if (reasoning && typeof reasoning === "string") { totalContentLength += reasoning.length; - accumulatedThinking += reasoning; + accumulatedThinking = appendBoundedText(accumulatedThinking, reasoning); } const extracted = extractUsage(parsed); @@ -299,18 +322,18 @@ export function createSSEStream(options = {}) { // Claude format - content if (parsed.delta?.text) { totalContentLength += parsed.delta.text.length; - accumulatedContent += parsed.delta.text; + accumulatedContent = appendBoundedText(accumulatedContent, parsed.delta.text); } // Claude format - thinking if (parsed.delta?.thinking) { totalContentLength += parsed.delta.thinking.length; - accumulatedThinking += parsed.delta.thinking; + accumulatedThinking = appendBoundedText(accumulatedThinking, parsed.delta.thinking); } // OpenAI format - content if (parsed.choices?.[0]?.delta?.content) { totalContentLength += parsed.choices[0].delta.content.length; - accumulatedContent += parsed.choices[0].delta.content; + accumulatedContent = appendBoundedText(accumulatedContent, parsed.choices[0].delta.content); } // Detect and correct native Kimi tool-call markup that leaks into the @@ -331,17 +354,17 @@ export function createSSEStream(options = {}) { } // Adjust accumulated content to reflect the stripped markup. totalContentLength -= originalDelta.length; - accumulatedContent = accumulatedContent.slice(0, accumulatedContent.length - originalDelta.length); + accumulatedContent = removeTrailingText(accumulatedContent, originalDelta); if (normalized.content) { totalContentLength += normalized.content.length; - accumulatedContent += normalized.content; + accumulatedContent = appendBoundedText(accumulatedContent, normalized.content); } } } // OpenAI format - reasoning if (parsed.choices?.[0]?.delta?.reasoning_content) { totalContentLength += parsed.choices[0].delta.reasoning_content.length; - accumulatedThinking += parsed.choices[0].delta.reasoning_content; + accumulatedThinking = appendBoundedText(accumulatedThinking, parsed.choices[0].delta.reasoning_content); } // Gemini format @@ -351,9 +374,9 @@ export function createSSEStream(options = {}) { totalContentLength += part.text.length; // Check if this is thinking content if (part.thought === true) { - accumulatedThinking += part.text; + accumulatedThinking = appendBoundedText(accumulatedThinking, part.text); } else { - accumulatedContent += part.text; + accumulatedContent = appendBoundedText(accumulatedContent, part.text); } } } diff --git a/app/open-sse/utils/streamHandler.js b/app/open-sse/utils/streamHandler.js index b8a06e2f..43cfcf79 100644 --- a/app/open-sse/utils/streamHandler.js +++ b/app/open-sse/utils/streamHandler.js @@ -4,7 +4,9 @@ import { dbg, isDebugEnabled } from "./debugLog.js"; // Get HH:MM:SS timestamp function getTimeString() { - return new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); + const d = new Date(); + const pad = (n) => String(n).padStart(2, "0"); + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } /** @@ -250,4 +252,3 @@ export function pipeWithDisconnect(providerResponse, transformStream, streamCont onAbortTerminal ); } - diff --git a/app/open-sse/utils/usageTracking.js b/app/open-sse/utils/usageTracking.js index f42fcebb..9c9f90c6 100644 --- a/app/open-sse/utils/usageTracking.js +++ b/app/open-sse/utils/usageTracking.js @@ -16,10 +16,13 @@ export const COLORS = { // Buffer tokens to prevent context errors const BUFFER_TOKENS = 2000; +const LOG_USAGE_EVENTS = process.env.USAGE_LOG_REQUESTS === "true"; // Get HH:MM:SS timestamp function getTimeString() { - return new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); + const d = new Date(); + const pad = (n) => String(n).padStart(2, "0"); + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } /** @@ -171,7 +174,16 @@ export function hasValidUsage(usage) { export function extractUsage(chunk) { if (!chunk || typeof chunk !== "object") return null; - // Claude format (message_delta event) + // Claude format (message_start/message_delta events) + if (chunk.type === "message_start" && chunk.message?.usage && typeof chunk.message.usage === "object") { + return normalizeUsage({ + prompt_tokens: chunk.message.usage.input_tokens || 0, + completion_tokens: chunk.message.usage.output_tokens || 0, + cache_read_input_tokens: chunk.message.usage.cache_read_input_tokens, + cache_creation_input_tokens: chunk.message.usage.cache_creation_input_tokens + }); + } + if (chunk.type === "message_delta" && chunk.usage && typeof chunk.usage === "object") { return normalizeUsage({ prompt_tokens: chunk.usage.input_tokens || 0, @@ -304,6 +316,7 @@ export function estimateUsage(body, contentLength, targetFormat = FORMATS.OPENAI */ export function logUsage(provider, usage, model = null, connectionId = null, apiKey = null) { if (!usage || typeof usage !== "object") return; + if (!LOG_USAGE_EVENTS) return; const p = provider?.toUpperCase() || "UNKNOWN"; diff --git a/app/package-lock.json b/app/package-lock.json index 902ef8b7..d3b5897d 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "9router-app", - "version": "0.7.4", + "version": "0.9.32", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "9router-app", - "version": "0.7.4", + "version": "0.9.32", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", diff --git a/app/package.json b/app/package.json index 3633ef5f..ceb060ae 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "xscope0-router-app", - "version": "0.7.4", + "version": "0.9.32", "description": "xScope0 Router web dashboard", "private": true, "scripts": { diff --git a/app/public/i18n/literals/id.json b/app/public/i18n/literals/id.json index 6a1161a6..d16a40c7 100644 --- a/app/public/i18n/literals/id.json +++ b/app/public/i18n/literals/id.json @@ -191,5 +191,65 @@ "Click to add, click again to remove. Changes are saved automatically.": "Klik untuk menambah, klik lagi untuk menghapus. Perubahan disimpan secara otomatis.", "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ Pemberitahuan Risiko: Penyedia ini menggunakan sesi langganan/OAuth yang tidak dilisensikan secara resmi untuk penggunaan proxy/router. Akun mungkin dibatasi atau diblokir. Gunakan dengan risiko Anda sendiri.", "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM mencegat lalu lintas HTTPS alat IDE (Antigravity, GitHub Copilot, Kiro) melalui CA lokal untuk mengalihkan permintaan ke penyedia Anda. Mungkin melanggar ToS → risiko ban akun. Gunakan dengan risiko Anda sendiri.", - "Endpoint is exposed without an API key.": "Endpoint terekspos tanpa kunci API." + "Endpoint is exposed without an API key.": "Endpoint terekspos tanpa kunci API.", + "connection": "koneksi", + "connections": "koneksi", + "Auto Login Bulk": "Login Otomatis Massal", + "Run bulk gmail|password automation with worker progress and manual assist.": "Jalankan otomatisasi massal gmail|password dengan progres pekerja dan bantuan manual.", + "Bulk Token": "Token Massal", + "Import many Kiro refresh tokens, one token per line.": "Impor banyak refresh token Kiro, satu token per baris.", + "Single Token": "Token Tunggal", + "Auto-detect or paste one Kiro refresh token.": "Deteksi otomatis atau tempel satu refresh token Kiro.", + "AWS Builder ID": "AWS Builder ID", + "Open the standard AWS Builder ID device login.": "Buka login perangkat AWS Builder ID standar.", + "AWS IDC": "AWS IDC", + "Enter an IAM Identity Center start URL and region.": "Masukkan URL awal IAM Identity Center dan region.", + "Google Login": "Login Google", + "Open Kiro social Google login with callback capture.": "Buka login sosial Google Kiro dengan penangkapan callback.", + "Kiro Bulk GSuite Auto Login": "Login Otomatis GSuite Massal Kiro", + "CodeBuddy Bulk GSuite Auto Login": "Login Otomatis GSuite Massal CodeBuddy", + "Qoder Bulk GSuite Auto Login": "Login Otomatis GSuite Massal Qoder", + "CodeBuddy OAuth Token Import": "Impor Token OAuth CodeBuddy", + "Paste CodeBuddy OAuth tokens, one per line. Supports three formats:": "Tempel token OAuth CodeBuddy, satu per baris. Mendukung tiga format:", + "Auto Login + Generate Key": "Login Otomatis + Buat Kunci", + "Run bulk GSuite gmail|password login, create a CodeBuddy Access Key, and save it for model calls.": "Jalankan login massal GSuite gmail|password, buat CodeBuddy Access Key, lalu simpan untuk panggilan model.", + "OAuth Token Import": "Impor Token OAuth", + "Paste OAuth tokens with optional refresh tokens and API keys for extended access.": "Tempel token OAuth dengan refresh token dan kunci API opsional untuk akses lebih lama.", + "Device OAuth Login": "Login OAuth Perangkat", + "Open CodeBuddy browser login and poll until the OAuth token is saved.": "Buka login browser CodeBuddy dan poll sampai token OAuth tersimpan.", + "Phone OTP + Generate Key": "OTP Telepon + Buat Kunci", + "Buy 5sim SMS OTP, login to CodeBuddy CN, generate an API key from the authenticated browser session, and save it.": "Beli OTP SMS 5sim, login ke CodeBuddy CN, buat kunci API dari sesi browser terautentikasi, lalu simpan.", + "Run bulk gmail:password or gmail|password automation via Google SSO with Qoder device flow.": "Jalankan otomatisasi massal gmail:password atau gmail|password via Google SSO dengan alur perangkat Qoder.", + "Open Qoder device login in browser and poll until the token is saved.": "Buka login perangkat Qoder di browser dan poll sampai token tersimpan.", + "Proxy": "Proxy", + "None": "Tidak Ada", + "Rotate": "Rotasi", + "Err-proxy": "Proxy error", + "Auto-ping": "Ping otomatis", + "Testing": "Menguji", + "Test": "Uji", + "OAuth Account": "Akun OAuth", + "Cookie Account": "Akun Cookie", + "selected": "dipilih", + "selected for bulk actions": "dipilih untuk tindakan massal", + "Apply Proxy": "Terapkan Proxy", + "Testing...": "Menguji...", + "Test each": "Uji tiap kunci", + "Stopping...": "Menghentikan...", + "Stop": "Hentikan", + "Error → inactive": "Error → nonaktif", + "Sticky:": "Sticky:", + "Provider-wide": "Seluruh penyedia", + "Selected keys": "Kunci terpilih", + "Available Models": "Model Tersedia", + "Active All": "Aktifkan Semua", + "Disable All": "Nonaktifkan Semua", + "Get API Key →": "Dapatkan Kunci API →", + "Sign up / Learn more": "Daftar / Pelajari lagi", + "Choose": "Pilih", + "or": "atau", + "Completed": "Selesai", + "Passed": "Lulus", + "Add API Key": "Tambah Kunci API", + "Add Connection": "Tambah Koneksi" } diff --git a/app/public/i18n/literals/zh-CN.json b/app/public/i18n/literals/zh-CN.json index dd232007..7b25f21e 100644 --- a/app/public/i18n/literals/zh-CN.json +++ b/app/public/i18n/literals/zh-CN.json @@ -760,8 +760,8 @@ "OpenAI Compatible Details": "OpenAI 兼容详情", "Messages API": "消息 API", "Sticky:": "粘滞:", - "connection": "个连接", - "connections": "个连接", + "connection": "连接", + "connections": "连接", "Suggested free models (≥200k context):": "推荐的免费模型(≥200k 上下文):", "Get API Key →": "获取 API 密钥 →", "OAuth": "OAuth", @@ -769,5 +769,55 @@ "Close": "关闭", "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ 风险提示:此提供商使用的订阅/OAuth 会话未获官方授权用于代理/路由器使用。账户可能被限制或封禁。使用风险自负。", "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM 通过本地 CA 拦截 IDE 工具(Antigravity、GitHub Copilot、Kiro)的 HTTPS 流量,将请求重定向到您的提供商。可能违反 ToS → 账户封禁风险。使用风险自负。", - "Endpoint is exposed without an API key.": "端点未设置 API 密钥即对外暴露。" + "Endpoint is exposed without an API key.": "端点未设置 API 密钥即对外暴露。", + "Auto Login Bulk": "批量自动登录", + "Run bulk gmail|password automation with worker progress and manual assist.": "运行批量 gmail|password 自动化,显示工作进度并支持手动辅助。", + "Bulk Token": "批量令牌", + "Import many Kiro refresh tokens, one token per line.": "导入多个 Kiro 刷新令牌,每行一个。", + "Single Token": "单个令牌", + "Auto-detect or paste one Kiro refresh token.": "自动检测或粘贴一个 Kiro 刷新令牌。", + "Open the standard AWS Builder ID device login.": "打开标准 AWS Builder ID 设备登录。", + "AWS IDC": "AWS IDC", + "Enter an IAM Identity Center start URL and region.": "输入 IAM Identity Center 起始 URL 和区域。", + "Google Login": "Google 登录", + "Open Kiro social Google login with callback capture.": "打开 Kiro 社交 Google 登录并捕获回调。", + "Kiro Bulk GSuite Auto Login": "Kiro 批量 GSuite 自动登录", + "CodeBuddy Bulk GSuite Auto Login": "CodeBuddy 批量 GSuite 自动登录", + "Qoder Bulk GSuite Auto Login": "Qoder 批量 GSuite 自动登录", + "CodeBuddy OAuth Token Import": "CodeBuddy OAuth 令牌导入", + "Paste CodeBuddy OAuth tokens, one per line. Supports three formats:": "粘贴 CodeBuddy OAuth 令牌,每行一个。支持三种格式:", + "Auto Login + Generate Key": "自动登录并生成密钥", + "Run bulk GSuite gmail|password login, create a CodeBuddy Access Key, and save it for model calls.": "批量运行 GSuite gmail|password 登录,创建 CodeBuddy 访问密钥,并保存用于模型调用。", + "OAuth Token Import": "OAuth 令牌导入", + "Paste OAuth tokens with optional refresh tokens and API keys for extended access.": "粘贴 OAuth 令牌,可附带刷新令牌和 API 密钥以延长访问。", + "Device OAuth Login": "设备 OAuth 登录", + "Open CodeBuddy browser login and poll until the OAuth token is saved.": "打开 CodeBuddy 浏览器登录,并轮询直到 OAuth 令牌保存。", + "Phone OTP + Generate Key": "手机 OTP 并生成密钥", + "Buy 5sim SMS OTP, login to CodeBuddy CN, generate an API key from the authenticated browser session, and save it.": "购买 5sim 短信 OTP,登录 CodeBuddy CN,从已认证的浏览器会话生成 API 密钥并保存。", + "Run bulk gmail:password or gmail|password automation via Google SSO with Qoder device flow.": "通过 Google SSO 和 Qoder 设备流程运行批量 gmail:password 或 gmail|password 自动化。", + "Open Qoder device login in browser and poll until the token is saved.": "在浏览器中打开 Qoder 设备登录,并轮询直到令牌保存。", + "Rotate": "轮换", + "Err-proxy": "错误换代理", + "Auto-ping": "自动 Ping", + "Testing": "测试中", + "Cookie Account": "Cookie 账号", + "selected": "已选择", + "selected for bulk actions": "已选择用于批量操作", + "Apply Proxy": "应用代理", + "Test each": "逐个测试", + "Stopping...": "停止中...", + "Stop": "停止", + "Error → inactive": "错误 → 停用", + "Provider-wide": "提供商全局", + "Selected keys": "选中密钥", + "Active All": "全部启用", + "Disable All": "全部禁用", + "Sign up / Learn more": "注册 / 了解更多", + "Choose": "选择", + "or": "或", + "Total": "总计", + "Completed": "已完成", + "Passed": "通过", + "Failed": "失败", + "Add API Key": "添加 API 密钥" } diff --git a/app/src/app/(dashboard)/dashboard/automation/page.js b/app/src/app/(dashboard)/dashboard/automation/page.js index 95e09887..72f18e21 100644 --- a/app/src/app/(dashboard)/dashboard/automation/page.js +++ b/app/src/app/(dashboard)/dashboard/automation/page.js @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { translate } from "@/i18n/runtime"; import { Badge, Button, @@ -14,7 +15,7 @@ import { import { FREE_PROVIDERS } from "@/shared/constants/providers"; function getConnectionLabel(count) { - return `${count} connection${count === 1 ? "" : "s"}`; + return `${count} ${translate(count === 1 ? "connection" : "connections")}`; } function KiroAutomationPanel({ providerInfo, onRefresh }) { @@ -30,30 +31,30 @@ function KiroAutomationPanel({ providerInfo, onRefresh }) { const options = [ { id: "bulk-account", - title: "Auto Login Bulk", + title: translate("Auto Login Bulk"), icon: "group_add", - description: "Run bulk gmail|password automation with worker progress and manual assist.", + description: translate("Run bulk gmail|password automation with worker progress and manual assist."), action: () => setIsBulkOpen(true), }, { id: "bulk-token", - title: "Bulk Token", + title: translate("Bulk Token"), icon: "playlist_add", - description: "Import many Kiro refresh tokens, one token per line.", + description: translate("Import many Kiro refresh tokens, one token per line."), action: () => openFlow({ method: "import", importMode: "bulk-token" }), }, { id: "single-token", - title: "Single Token", + title: translate("Single Token"), icon: "vpn_key", - description: "Auto-detect or paste one Kiro refresh token.", + description: translate("Auto-detect or paste one Kiro refresh token."), action: () => openFlow({ method: "import", importMode: "single-token" }), }, { id: "builder-id", - title: "AWS Builder ID", + title: translate("AWS Builder ID"), icon: "shield", - description: "Open the standard AWS Builder ID device login.", + description: translate("Open the standard AWS Builder ID device login."), action: () => openFlow({ method: "builder-id" }), }, { @@ -65,9 +66,9 @@ function KiroAutomationPanel({ providerInfo, onRefresh }) { }, { id: "google", - title: "Google Login", + title: translate("Google Login"), icon: "account_circle", - description: "Open Kiro social Google login with callback capture.", + description: translate("Open Kiro social Google login with callback capture."), action: () => openFlow({ method: "social", provider: "google" }), }, ]; @@ -178,8 +179,8 @@ function CodeBuddyBulkTokenModal({ isOpen, onClose, onSuccess }) { return (
e.stopPropagation()}> -

CodeBuddy OAuth Token Import

-

Paste CodeBuddy OAuth tokens, one per line. Supports three formats:

+

{translate("CodeBuddy OAuth Token Import")}

+

{translate("Paste CodeBuddy OAuth tokens, one per line. Supports three formats:")}

check_circle @@ -255,10 +256,10 @@ function CodeBuddyAutomationPanel({ providerInfo, onRefresh }) { > playlist_add - OAuth Token Import + {translate("OAuth Token Import")} - Paste OAuth tokens with optional refresh tokens and API keys for extended access. + {translate("Paste OAuth tokens with optional refresh tokens and API keys for extended access.")}
@@ -345,10 +346,10 @@ function QoderAutomationPanel({ providerInfo, onRefresh }) { > group_add - Auto Login Bulk + {translate("Auto Login Bulk")} - Run bulk gmail:password or gmail|password automation via Google SSO with Qoder device flow. + {translate("Run bulk gmail:password or gmail|password automation via Google SSO with Qoder device flow.")}
setIsBulkOpen(false)} @@ -387,6 +388,38 @@ function QoderAutomationPanel({ providerInfo, onRefresh }) { ); } +function AntigravityAutomationPanel({ onRefresh }) { + const [isBulkOpen, setIsBulkOpen] = useState(false); + + return ( + <> +
+ +
+ setIsBulkOpen(false)} + /> + + ); +} + const AUTOMATION_PROVIDERS = [ { id: "kiro", @@ -420,6 +453,14 @@ const AUTOMATION_PROVIDERS = [ supportedModes: ["bulk-account", "device-oauth"], component: QoderAutomationPanel, }, + { + id: "antigravity", + label: "Antigravity", + icon: "travel_explore", + description: "Bulk gmail|password Google login for Antigravity OAuth.", + supportedModes: ["bulk-account"], + component: AntigravityAutomationPanel, + }, ]; export default function AutomationPage() { diff --git a/app/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js b/app/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js index 6b4184ee..52a75421 100644 --- a/app/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js +++ b/app/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js @@ -4,10 +4,10 @@ import { useState } from "react"; import { Button, Badge, Input, Modal, Select } from "@/shared/components"; import { AI_PROVIDERS } from "@/shared/constants/providers"; -const BULK_PLACEHOLDER = `name1|sk-key1\nname2|sk-key2\nsk-key-only-auto-named`; +const BULK_PLACEHOLDER = `sk-key-1\nsk-key-2\nsk-key-3`; -function CloudflareConfigSection({ cloudflareData, setCloudflareData }) { - return ( +function CloudflareConfigSection({ cloudflareData, setCloudflareData }) { + return (

Cloudflare Workers AI

dash.cloudflare.com

- ); -} - - -function AzureConfigSection({ azureData, setAzureData }) { - return ( + ); +} + + +function AzureConfigSection({ azureData, setAzureData }) { + return (

Azure OpenAI Configuration

@@ -55,10 +55,10 @@ function AzureConfigSection({ azureData, setAzureData }) { />
- ); -} - - + ); +} + + export default function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, authType, authHint, website, proxyPools, error, onSave, onBulkDone, onClose }) { const NONE_PROXY_POOL_VALUE = "__none__"; const isOllamaLocal = provider === "ollama-local"; @@ -133,10 +133,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa const handleSubmit = async () => { if (!provider) return; if (!isOllamaLocal && !formData.apiKey) return; - if (!isOllamaLocal) { - // Non-ollama providers require a name - if (!formData.name) return; - } + const connectionName = formData.name.trim() || `${providerName || provider} Key`; if (isCompatible && !formData.defaultModel.trim()) return; setSaving(true); try { @@ -158,7 +155,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa setValidating(false); } await onSave({ - name: formData.name || (isOllamaLocal ? "Ollama Local" : ""), + name: connectionName, apiKey: formData.apiKey, defaultModel: isCompatible ? formData.defaultModel.trim() : undefined, priority: formData.priority, @@ -213,7 +210,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
{mode === "bulk" && (
-

One key per line. Format: name|apiKey or just apiKey (auto-named by index).

+

One API key per line. Optional: name|apiKey.