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.")}
login
- Device OAuth Login
+ {translate("Device OAuth Login")}
- Open CodeBuddy browser login and poll until the OAuth token is saved.
+ {translate("Open CodeBuddy browser login and poll until the OAuth token is saved.")}
@@ -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.")}
login
- Device OAuth Login
+ {translate("Device OAuth Login")}
- Open Qoder device login in browser and poll until the token is saved.
+ {translate("Open Qoder device login in browser and poll until the token is saved.")}
setIsBulkOpen(false)}
@@ -387,6 +388,38 @@ function QoderAutomationPanel({ providerInfo, onRefresh }) {
);
}
+function AntigravityAutomationPanel({ onRefresh }) {
+ const [isBulkOpen, setIsBulkOpen] = useState(false);
+
+ return (
+ <>
+
+ setIsBulkOpen(true)}
+ className="flex min-h-[112px] min-w-0 flex-col gap-2 rounded-lg border border-border bg-surface px-4 py-3 text-left transition-colors hover:border-primary/40 hover:bg-primary/5"
+ >
+
+ group_add
+ Antigravity Bulk Auto Login
+
+
+ Run bulk gmail|password Google login and save Antigravity OAuth connections.
+
+
+
+ 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.
)}
- {isXaiApiKey && (
-
- Use a direct xAI API key from console.x.ai. This is separate from Grok Build OAuth.
-
- )}
- {isCookie && authHint && (
-
- {authHint}
- {website && (
- <>
- {" "}
-
- Open {website.replace(/^https?:\/\//, "")}
-
- >
- )}
-
- )}
{providerRegions && (
)}
- {error && {error}
}
- {isCompatible && (
-
- Enter the model ID exactly as your compatible endpoint expects it. This model will be saved as the connection default.
-
- )}
- {isCloudflareAi && }
- {isAzure && }
+ {error && {error}
}
+ {isCloudflareAi && }
+ {isAzure && }
)}
-
- Legacy manual proxy fields are still accepted by API for backward compatibility.
-
-
+
{saving ? "Saving..." : "Save"}
diff --git a/app/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js b/app/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js
index 1e6fa493..88a5d3d6 100644
--- a/app/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js
+++ b/app/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js
@@ -4,8 +4,9 @@ import { useState, useEffect, useRef } from "react";
import { getStatusVariant as getConnectionStatusVariant } from "@/shared/utils/connectionStatus";
import { Badge, Toggle, Tooltip } from "@/shared/components";
import CooldownTimer from "./CooldownTimer";
+import { translate } from "@/i18n/runtime";
-export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onUpdateProviderData, onEdit, onDelete, showDelete = true, oneByOneStatus = null, autoPing = null, showProxyAutomation = false }) {
+export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onUpdateProviderData, onEdit, onDelete, onTest = null, showDelete = true, oneByOneStatus = null, autoPing = null, showProxyAutomation = false }) {
const [showProxyDropdown, setShowProxyDropdown] = useState(false);
const [showRotateDropdown, setShowRotateDropdown] = useState(false);
const [updatingProxy, setUpdatingProxy] = useState(false);
@@ -74,11 +75,11 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
const isOAuthConnection = rowAuthType === "oauth";
const isCookieConnection = rowAuthType === "cookie";
const authIcon = isCookieConnection ? "cookie" : isOAuthConnection ? "lock" : "key";
- const authLabel = isOAuthConnection ? "OAuth" : isCookieConnection ? "Cookie" : "API Key";
+ const authLabel = translate(isOAuthConnection ? "OAuth" : isCookieConnection ? "Cookie" : "API Key");
const displayName = connection.name?.trim()
|| connection.email?.trim()
|| connection.displayName?.trim()
- || (isOAuthConnection ? "OAuth Account" : isCookieConnection ? "Cookie Account" : "API Key");
+ || translate(isOAuthConnection ? "OAuth Account" : isCookieConnection ? "Cookie Account" : "API Key");
const secondaryDisplayName = connection.name?.trim() && connection.email?.trim() && connection.name.trim() !== connection.email.trim()
? connection.email.trim()
: connection.name?.trim() && connection.displayName?.trim() && connection.name.trim() !== connection.displayName.trim()
@@ -230,7 +231,7 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
{updatingProxy ? "progress_activity" : "lan"}
- Proxy
+ {translate("Proxy")}
{showProxyDropdown && (
@@ -255,7 +256,7 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
)}
{showProxyAutomation && (
-
+
setShowRotateDropdown((v) => !v)}
className={`flex w-full flex-col items-center rounded px-2 py-1 transition-colors hover:bg-black/5 dark:hover:bg-white/5 ${connection.providerSpecificData?.autoRotateProxyMinutes ? "text-primary" : "text-text-muted hover:text-primary"}`}
@@ -266,7 +267,7 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
{showRotateDropdown && (
- {[[null, "Off"], [5, "Every 5m"], [10, "Every 10m"], [15, "Every 15m"]].map(([value, label]) => (
+ {[[null, "Off"], [1, "Every 1m"], [5, "Every 5m"], [10, "Every 10m"], [15, "Every 15m"], [30, "Every 30m"]].map(([value, label]) => (
{ onUpdateProviderData?.({ autoRotateProxyMinutes: value }); setShowRotateDropdown(false); }}
@@ -286,7 +287,7 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
className={`flex w-full flex-col items-center rounded px-2 py-1 transition-colors hover:bg-black/5 dark:hover:bg-white/5 ${connection.providerSpecificData?.autoRotateProxyOnError ? "text-primary" : "text-text-muted hover:text-primary"}`}
>
sync_problem
- Err-proxy
+ {translate("Err-proxy")}
)}
@@ -297,18 +298,28 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
className={`flex w-full flex-col items-center rounded px-2 py-1 transition-colors hover:bg-black/5 dark:hover:bg-white/5 ${autoPing.on ? "text-primary" : "text-text-muted hover:text-primary"}`}
>
bolt
- Auto-ping
+ {translate("Auto-ping")}
)}
+ {onTest && (
+
+ science
+ {translate(oneByOneStatus?.state === "testing" ? "Testing" : "Test")}
+
+ )}
edit
- Edit
+ {translate("Edit")}
{showDelete && (
delete
- Delete
+ {translate("Delete")}
)}
diff --git a/app/src/app/(dashboard)/dashboard/providers/[id]/page.js b/app/src/app/(dashboard)/dashboard/providers/[id]/page.js
index 56765860..fa89f4d0 100644
--- a/app/src/app/(dashboard)/dashboard/providers/[id]/page.js
+++ b/app/src/app/(dashboard)/dashboard/providers/[id]/page.js
@@ -20,12 +20,10 @@ import AddApiKeyModal from "./AddApiKeyModal";
import EditCompatibleNodeModal from "./EditCompatibleNodeModal";
import AddCustomModelModal from "./AddCustomModelModal";
import BulkImportCodexModal from "./BulkImportCodexModal";
+import { buildProviderSpecificDataPatch, buildRotationPatch, canTestProviderConnection, PROXY_ROTATION_INTERVAL_OPTIONS, shouldShowFetchModelsButton } from "./providerPageUtils";
-const ONE_BY_ONE_DELAY_MS = 1000;
+const ONE_BY_ONE_CONCURRENCY = 16;
-function sleep(ms) {
- return new Promise((resolve) => setTimeout(resolve, ms));
-}
export default function ProviderDetailPage() {
const params = useParams();
@@ -56,6 +54,8 @@ export default function ProviderDetailPage() {
const [selectedConnectionIds, setSelectedConnectionIds] = useState([]);
const [bulkProxyPoolId, setBulkProxyPoolId] = useState("__none__");
const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false);
+ const [bulkRotationMinutes, setBulkRotationMinutes] = useState("");
+ const [bulkRotationOnError, setBulkRotationOnError] = useState(false);
const [providerStrategy, setProviderStrategy] = useState(null);
const [providerStickyLimit, setProviderStickyLimit] = useState("");
const [providerAutoDeactivate, setProviderAutoDeactivate] = useState(false);
@@ -72,7 +72,7 @@ export default function ProviderDetailPage() {
const [oneByOneResults, setOneByOneResults] = useState({});
const [oneByOneSummary, setOneByOneSummary] = useState(null);
const stopOneByOneRef = useRef(false);
- const [importingQoderModels, setImportingQoderModels] = useState(false);
+ const [importingProviderModels, setImportingProviderModels] = useState(false);
const { copied, copy } = useCopyToClipboard();
const AG_RISK_STORAGE_KEY = "ag_risk_confirmed";
@@ -110,6 +110,8 @@ export default function ProviderDetailPage() {
triggerApiKeyConnection();
};
+ const canTestConnection = (connection) => canTestProviderConnection(connection, { isOAuth });
+
const handleAgRiskConfirm = () => {
if (typeof window !== "undefined") {
window.localStorage.setItem(AG_RISK_STORAGE_KEY, "true");
@@ -188,7 +190,7 @@ export default function ProviderDetailPage() {
const handleDisableAll = async (ids) => {
if (!ids.length) return;
setConfirmState({
- title: "Disable All Models",
+ title: `${translate("Disable All")} Models`,
message: `Disable all ${ids.length} model(s)?`,
onConfirm: async () => {
setConfirmState(null);
@@ -343,7 +345,7 @@ export default function ProviderDetailPage() {
if (strategy) override.fallbackStrategy = strategy;
else delete override.fallbackStrategy;
if (strategy === "round-robin" && stickyLimit !== "") {
- override.stickyRoundRobinLimit = Number(stickyLimit) || 3;
+ override.stickyRoundRobinLimit = Math.min(16, Math.max(1, Number(stickyLimit) || 1));
} else {
delete override.stickyRoundRobinLimit;
}
@@ -371,8 +373,9 @@ export default function ProviderDetailPage() {
};
const handleStickyLimitChange = (value) => {
- setProviderStickyLimit(value);
- saveProviderStrategy("round-robin", value);
+ const nextValue = value === "" ? "" : String(Math.min(16, Math.max(1, Number(value) || 1)));
+ setProviderStickyLimit(nextValue);
+ saveProviderStrategy("round-robin", nextValue);
};
const handleProviderAutoDeactivateToggle = async (enabled) => {
@@ -532,16 +535,15 @@ export default function ProviderDetailPage() {
}
};
- // Fetch Qoder model list and automatically add to available models
- const handleImportQoderModels = async () => {
- if (importingQoderModels) return;
+ const handleImportProviderModels = async () => {
+ if (importingProviderModels) return;
const activeConnection = connections.find((conn) => conn.isActive !== false);
if (!activeConnection) {
- alert(translate("Please add an active Qoder connection first"));
+ alert(translate("Please add an active connection first"));
return;
}
- setImportingQoderModels(true);
+ setImportingProviderModels(true);
try {
const res = await fetch(`/api/providers/${activeConnection.id}/models`);
const data = await res.json();
@@ -559,30 +561,65 @@ export default function ProviderDetailPage() {
for (const model of models) {
const modelId = model.id || model.name;
if (!modelId) continue;
-
- // Qoder model ID format may be "qoder/auto" or "auto", need to remove prefix
- const cleanModelId = modelId.replace(/^qoder\//, "");
+
+ const modelIdString = String(modelId);
+ const modelPrefix = `${providerStorageAlias}/`;
+ const cleanModelId = modelIdString.startsWith(modelPrefix) ? modelIdString.slice(modelPrefix.length) : modelIdString;
const alreadyExists = customModels.some(
(entry) => entry.providerAlias === providerStorageAlias && entry.id === cleanModelId && (entry.kind || entry.type || "llm") === "llm"
) || Object.values(modelAliases).includes(`${providerStorageAlias}/${cleanModelId}`);
- if (alreadyExists) {
- continue;
- }
+ if (alreadyExists) continue;
await handleAddCustomModel(cleanModelId, "llm", providerStorageAlias);
importedCount += 1;
}
-
+
if (importedCount === 0) {
alert(translate("All models already exist, no new models added"));
} else {
alert(translate("Successfully added") + ` ${importedCount} ` + translate("models"));
}
} catch (error) {
- console.log("Error importing Qoder models:", error);
+ console.log("Error importing provider models:", error);
alert(translate("Error fetching models") + ": " + error.message);
} finally {
- setImportingQoderModels(false);
+ setImportingProviderModels(false);
+ }
+ };
+
+ const handleTestConnection = async (connectionId) => {
+ if (!connectionId) return;
+ setOneByOneResults((prev) => ({
+ ...prev,
+ [connectionId]: { state: "testing", error: null },
+ }));
+ try {
+ const res = await fetch(`/api/providers/${connectionId}/test`, { method: "POST" });
+ const data = await res.json();
+ const valid = !!data.valid;
+ setOneByOneResults((prev) => ({
+ ...prev,
+ [connectionId]: {
+ state: valid ? "success" : "failed",
+ error: valid ? null : (data.error || null),
+ },
+ }));
+ } catch (error) {
+ setOneByOneResults((prev) => ({
+ ...prev,
+ [connectionId]: { state: "failed", error: error.message || "Test failed" },
+ }));
+ }
+ };
+
+ const testProviderConnection = async (connectionId) => {
+ try {
+ const res = await fetch(`/api/providers/${connectionId}/test`, { method: "POST" });
+ const data = await res.json();
+ const valid = !!data.valid;
+ return { valid, error: valid ? null : (data.error || null) };
+ } catch (error) {
+ return { valid: false, error: error.message || "Test failed" };
}
};
@@ -600,69 +637,55 @@ export default function ProviderDetailPage() {
setOneByOneResults(queuedState);
setOneByOneSummary({ total: connections.length, completed: 0, passed: 0, failed: 0, stopped: false });
+ let nextIndex = 0;
+ let completed = 0;
let passed = 0;
let failed = 0;
- try {
- for (let index = 0; index < connections.length; index += 1) {
- if (stopOneByOneRef.current) {
- setOneByOneSummary({
- total: connections.length,
- completed: index,
- passed,
- failed,
- stopped: true,
- });
- break;
- }
-
- const connection = connections[index];
+ const runNext = async () => {
+ while (!stopOneByOneRef.current && nextIndex < connections.length) {
+ const connection = connections[nextIndex];
+ nextIndex += 1;
setOneByOneCurrentConnectionId(connection.id);
setOneByOneResults((prev) => ({
...prev,
[connection.id]: { state: "testing", error: null },
}));
- try {
- const res = await fetch(`/api/providers/${connection.id}/test`, { method: "POST" });
- const data = await res.json();
- const valid = !!data.valid;
-
- if (valid) {
- passed += 1;
- } else {
- failed += 1;
- }
-
- setOneByOneResults((prev) => ({
- ...prev,
- [connection.id]: {
- state: valid ? "success" : "failed",
- error: valid ? null : (data.error || null),
- },
- }));
- } catch (error) {
- failed += 1;
- setOneByOneResults((prev) => ({
- ...prev,
- [connection.id]: {
- state: "failed",
- error: error.message || "Test failed",
- },
- }));
- }
+ const result = await testProviderConnection(connection.id);
+ completed += 1;
+ if (result.valid) passed += 1;
+ else failed += 1;
+ setOneByOneResults((prev) => ({
+ ...prev,
+ [connection.id]: {
+ state: result.valid ? "success" : "failed",
+ error: result.error,
+ },
+ }));
setOneByOneSummary({
total: connections.length,
- completed: index + 1,
+ completed,
passed,
failed,
stopped: false,
});
+ }
+ };
- if (index < connections.length - 1) {
- await sleep(ONE_BY_ONE_DELAY_MS);
- }
+ try {
+ await Promise.all(
+ Array.from({ length: Math.min(ONE_BY_ONE_CONCURRENCY, connections.length) }, runNext),
+ );
+ if (stopOneByOneRef.current) {
+ setOneByOneSummary({
+ total: connections.length,
+ completed,
+ passed,
+ failed,
+ stopped: true,
+ });
}
} finally {
setOneByOneCurrentConnectionId(null);
@@ -812,15 +835,13 @@ export default function ProviderDetailPage() {
};
const handleUpdateProviderData = async (conn, patch) => {
- const providerSpecificData = { ...(conn.providerSpecificData || {}), ...patch };
- for (const [key, value] of Object.entries(providerSpecificData)) {
- if (value === null || value === undefined || value === "") delete providerSpecificData[key];
- }
+ const providerSpecificData = buildProviderSpecificDataPatch(conn.providerSpecificData || {}, patch);
+ const submittedProviderSpecificData = { ...(conn.providerSpecificData || {}), ...patch };
try {
const res = await fetch(`/api/providers/${conn.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ providerSpecificData }),
+ body: JSON.stringify({ providerSpecificData: submittedProviderSpecificData }),
});
if (res.ok) {
setConnections(prev => prev.map(c => c.id === conn.id ? { ...c, providerSpecificData } : c));
@@ -830,6 +851,21 @@ export default function ProviderDetailPage() {
}
};
+ const applyRotationSettings = async (targets) => {
+ if (targets.length === 0 || bulkUpdatingProxy) return;
+ const intervalMinutes = bulkRotationMinutes === "" ? null : Number(bulkRotationMinutes);
+ const patch = buildRotationPatch({
+ intervalMinutes,
+ rotateOnError: bulkRotationOnError ? true : null,
+ });
+ setBulkUpdatingProxy(true);
+ try {
+ await Promise.all(targets.map((conn) => handleUpdateProviderData(conn, patch)));
+ } finally {
+ setBulkUpdatingProxy(false);
+ }
+ };
+
const handleSwapPriority = async (index1, index2) => {
// Optimistic update state
const newConnections = [...connections];
@@ -963,7 +999,7 @@ export default function ProviderDetailPage() {
onChange={toggleSelectAllConnections}
className="h-3.5 w-3.5 rounded border-border accent-primary"
/>
- {selectedConnectionIds.length > 0 ? `${selectedConnectionIds.length} selected` : "Select all"}
+ {selectedConnectionIds.length > 0 ? `${selectedConnectionIds.length} ${translate("selected")}` : translate("Select all")}
)}
{connections
@@ -988,7 +1024,7 @@ export default function ProviderDetailPage() {
onMoveDown={() => handleSwapPriority(index, index + 1)}
onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
onUpdateProviderData={(patch) => handleUpdateProviderData(conn, patch)}
- showProxyAutomation={["mimo-free", "opencode"].includes(providerId)}
+ showProxyAutomation={true}
showDelete={providerId !== "kiro" || conn.isActive === false}
autoPing={providerId === "claude" && conn.authType === "oauth" ? {
on: autoPing.connections[conn.id] === true,
@@ -1017,6 +1053,7 @@ export default function ProviderDetailPage() {
setShowEditModal(true);
}}
onDelete={() => handleDelete(conn.id)}
+ onTest={canTestConnection(conn) ? () => handleTestConnection(conn.id) : null}
oneByOneStatus={oneByOneResults[conn.id] || null}
/>
@@ -1031,7 +1068,7 @@ export default function ProviderDetailPage() {
@@ -1195,17 +1232,16 @@ export default function ProviderDetailPage() {
Add Model
- {/* Import Qoder models button — only show for qoder provider */}
- {providerId === "qoder" && connections.some((conn) => conn.isActive !== false) && (
+ {shouldShowFetchModelsButton() && (
-
- {importingQoderModels ? "progress_activity" : "download"}
+
+ {importingProviderModels ? "progress_activity" : "download"}
- {importingQoderModels ? translate("Fetching...") : translate("Fetch Qoder Models")}
+ {importingProviderModels ? translate("Fetching...") : translate("Fetch Models")}
)}
@@ -1279,7 +1315,7 @@ export default function ProviderDetailPage() {
Provider not found
- Back to Providers
+ {translate("Back to Providers")}
);
@@ -1296,7 +1332,7 @@ export default function ProviderDetailPage() {
};
return (
-
+
{/* Header */}
arrow_back
- Back to Providers
+ {translate("Back to Providers")}
open_in_new
- {providerInfo.notice?.apiKeyUrl ? "Get API Key" : "Sign up / Learn more"}
+ {providerInfo.notice?.apiKeyUrl ? translate("Get API Key") : translate("Sign up / Learn more")}
)}
@@ -1367,7 +1403,7 @@ export default function ProviderDetailPage() {
rel="noopener noreferrer"
className="inline-flex justify-center rounded bg-blue-500 px-2 py-1 text-xs font-medium text-white transition-colors hover:bg-blue-600 sm:py-0.5"
>
- Get API Key →
+ {translate("Get API Key →")}
)}
@@ -1439,9 +1475,14 @@ export default function ProviderDetailPage() {
) : (
-
-
Connections
-
+
+
+
{translate("Connections")}
+ {selectedConnectionIds.length > 0 && (
+
{selectedConnectionIds.length} {translate("selected for bulk actions")}
+ )}
+
+
{connections.length > 0 && proxyPools.length > 0 && (
setShowBulkProxyModal(true)}
>
- Apply Proxy
+ {translate("Apply Proxy")}
)}
{connections.length > 1 && (
@@ -1461,7 +1502,7 @@ export default function ProviderDetailPage() {
disabled={selectedConnectionIds.length === 0 && !connections.some(c => c.isActive === false)}
className="w-full sm:w-auto"
>
- Delete
+ {translate("Delete")}
)}
{connections.length > 0 && (
@@ -1474,7 +1515,7 @@ export default function ProviderDetailPage() {
disabled={oneByOneRunning}
className="w-full sm:w-auto whitespace-nowrap"
>
- {oneByOneRunning ? "Testing..." : "Test each"}
+ {oneByOneRunning ? translate("Testing...") : translate("Test each")}
{oneByOneRunning && (
- {oneByOneStopping ? "Stopping..." : "Stop"}
+ {oneByOneStopping ? translate("Stopping...") : translate("Stop")}
)}
>
@@ -1510,22 +1551,23 @@ export default function ProviderDetailPage() {
className={`flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs transition-colors ${providerAutoDeactivate ? "border-red-500 bg-red-500/10 text-red-500" : "border-border text-text-muted hover:text-red-500"}`}
>
do_not_disturb_on
- Error → inactive
+ {translate("Error → inactive")}
)}
{/* Round Robin toggle */}
-
Round Robin
+
{translate("Round Robin")}
{providerStrategy === "round-robin" && (
- Sticky:
+ {translate("Sticky:")}
handleStickyLimitChange(e.target.value)}
placeholder="1"
@@ -1544,10 +1586,10 @@ export default function ProviderDetailPage() {
{isOAuth ? "lock" : "key"}
-
No connections yet
+
{translate("No connections yet")}
{hasDualAuthModes && (
- Choose {oauthConnectionLabel} or {apiKeyConnectionLabel}.
+ {translate("Choose")} {oauthConnectionLabel} {translate("or")} {apiKeyConnectionLabel}.
)}
@@ -1579,7 +1621,7 @@ export default function ProviderDetailPage() {
icon="add"
onClick={triggerAddConnection}
>
- {isCompatible ? "Add API Key" : (providerId === "iflow" ? "OAuth" : "Add Connection")}
+ {translate(isCompatible ? "Add API Key" : (providerId === "iflow" ? "OAuth" : "Add Connection"))}
>
)}
@@ -1590,30 +1632,57 @@ export default function ProviderDetailPage() {
{oneByOneSummary && (
- Total: {oneByOneSummary.total}
- Completed: {oneByOneSummary.completed}
- Passed: {oneByOneSummary.passed}
- Failed: {oneByOneSummary.failed}
+ {translate("Total")}: {oneByOneSummary.total}
+ {translate("Completed")}: {oneByOneSummary.completed}
+ {translate("Passed")}: {oneByOneSummary.passed}
+ {translate("Failed")}: {oneByOneSummary.failed}
{oneByOneSummary.stopped && (
- Stopped
+ {translate("Stopped")}
)}
{oneByOneRunning && oneByOneCurrentConnectionId && (
- Running: {connections.find((conn) => conn.id === oneByOneCurrentConnectionId)?.name || oneByOneCurrentConnectionId}
+ {translate("Running")}: {connections.find((conn) => conn.id === oneByOneCurrentConnectionId)?.name || oneByOneCurrentConnectionId}
)}
)}
{connections.length > 0 && (
-
-
+
)}
{connectionsList}
@@ -1684,7 +1753,7 @@ export default function ProviderDetailPage() {
- {"Available Models"}
+ {translate("Available Models")}
{!isCompatible && (() => {
const allIds = [
@@ -1696,12 +1765,12 @@ export default function ProviderDetailPage() {
{disabledModelIds.length > 0 && (
- Active All
+ {translate("Active All")}
)}
{activeIds.length > 0 && (
handleDisableAll(activeIds)}>
- Disable All
+ {translate("Disable All")}
)}
diff --git a/app/src/app/(dashboard)/dashboard/providers/[id]/providerPageUtils.js b/app/src/app/(dashboard)/dashboard/providers/[id]/providerPageUtils.js
new file mode 100644
index 00000000..d123a5fe
--- /dev/null
+++ b/app/src/app/(dashboard)/dashboard/providers/[id]/providerPageUtils.js
@@ -0,0 +1,29 @@
+export function shouldShowFetchModelsButton() {
+ return true;
+}
+
+export function canTestProviderConnection(connection, { isOAuth = false } = {}) {
+ const authType = connection?.authType || (isOAuth ? "oauth" : "apikey");
+ return authType === "apikey" || authType === "api_key" || authType === "cookie";
+}
+
+export { PROXY_ROTATION_INTERVAL_OPTIONS } from "@/shared/constants/proxyRotation.js";
+
+export function cleanProviderSpecificData(data = {}) {
+ const cleaned = { ...data };
+ for (const [key, value] of Object.entries(cleaned)) {
+ if (value === null || value === undefined || value === "") delete cleaned[key];
+ }
+ return cleaned;
+}
+
+export function buildProviderSpecificDataPatch(current = {}, patch = {}) {
+ return cleanProviderSpecificData({ ...current, ...patch });
+}
+
+export function buildRotationPatch({ intervalMinutes, rotateOnError }) {
+ return {
+ autoRotateProxyMinutes: intervalMinutes,
+ autoRotateProxyOnError: rotateOnError,
+ };
+}
diff --git a/app/src/app/(dashboard)/dashboard/proxy-pools/page.js b/app/src/app/(dashboard)/dashboard/proxy-pools/page.js
index eb466a58..84b240d9 100644
--- a/app/src/app/(dashboard)/dashboard/proxy-pools/page.js
+++ b/app/src/app/(dashboard)/dashboard/proxy-pools/page.js
@@ -1,1085 +1,1190 @@
-"use client";
-
-import { useCallback, useEffect, useMemo, useState, useRef } from "react";
-import { Badge, Button, Card, CardSkeleton, Input, Modal, Toggle, ConfirmModal } from "@/shared/components";
-import { useNotificationStore } from "@/store/notificationStore";
-
-function parseProxyLine(line) {
- const trimmed = line.trim();
- if (!trimmed) return null;
- if (trimmed.includes("://")) {
- const parsed = new URL(trimmed);
- const hostLabel = parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname;
- return { proxyUrl: parsed.toString(), name: `Imported ${hostLabel}` };
- }
- const parts = trimmed.split(":");
- if (parts.length === 4) {
- const [host, port, username, password] = parts;
- if (!host || !port || !username || !password) throw new Error("Invalid host:port:user:pass format");
- const proxyUrl = `http://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}`;
- const parsed = new URL(proxyUrl);
- return { proxyUrl: parsed.toString(), name: `Imported ${host}:${port}` };
- }
- throw new Error("Unsupported format");
-}
-
-function getStatusVariant(status) {
- if (status === "active") return "success";
- if (status === "error") return "error";
- return "default";
-}
-
-function formatDateTime(value) {
- if (!value) return "Never";
- const date = new Date(value);
- if (Number.isNaN(date.getTime())) return "Never";
- return date.toLocaleString();
-}
-
-function normalizeFormData(data = {}) {
- return {
- name: data.name || "",
- proxyUrl: data.proxyUrl || "",
- noProxy: data.noProxy || "",
- isActive: data.isActive !== false,
- strictProxy: data.strictProxy === true,
- };
-}
-
-const VERCEL_TOKEN_HINT = <>Token is used once for deployment and not stored.
Get token → >;
-const CF_TOKEN_HINT = <>Requires "Workers Scripts: Edit" permission.
Get token → >;
-
-export default function ProxyPoolsPage() {
- const [proxyPools, setProxyPools] = useState([]);
- const [loading, setLoading] = useState(true);
- const [showFormModal, setShowFormModal] = useState(false);
- const [showBatchImportModal, setShowBatchImportModal] = useState(false);
- const [showVercelModal, setShowVercelModal] = useState(false);
- const [showCloudflareModal, setShowCloudflareModal] = useState(false);
- const [showDenoModal, setShowDenoModal] = useState(false);
- const [showRelayMenu, setShowRelayMenu] = useState(false);
- const [editingProxyPool, setEditingProxyPool] = useState(null);
- const [formData, setFormData] = useState(() => normalizeFormData());
- const [batchImportText, setBatchImportText] = useState("");
- const [vercelForm, setVercelForm] = useState({ vercelToken: "", projectName: "vercel-relay" });
- const [cloudflareForm, setCloudflareForm] = useState({ accountId: "", apiToken: "", projectName: "cloudflare-relay" });
- const [denoForm, setDenoForm] = useState({ denoToken: "", orgDomain: "", projectName: "" });
- const [saving, setSaving] = useState(false);
- const [importing, setImporting] = useState(false);
- const [deploying, setDeploying] = useState(false);
- const [testingId, setTestingId] = useState(null);
- const [selectedIds, setSelectedIds] = useState([]);
- const [healthChecking, setHealthChecking] = useState(false);
- const [healthProgress, setHealthProgress] = useState({ current: 0, total: 0 });
- const [bulkBusy, setBulkBusy] = useState(false);
- const [confirmState, setConfirmState] = useState(null);
- const relayMenuRef = useRef(null);
- const notify = useNotificationStore();
-
- useEffect(() => {
- const handleClickOutside = (e) => {
- if (relayMenuRef.current && !relayMenuRef.current.contains(e.target)) {
- setShowRelayMenu(false);
- }
- };
- if (showRelayMenu) {
- document.addEventListener("mousedown", handleClickOutside);
- }
- return () => document.removeEventListener("mousedown", handleClickOutside);
- }, [showRelayMenu]);
-
- const fetchProxyPools = useCallback(async () => {
- try {
- const res = await fetch("/api/proxy-pools?includeUsage=true", { cache: "no-store" });
- const data = await res.json();
- if (res.ok) {
- setProxyPools(data.proxyPools || []);
- }
- } catch (error) {
- console.log("Error fetching proxy pools:", error);
- } finally {
- setLoading(false);
- }
- }, []);
-
- useEffect(() => {
- fetchProxyPools();
- }, [fetchProxyPools]);
-
- const resetForm = () => {
- setEditingProxyPool(null);
- setFormData(normalizeFormData());
- };
-
- const openCreateModal = () => {
- resetForm();
- setShowFormModal(true);
- };
-
- const openEditModal = (proxyPool) => {
- setEditingProxyPool(proxyPool);
- setFormData(normalizeFormData(proxyPool));
- setShowFormModal(true);
- };
-
- const closeFormModal = () => {
- setShowFormModal(false);
- resetForm();
- };
-
- const handleSave = async () => {
- const payload = {
- name: formData.name.trim(),
- proxyUrl: formData.proxyUrl.trim(),
- noProxy: formData.noProxy.trim(),
- isActive: formData.isActive === true,
- strictProxy: formData.strictProxy === true,
- };
-
- if (!payload.name || !payload.proxyUrl) return;
-
- setSaving(true);
- try {
- const isEdit = !!editingProxyPool;
- const res = await fetch(isEdit ? `/api/proxy-pools/${editingProxyPool.id}` : "/api/proxy-pools", {
- method: isEdit ? "PUT" : "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- });
-
- if (res.ok) {
- await fetchProxyPools();
- closeFormModal();
- notify.success(editingProxyPool ? "Proxy pool updated" : "Proxy pool created");
- } else {
- const data = await res.json();
- notify.error(data.error || "Failed to save proxy pool");
- }
- } catch (error) {
- console.log("Error saving proxy pool:", error);
- } finally {
- setSaving(false);
- }
- };
-
- const handleDelete = async (proxyPool) => {
- setConfirmState({
- title: "Delete Proxy Pool",
- message: `Delete proxy pool "${proxyPool.name}"?`,
- onConfirm: async () => {
- setConfirmState(null);
- try {
- const res = await fetch(`/api/proxy-pools/${proxyPool.id}`, { method: "DELETE" });
- if (res.ok) {
- setProxyPools((prev) => prev.filter((item) => item.id !== proxyPool.id));
- notify.success("Proxy pool deleted");
- return;
- }
-
- const data = await res.json();
- if (res.status === 409) {
- notify.warning(`Cannot delete: ${data.boundConnectionCount || 0} connection(s) are still using this pool.`);
- } else {
- notify.error(data.error || "Failed to delete proxy pool");
- }
- } catch (error) {
- console.log("Error deleting proxy pool:", error);
- notify.error("Failed to delete proxy pool");
- }
- }
- });
- };
-
- const handleTest = async (proxyPoolId) => {
- setTestingId(proxyPoolId);
- try {
- const res = await fetch(`/api/proxy-pools/${proxyPoolId}/test`, { method: "POST" });
- const data = await res.json();
-
- if (!res.ok) {
- notify.error(data.error || "Failed to test proxy");
- return;
- }
-
- await fetchProxyPools();
- notify.success(data.ok ? "Proxy test passed" : "Proxy test failed");
- } catch (error) {
- console.log("Error testing proxy pool:", error);
- notify.error("Failed to test proxy");
- } finally {
- setTestingId(null);
- }
- };
-
- const handleToggleActive = async (pool) => {
- const next = !pool.isActive;
- setProxyPools((prev) => prev.map((p) => p.id === pool.id ? { ...p, isActive: next } : p));
- try {
- const res = await fetch(`/api/proxy-pools/${pool.id}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ isActive: next }),
- });
- if (!res.ok) {
- setProxyPools((prev) => prev.map((p) => p.id === pool.id ? { ...p, isActive: pool.isActive } : p));
- notify.error("Failed to update active state");
- }
- } catch (error) {
- console.log("Error toggling active:", error);
- setProxyPools((prev) => prev.map((p) => p.id === pool.id ? { ...p, isActive: pool.isActive } : p));
- }
- };
-
- const validSelectedIds = selectedIds.filter((id) => proxyPools.some((p) => p.id === id));
- const allSelected = proxyPools.length > 0 && validSelectedIds.length === proxyPools.length;
- const toggleSelect = (id) => setSelectedIds((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]);
- const toggleSelectAll = () => setSelectedIds(allSelected ? [] : proxyPools.map((p) => p.id));
- const clearSelection = () => setSelectedIds([]);
-
- const bulkSetActive = async (isActive) => {
- const targets = selectedIds.length > 0 ? selectedIds : proxyPools.map((p) => p.id);
- if (targets.length === 0) return;
- setBulkBusy(true);
- try {
- const results = await Promise.all(targets.map(async (id) => {
- try {
- const res = await fetch(`/api/proxy-pools/${id}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ isActive }),
- });
- return res.ok ? "ok" : "fail";
- } catch { return "fail"; }
- }));
- const ok = results.filter(r => r === "ok").length;
- const failed = results.filter(r => r === "fail").length;
- await fetchProxyPools();
- notify.success(`${isActive ? "Activated" : "Deactivated"} ${ok}${failed ? `, failed ${failed}` : ""}`);
- } finally {
- setBulkBusy(false);
- }
- };
-
- const deleteProxyPools = async (ids) => {
- const results = await Promise.all(ids.map(async (id) => {
- try {
- const res = await fetch(`/api/proxy-pools/${id}`, { method: "DELETE" });
- if (res.ok) return "ok";
- if (res.status === 409) return "blocked";
- return "fail";
- } catch { return "fail"; }
- }));
- const ok = results.filter(r => r === "ok").length;
- const blocked = results.filter(r => r === "blocked").length;
- const failed = results.filter(r => r === "fail").length;
- await fetchProxyPools();
- clearSelection();
- notify.success(`Deleted ${ok}${blocked ? `, ${blocked} bound` : ""}${failed ? `, ${failed} failed` : ""}`);
- };
-
- const bulkDelete = async () => {
- if (selectedIds.length === 0) return;
- setConfirmState({
- title: "Delete Proxy Pools",
- message: `Delete ${selectedIds.length} proxy pool(s)?`,
- onConfirm: async () => {
- setConfirmState(null);
- setBulkBusy(true);
- try {
- await deleteProxyPools(selectedIds);
- } finally {
- setBulkBusy(false);
- }
- }
- });
- };
-
- const bulkDeleteInactive = async () => {
- const inactiveIds = proxyPools.filter((pool) => pool.isActive !== true).map((pool) => pool.id);
- if (inactiveIds.length === 0) return;
- setConfirmState({
- title: "Delete Inactive Proxy Pools",
- message: `Delete ${inactiveIds.length} inactive proxy pool(s)?`,
- onConfirm: async () => {
- setConfirmState(null);
- setBulkBusy(true);
- try {
- await deleteProxyPools(inactiveIds);
- } finally {
- setBulkBusy(false);
- }
- }
- });
- };
-
- const handleHealthCheck = async () => {
- const targets = selectedIds.length > 0
- ? proxyPools.filter((p) => selectedIds.includes(p.id))
- : proxyPools;
- if (targets.length === 0) return;
- setHealthChecking(true);
- setHealthProgress({ current: 0, total: targets.length });
- let alive = 0; const deadIds = [];
- let done = 0;
- const CONCURRENCY = 10;
- const queue = [...targets];
-
- const worker = async () => {
- while (queue.length > 0) {
- const pool = queue.shift();
- if (!pool) break;
- try {
- const res = await fetch(`/api/proxy-pools/${pool.id}/test`, { method: "POST" });
- const data = await res.json();
- if (res.ok && data.ok) alive += 1; else deadIds.push(pool.id);
- } catch {
- deadIds.push(pool.id);
- } finally {
- done += 1;
- setHealthProgress({ current: done, total: targets.length });
- }
- }
- };
-
- await Promise.all(Array.from({ length: Math.min(CONCURRENCY, targets.length) }, worker));
- await fetchProxyPools();
- setHealthChecking(false);
- setHealthProgress({ current: 0, total: 0 });
-
- if (deadIds.length > 0) {
- setConfirmState({
- title: "Disable Dead Proxies",
- message: `Alive: ${alive}, Dead: ${deadIds.length}.\n\nDisable ${deadIds.length} dead proxies?`,
- onConfirm: async () => {
- setConfirmState(null);
- setBulkBusy(true);
- try {
- await Promise.all(deadIds.map(id =>
- fetch(`/api/proxy-pools/${id}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ isActive: false }),
- }).catch(() => {})
- ));
- await fetchProxyPools();
- notify.success(`Disabled ${deadIds.length} dead proxies`);
- } finally {
- setBulkBusy(false);
- }
- }
- });
- } else {
- notify.success(`Health check done. Alive: ${alive}, Dead: ${deadIds.length}`);
- }
- };
-
- const openBatchImportModal = () => {
- setBatchImportText("");
- setShowBatchImportModal(true);
- };
-
- const closeBatchImportModal = () => {
- if (importing) return;
- setShowBatchImportModal(false);
- };
-
- const openVercelModal = () => {
- setVercelForm({ vercelToken: "", projectName: "vercel-relay" });
- setShowVercelModal(true);
- };
-
- const closeVercelModal = () => {
- if (deploying) return;
- setShowVercelModal(false);
- };
-
- const openCloudflareModal = () => {
- setCloudflareForm({ accountId: "", apiToken: "", projectName: "cloudflare-relay" });
- setShowCloudflareModal(true);
- };
-
- const closeCloudflareModal = () => {
- if (deploying) return;
- setShowCloudflareModal(false);
- };
-
- const openDenoModal = () => {
- setDenoForm({ denoToken: "", orgDomain: "", projectName: "" });
- setShowDenoModal(true);
- };
-
- const closeDenoModal = () => {
- if (deploying) return;
- setShowDenoModal(false);
- };
-
- const handleVercelDeploy = async () => {
- if (!vercelForm.vercelToken.trim()) return;
- setDeploying(true);
- try {
- const res = await fetch("/api/proxy-pools/vercel-deploy", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(vercelForm),
- });
- const data = await res.json();
- if (res.ok) {
- await fetchProxyPools();
- closeVercelModal();
- notify.success(`Deployed: ${data.deployUrl}`);
- } else {
- notify.error(data.error || "Deploy failed");
- }
- } catch (error) {
- console.log("Error deploying Vercel relay:", error);
- notify.error("Deploy failed");
- } finally {
- setDeploying(false);
- }
- };
-
- const handleCloudflareDeploy = async () => {
- if (!cloudflareForm.accountId.trim() || !cloudflareForm.apiToken.trim()) return;
- setDeploying(true);
- try {
- const res = await fetch("/api/proxy-pools/cloudflare-deploy", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(cloudflareForm),
- });
- const data = await res.json();
- if (res.ok) {
- await fetchProxyPools();
- closeCloudflareModal();
- notify.success(`Deployed: ${data.deployUrl}`);
- } else {
- notify.error(data.error || "Deploy failed");
- }
- } catch (error) {
- console.log("Error deploying Cloudflare relay:", error);
- notify.error("Deploy failed");
- } finally {
- setDeploying(false);
- }
- };
-
- const handleDenoDeploy = async () => {
- if (!denoForm.denoToken.trim()) return;
- setDeploying(true);
- try {
- const res = await fetch("/api/proxy-pools/deno-deploy", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(denoForm),
- });
- const data = await res.json();
- if (res.ok) {
- await fetchProxyPools();
- closeDenoModal();
- notify.success(`Deployed: ${data.deployUrl}`);
- } else {
- notify.error(data.error || "Deploy failed");
- }
- } catch (error) {
- console.log("Error deploying Deno relay:", error);
- notify.error("Deploy failed");
- } finally {
- setDeploying(false);
- }
- };
-
- const handleBatchImport = async () => {
- const lines = batchImportText
- .split(/\r?\n/)
- .flatMap((line) => { const t = line.trim(); return t ? [t] : []; });
-
- if (lines.length === 0) {
- notify.warning("Please paste at least one proxy line.");
- return;
- }
-
- const parsedEntries = [];
- const invalidLines = [];
-
- lines.forEach((line, index) => {
- try {
- const parsed = parseProxyLine(line);
- if (parsed) {
- parsedEntries.push({
- ...parsed,
- lineNumber: index + 1,
- });
- }
- } catch (error) {
- invalidLines.push(`Line ${index + 1}: ${error.message}`);
- }
- });
-
- if (invalidLines.length > 0) {
- notify.error(`Invalid proxy format:\n${invalidLines.join("\n")}`);
- return;
- }
-
- setImporting(true);
- try {
- const existingKeys = new Set(
- proxyPools.map((pool) => `${(pool.proxyUrl || "").trim()}|||${(pool.noProxy || "").trim()}`)
- );
-
- let created = 0;
- let skipped = 0;
- let failed = 0;
-
- const toCreate = parsedEntries.filter(entry => {
- const dedupeKey = `${entry.proxyUrl}|||`;
- if (existingKeys.has(dedupeKey)) { skipped += 1; return false; }
- return true;
- });
-
- const results = await Promise.all(toCreate.map(async (entry) => {
- const res = await fetch("/api/proxy-pools", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- name: entry.name,
- proxyUrl: entry.proxyUrl,
- noProxy: "",
- isActive: true,
- }),
- });
- return res.ok;
- }));
-
- for (const ok of results) {
- if (ok) created += 1; else failed += 1;
- }
-
- await fetchProxyPools();
- setShowBatchImportModal(false);
- notify.success(`Batch import completed: Created ${created}, Skipped ${skipped}, Failed ${failed}`);
- } catch (error) {
- console.log("Error batch importing proxies:", error);
- notify.error("Batch import failed");
- } finally {
- setImporting(false);
- }
- };
-
- const activeCount = useMemo(
- () => proxyPools.filter((pool) => pool.isActive === true).length,
- [proxyPools]
- );
- const inactiveCount = proxyPools.length - activeCount;
-
- if (loading) {
- return (
-
-
-
-
- );
- }
-
- return (
-
-
-
-
Proxy Pools
-
-
-
-
-
setShowRelayMenu(!showRelayMenu)}
- >
- Deploy Relay
-
- {showRelayMenu ? "expand_less" : "expand_more"}
-
-
-
- {showRelayMenu && (
-
- {
- openCloudflareModal();
- setShowRelayMenu(false);
- }}
- className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-text-main transition-colors hover:bg-black/5 dark:hover:bg-white/5"
- >
- cloud
- Cloudflare Relay
-
- {
- openVercelModal();
- setShowRelayMenu(false);
- }}
- className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-text-main transition-colors hover:bg-black/5 dark:hover:bg-white/5"
- >
- cloud_upload
- Vercel Relay
-
- {
- openDenoModal();
- setShowRelayMenu(false);
- }}
- className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-text-main transition-colors hover:bg-black/5 dark:hover:bg-white/5"
- >
- terminal
- Deno Relay
-
-
- )}
-
-
-
- Batch Import
-
-
Add Proxy Pool
-
-
-
-
-
- {proxyPools.length > 0 && (
-
-
- {allSelected ? "Unselect all" : "Select all"}
-
- )}
- Total: {proxyPools.length}
- Active: {activeCount}
- Inactive: {inactiveCount}
- {inactiveCount > 0 && (
-
- Delete Inactive
-
- )}
-
-
- {(selectedIds.length > 0 || healthChecking) && (
-
-
checklist
-
- {selectedIds.length > 0 ? `${selectedIds.length} selected` : "All pools"}
-
-
-
- {healthChecking ? `Checking ${healthProgress.current}/${healthProgress.total}` : "Health Check"}
-
- {selectedIds.length > 0 && (
- <>
- bulkSetActive(true)} disabled={bulkBusy || healthChecking}>
- Activate
-
- bulkSetActive(false)} disabled={bulkBusy || healthChecking}>
- Deactivate
-
-
- Delete
-
-
- Clear
-
- >
- )}
-
-
- )}
-
- {proxyPools.length === 0 ? (
-
-
No proxy pool entries yet
-
- Create a proxy pool entry, then assign it to connections.
-
-
Add Proxy Pool
-
- ) : (
-
- {proxyPools.map((pool) => (
-
-
-
toggleSelect(pool.id)}
- aria-label={`Select proxy ${pool.name || pool.id}`}
- className="mt-1 size-4 shrink-0 rounded border-black/20 dark:border-white/20"
- />
-
-
-
{pool.name}
-
- {pool.testStatus || "unknown"}
-
-
- {pool.isActive ? "active" : "inactive"}
-
- {pool.type === "vercel" && (
-
vercel relay
- )}
- {pool.type === "cloudflare" && (
-
cloudflare relay
- )}
-
- {pool.boundConnectionCount || 0} bound
-
-
-
{pool.proxyUrl}
- {pool.noProxy ? (
-
No proxy: {pool.noProxy}
- ) : null}
-
- Last tested: {formatDateTime(pool.lastTestedAt)}
- {pool.lastError ? ` · ${pool.lastError}` : ""}
-
-
-
-
-
- handleToggleActive(pool)}
- title={pool.isActive ? "Disable" : "Enable"}
- />
- handleTest(pool.id)}
- className="p-2 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-primary"
- title="Test proxy"
- disabled={testingId === pool.id}
- >
-
- {testingId === pool.id ? "progress_activity" : "science"}
-
-
- openEditModal(pool)}
- className="p-2 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-primary"
- title="Edit"
- >
- edit
-
- handleDelete(pool)}
- className="p-2 rounded hover:bg-red-500/10 text-red-500"
- title="Delete"
- >
- delete
-
-
-
- ))}
-
- )}
-
-
-
-
-
-
Paste Proxy List (One per line)
-
-
-
-
- {importing ? "Importing..." : "Import"}
-
-
- Cancel
-
-
-
-
-
-
-
-
-
What is Vercel Relay?
-
- Deploys an edge relay function to Vercel. All AI provider requests will be forwarded through Vercel's edge network, masking your real IP from providers.
-
-
- Your IP is replaced by Vercel's dynamic edge IPs (hundreds of IPs across 20+ global regions)
- Vercel serves millions of apps — providers can't block Vercel IPs without affecting legitimate traffic
- Free tier: 100GB bandwidth/month, 500K edge invocations
- Deploy multiple relays on different accounts for more IP diversity
-
-
-
setVercelForm((prev) => ({ ...prev, vercelToken: e.target.value }))}
- placeholder="your-vercel-api-token"
- hint={VERCEL_TOKEN_HINT}
- type="password"
- />
-
setVercelForm((prev) => ({ ...prev, projectName: e.target.value }))}
- placeholder="my-relay"
- hint="Unique name for your Vercel project. Leave empty for auto-generated name."
- />
-
-
- {deploying ? "Deploying... (may take ~1 min)" : "Deploy"}
-
-
- Cancel
-
-
-
-
-
-
-
-
-
What is Cloudflare Relay?
-
- Deploys a Cloudflare Worker as a proxy relay. All AI provider requests will be forwarded through Cloudflare's global edge network.
-
-
- High performance global routing and IP masking via Cloudflare Workers
- Free tier: 100,000 requests per day
- Requires Cloudflare Account ID and a Workers API Token (Edit Workers permission)
-
-
-
How to generate your API Token:
-
- Go to My Profile → API Tokens → Create Token
- Scroll down to Custom Token and click Get started
- Under Permissions : Account | Workers Scripts | Edit
- Under Account Resources : Include | Account | Your Account Name
- Click Continue to summary → Create Token
-
-
-
-
setCloudflareForm((prev) => ({ ...prev, accountId: e.target.value }))}
- placeholder="your-cloudflare-account-id"
- hint="Found on the right side of the Cloudflare dashboard overview page."
- />
-
setCloudflareForm((prev) => ({ ...prev, apiToken: e.target.value }))}
- placeholder="your-cloudflare-api-token"
- hint={CF_TOKEN_HINT}
- type="password"
- />
-
setCloudflareForm((prev) => ({ ...prev, projectName: e.target.value }))}
- placeholder="my-relay"
- hint="Unique name for your Cloudflare Worker. Leave empty for auto-generated name."
- />
-
-
- {deploying ? "Deploying..." : "Deploy Worker"}
-
-
- Cancel
-
-
-
-
-
-
-
-
-
What is Deno Relay?
-
- Deploys a relay worker to Deno Deploy's global edge network. All AI provider requests are forwarded through Deno's edge, masking your real IP.
-
-
- Deno Deploy v2 runs on a high-performance global edge network
- Free tier: 1M requests & 100GiB outbound traffic per month
- No per-request CPU time limits (unlike Vercel/Cloudflare)
- Support up to 20 active apps & 50 custom domains
- Deploy multiple relays for maximum IP diversity
-
-
-
How to generate API token:
-
- Go to console.deno.com
- Select your Organization → Settings → Organization Tokens
- Create a Organization Token (prefix ddo_ )
-
-
-
-
setDenoForm((prev) => ({ ...prev, denoToken: e.target.value }))}
- placeholder="ddo_xxxxxxxxxxxxxxxx"
- hint="Token is used once for deployment, not stored. Found in Organization Settings."
- type="password"
- />
-
setDenoForm((prev) => ({ ...prev, orgDomain: e.target.value }))}
- placeholder="your-org.deno.net"
- hint="Organization's default domain. Your relay URL will be in the format: https://my-relay.your-org.deno.net"
- />
-
setDenoForm((prev) => ({ ...prev, projectName: e.target.value }))}
- placeholder="deno-relay"
- hint="Unique app name. Leave empty for auto-generated name."
- />
-
-
- {deploying ? "Deploying..." : "Deploy Relay"}
-
-
- Cancel
-
-
-
-
-
-
-
-
setFormData((prev) => ({ ...prev, name: e.target.value }))}
- placeholder="Office Proxy"
- />
-
setFormData((prev) => ({ ...prev, proxyUrl: e.target.value }))}
- placeholder="http://127.0.0.1:7897"
- />
-
setFormData((prev) => ({ ...prev, noProxy: e.target.value }))}
- placeholder="localhost,127.0.0.1,.internal"
- hint="Comma-separated hosts/domains to bypass proxy"
- />
-
-
-
-
Active
-
Inactive pools are ignored by runtime resolution.
-
-
setFormData((prev) => ({ ...prev, isActive: !prev.isActive }))}
- disabled={saving}
- />
-
-
-
-
-
Strict Proxy
-
Fail request if proxy is unreachable instead of falling back to direct.
-
-
setFormData((prev) => ({ ...prev, strictProxy: !prev.strictProxy }))}
- disabled={saving}
- />
-
-
-
-
- {saving ? "Saving..." : "Save"}
-
-
- Cancel
-
-
-
-
-
- {/* Confirm Modal */}
-
setConfirmState(null)}
- onConfirm={confirmState?.onConfirm}
- title={confirmState?.title || "Confirm"}
- message={confirmState?.message}
- variant="danger"
- />
-
- );
-}
+"use client";
+
+import { useCallback, useEffect, useMemo, useState, useRef } from "react";
+import { Badge, Button, Card, CardSkeleton, Input, Modal, Toggle, ConfirmModal } from "@/shared/components";
+import { useNotificationStore } from "@/store/notificationStore";
+import {
+ extractProxyImportLines,
+ getSmartHealthDeleteMode,
+ getSmartHealthIntervalMs,
+ parseProxyLine,
+ SMART_HEALTH_CONCURRENCY,
+ SMART_HEALTH_DELETE_MODES,
+ SMART_HEALTH_INTERVAL_OPTIONS,
+ summarizeProxyHealthResults,
+} from "./utils";
+
+function getStatusVariant(status) {
+ if (status === "active") return "success";
+ if (status === "error") return "error";
+ return "default";
+}
+
+function formatDateTime(value) {
+ if (!value) return "Never";
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return "Never";
+ return date.toLocaleString();
+}
+
+function normalizeFormData(data = {}) {
+ return {
+ name: data.name || "",
+ proxyUrl: data.proxyUrl || "",
+ noProxy: data.noProxy || "",
+ isActive: data.isActive !== false,
+ strictProxy: data.strictProxy === true,
+ };
+}
+
+const VERCEL_TOKEN_HINT = <>Token is used once for deployment and not stored.
Get token → >;
+const CF_TOKEN_HINT = <>Requires "Workers Scripts: Edit" permission.
Get token → >;
+
+export default function ProxyPoolsPage() {
+ const [proxyPools, setProxyPools] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [showFormModal, setShowFormModal] = useState(false);
+ const [showBatchImportModal, setShowBatchImportModal] = useState(false);
+ const [showVercelModal, setShowVercelModal] = useState(false);
+ const [showCloudflareModal, setShowCloudflareModal] = useState(false);
+ const [showDenoModal, setShowDenoModal] = useState(false);
+ const [showRelayMenu, setShowRelayMenu] = useState(false);
+ const [editingProxyPool, setEditingProxyPool] = useState(null);
+ const [formData, setFormData] = useState(() => normalizeFormData());
+ const [batchImportText, setBatchImportText] = useState("");
+ const [vercelForm, setVercelForm] = useState({ vercelToken: "", projectName: "vercel-relay" });
+ const [cloudflareForm, setCloudflareForm] = useState({ accountId: "", apiToken: "", projectName: "cloudflare-relay" });
+ const [denoForm, setDenoForm] = useState({ denoToken: "", orgDomain: "", projectName: "" });
+ const [saving, setSaving] = useState(false);
+ const [importing, setImporting] = useState(false);
+ const [deploying, setDeploying] = useState(false);
+ const [testingId, setTestingId] = useState(null);
+ const [selectedIds, setSelectedIds] = useState([]);
+ const [healthChecking, setHealthChecking] = useState(false);
+ const [healthProgress, setHealthProgress] = useState({ current: 0, total: 0 });
+ const [smartHealthIntervalMinutes, setSmartHealthIntervalMinutes] = useState(0);
+ const [smartHealthDeleteMode, setSmartHealthDeleteMode] = useState("confirm");
+ const [bulkBusy, setBulkBusy] = useState(false);
+ const [confirmState, setConfirmState] = useState(null);
+ const relayMenuRef = useRef(null);
+ const notify = useNotificationStore();
+
+ useEffect(() => {
+ const handleClickOutside = (e) => {
+ if (relayMenuRef.current && !relayMenuRef.current.contains(e.target)) {
+ setShowRelayMenu(false);
+ }
+ };
+ if (showRelayMenu) {
+ document.addEventListener("mousedown", handleClickOutside);
+ }
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, [showRelayMenu]);
+
+ const fetchProxyPools = useCallback(async () => {
+ try {
+ const res = await fetch("/api/proxy-pools?includeUsage=true", { cache: "no-store" });
+ const data = await res.json();
+ if (res.ok) {
+ setProxyPools(data.proxyPools || []);
+ }
+ } catch (error) {
+ console.log("Error fetching proxy pools:", error);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchProxyPools();
+ }, [fetchProxyPools]);
+ useEffect(() => {
+ const saved = Number(localStorage.getItem("smartHealthIntervalMinutes") || 0);
+ if (getSmartHealthIntervalMs(saved) > 0) {
+ setSmartHealthIntervalMinutes(saved);
+ }
+ setSmartHealthDeleteMode(getSmartHealthDeleteMode(localStorage.getItem("smartHealthDeleteMode")));
+ }, []);
+
+ const resetForm = () => {
+ setEditingProxyPool(null);
+ setFormData(normalizeFormData());
+ };
+
+ const openCreateModal = () => {
+ resetForm();
+ setShowFormModal(true);
+ };
+
+ const openEditModal = (proxyPool) => {
+ setEditingProxyPool(proxyPool);
+ setFormData(normalizeFormData(proxyPool));
+ setShowFormModal(true);
+ };
+
+ const closeFormModal = () => {
+ setShowFormModal(false);
+ resetForm();
+ };
+
+ const handleSave = async () => {
+ const payload = {
+ name: formData.name.trim(),
+ proxyUrl: formData.proxyUrl.trim(),
+ noProxy: formData.noProxy.trim(),
+ isActive: formData.isActive === true,
+ strictProxy: formData.strictProxy === true,
+ };
+
+ if (!payload.name || !payload.proxyUrl) return;
+
+ setSaving(true);
+ try {
+ const isEdit = !!editingProxyPool;
+ const res = await fetch(isEdit ? `/api/proxy-pools/${editingProxyPool.id}` : "/api/proxy-pools", {
+ method: isEdit ? "PUT" : "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+
+ if (res.ok) {
+ await fetchProxyPools();
+ closeFormModal();
+ notify.success(editingProxyPool ? "Proxy pool updated" : "Proxy pool created");
+ } else {
+ const data = await res.json();
+ notify.error(data.error || "Failed to save proxy pool");
+ }
+ } catch (error) {
+ console.log("Error saving proxy pool:", error);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleDelete = async (proxyPool) => {
+ setConfirmState({
+ title: "Delete Proxy Pool",
+ message: `Delete proxy pool "${proxyPool.name}"?`,
+ onConfirm: async () => {
+ setConfirmState(null);
+ try {
+ const res = await fetch(`/api/proxy-pools/${proxyPool.id}`, { method: "DELETE" });
+ if (res.ok) {
+ setProxyPools((prev) => prev.filter((item) => item.id !== proxyPool.id));
+ notify.success("Proxy pool deleted");
+ return;
+ }
+
+ const data = await res.json();
+ if (res.status === 409) {
+ notify.warning(`Cannot delete: ${data.boundConnectionCount || 0} connection(s) are still using this pool.`);
+ } else {
+ notify.error(data.error || "Failed to delete proxy pool");
+ }
+ } catch (error) {
+ console.log("Error deleting proxy pool:", error);
+ notify.error("Failed to delete proxy pool");
+ }
+ }
+ });
+ };
+
+ const handleTest = async (proxyPoolId) => {
+ setTestingId(proxyPoolId);
+ try {
+ const res = await fetch(`/api/proxy-pools/${proxyPoolId}/test`, { method: "POST" });
+ const data = await res.json();
+
+ if (!res.ok) {
+ notify.error(data.error || "Failed to test proxy");
+ return;
+ }
+
+ await fetchProxyPools();
+ notify.success(data.ok ? "Proxy test passed" : "Proxy test failed");
+ } catch (error) {
+ console.log("Error testing proxy pool:", error);
+ notify.error("Failed to test proxy");
+ } finally {
+ setTestingId(null);
+ }
+ };
+
+ const handleToggleActive = async (pool) => {
+ const next = !pool.isActive;
+ setProxyPools((prev) => prev.map((p) => p.id === pool.id ? { ...p, isActive: next } : p));
+ try {
+ const res = await fetch(`/api/proxy-pools/${pool.id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ isActive: next }),
+ });
+ if (!res.ok) {
+ setProxyPools((prev) => prev.map((p) => p.id === pool.id ? { ...p, isActive: pool.isActive } : p));
+ notify.error("Failed to update active state");
+ }
+ } catch (error) {
+ console.log("Error toggling active:", error);
+ setProxyPools((prev) => prev.map((p) => p.id === pool.id ? { ...p, isActive: pool.isActive } : p));
+ }
+ };
+
+ const validSelectedIds = selectedIds.filter((id) => proxyPools.some((p) => p.id === id));
+ const allSelected = proxyPools.length > 0 && validSelectedIds.length === proxyPools.length;
+ const toggleSelect = (id) => setSelectedIds((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]);
+ const toggleSelectAll = () => setSelectedIds(allSelected ? [] : proxyPools.map((p) => p.id));
+ const clearSelection = () => setSelectedIds([]);
+
+ const bulkSetActive = async (isActive) => {
+ const targets = selectedIds.length > 0 ? selectedIds : proxyPools.map((p) => p.id);
+ if (targets.length === 0) return;
+ setBulkBusy(true);
+ try {
+ const results = await Promise.all(targets.map(async (id) => {
+ try {
+ const res = await fetch(`/api/proxy-pools/${id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ isActive }),
+ });
+ return res.ok ? "ok" : "fail";
+ } catch { return "fail"; }
+ }));
+ const ok = results.filter(r => r === "ok").length;
+ const failed = results.filter(r => r === "fail").length;
+ await fetchProxyPools();
+ notify.success(`${isActive ? "Activated" : "Deactivated"} ${ok}${failed ? `, failed ${failed}` : ""}`);
+ } finally {
+ setBulkBusy(false);
+ }
+ };
+
+ const deleteProxyPools = async (ids) => {
+ const results = await Promise.all(ids.map(async (id) => {
+ try {
+ const res = await fetch(`/api/proxy-pools/${id}`, { method: "DELETE" });
+ if (res.ok) return "ok";
+ if (res.status === 409) return "blocked";
+ return "fail";
+ } catch { return "fail"; }
+ }));
+ const ok = results.filter(r => r === "ok").length;
+ const blocked = results.filter(r => r === "blocked").length;
+ const failed = results.filter(r => r === "fail").length;
+ await fetchProxyPools();
+ clearSelection();
+ notify.success(`Deleted ${ok}${blocked ? `, ${blocked} bound` : ""}${failed ? `, ${failed} failed` : ""}`);
+ };
+
+ const bulkDelete = async () => {
+ if (selectedIds.length === 0) return;
+ setConfirmState({
+ title: "Delete Proxy Pools",
+ message: `Delete ${selectedIds.length} proxy pool(s)?`,
+ onConfirm: async () => {
+ setConfirmState(null);
+ setBulkBusy(true);
+ try {
+ await deleteProxyPools(selectedIds);
+ } finally {
+ setBulkBusy(false);
+ }
+ }
+ });
+ };
+
+ const bulkDeleteInactive = async () => {
+ const inactiveIds = proxyPools.filter((pool) => pool.isActive !== true).map((pool) => pool.id);
+ if (inactiveIds.length === 0) return;
+ setConfirmState({
+ title: "Delete Inactive Proxy Pools",
+ message: `Delete ${inactiveIds.length} inactive proxy pool(s)?`,
+ onConfirm: async () => {
+ setConfirmState(null);
+ setBulkBusy(true);
+ try {
+ await deleteProxyPools(inactiveIds);
+ } finally {
+ setBulkBusy(false);
+ }
+ }
+ });
+ };
+
+ const handleHealthCheck = async () => {
+ const targets = selectedIds.length > 0
+ ? proxyPools.filter((p) => selectedIds.includes(p.id))
+ : proxyPools;
+ if (targets.length === 0) return;
+ setHealthChecking(true);
+ setHealthProgress({ current: 0, total: targets.length });
+ const results = [];
+ let done = 0;
+ const queue = [...targets];
+
+ const worker = async () => {
+ while (queue.length > 0) {
+ const pool = queue.shift();
+ if (!pool) break;
+ try {
+ const res = await fetch(`/api/proxy-pools/${pool.id}/test`, { method: "POST" });
+ const data = await res.json();
+ results.push({ id: pool.id, ok: res.ok && data.ok });
+ } catch {
+ results.push({ id: pool.id, ok: false });
+ } finally {
+ done += 1;
+ setHealthProgress({ current: done, total: targets.length });
+ }
+ }
+ };
+
+ await Promise.all(Array.from({ length: Math.min(SMART_HEALTH_CONCURRENCY, targets.length) }, worker));
+ await fetchProxyPools();
+ setHealthChecking(false);
+ setHealthProgress({ current: 0, total: 0 });
+
+ const { alive, deadIds } = summarizeProxyHealthResults(results);
+ if (deadIds.length === 0) {
+ notify.success(`SmartHealth done. Alive: ${alive}, Dead: 0`);
+ return;
+ }
+
+ if (smartHealthDeleteMode === "auto") {
+ setBulkBusy(true);
+ try {
+ await deleteProxyPools(deadIds);
+ } finally {
+ setBulkBusy(false);
+ }
+ return;
+ }
+
+ if (smartHealthDeleteMode === "off") {
+ notify.warning(`SmartHealth done. Alive: ${alive}, Dead: ${deadIds.length}`);
+ return;
+ }
+
+ setConfirmState({
+ title: "SmartHealth",
+ message: `Alive: ${alive}, Dead: ${deadIds.length}.\n\nDelete ${deadIds.length} dead proxy pool(s)? Bound pools are skipped by the API.`,
+ onConfirm: async () => {
+ setConfirmState(null);
+ setBulkBusy(true);
+ try {
+ await deleteProxyPools(deadIds);
+ } finally {
+ setBulkBusy(false);
+ }
+ }
+ });
+ };
+
+ const updateSmartHealthInterval = (value) => {
+ const minutes = Number(value || 0);
+ const nextMinutes = getSmartHealthIntervalMs(minutes) > 0 ? minutes : 0;
+ setSmartHealthIntervalMinutes(nextMinutes);
+ if (nextMinutes > 0) {
+ localStorage.setItem("smartHealthIntervalMinutes", String(nextMinutes));
+ } else {
+ localStorage.removeItem("smartHealthIntervalMinutes");
+ }
+ };
+
+ const updateSmartHealthDeleteMode = (value) => {
+ const nextMode = getSmartHealthDeleteMode(value);
+ setSmartHealthDeleteMode(nextMode);
+ localStorage.setItem("smartHealthDeleteMode", nextMode);
+ };
+
+ useEffect(() => {
+ const intervalMs = getSmartHealthIntervalMs(smartHealthIntervalMinutes);
+ if (intervalMs === 0 || proxyPools.length === 0 || healthChecking || bulkBusy) return undefined;
+ const timer = setInterval(() => {
+ void handleHealthCheck();
+ }, intervalMs);
+ return () => clearInterval(timer);
+ }, [smartHealthIntervalMinutes, proxyPools.length, healthChecking, bulkBusy, smartHealthDeleteMode]);
+
+ const openBatchImportModal = () => {
+ setBatchImportText("");
+ setShowBatchImportModal(true);
+ };
+
+ const closeBatchImportModal = () => {
+ if (importing) return;
+ setShowBatchImportModal(false);
+ };
+
+ const handleImportFile = async (event) => {
+ const file = event.target.files?.[0];
+ event.target.value = "";
+ if (!file) return;
+ const extension = file.name.toLowerCase().split(".").pop();
+ if (!['json', 'txt'].includes(extension)) {
+ notify.error("Import only supports .json or .txt files");
+ return;
+ }
+ try {
+ const text = await file.text();
+ extractProxyImportLines(text);
+ setBatchImportText(text);
+ notify.success(`Loaded ${file.name}`);
+ } catch (error) {
+ notify.error(error.message || "Failed to read import file");
+ }
+ };
+
+ const openVercelModal = () => {
+ setVercelForm({ vercelToken: "", projectName: "vercel-relay" });
+ setShowVercelModal(true);
+ };
+
+ const closeVercelModal = () => {
+ if (deploying) return;
+ setShowVercelModal(false);
+ };
+
+ const openCloudflareModal = () => {
+ setCloudflareForm({ accountId: "", apiToken: "", projectName: "cloudflare-relay" });
+ setShowCloudflareModal(true);
+ };
+
+ const closeCloudflareModal = () => {
+ if (deploying) return;
+ setShowCloudflareModal(false);
+ };
+
+ const openDenoModal = () => {
+ setDenoForm({ denoToken: "", orgDomain: "", projectName: "" });
+ setShowDenoModal(true);
+ };
+
+ const closeDenoModal = () => {
+ if (deploying) return;
+ setShowDenoModal(false);
+ };
+
+ const handleVercelDeploy = async () => {
+ if (!vercelForm.vercelToken.trim()) return;
+ setDeploying(true);
+ try {
+ const res = await fetch("/api/proxy-pools/vercel-deploy", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(vercelForm),
+ });
+ const data = await res.json();
+ if (res.ok) {
+ await fetchProxyPools();
+ closeVercelModal();
+ notify.success(`Deployed: ${data.deployUrl}`);
+ } else {
+ notify.error(data.error || "Deploy failed");
+ }
+ } catch (error) {
+ console.log("Error deploying Vercel relay:", error);
+ notify.error("Deploy failed");
+ } finally {
+ setDeploying(false);
+ }
+ };
+
+ const handleCloudflareDeploy = async () => {
+ if (!cloudflareForm.accountId.trim() || !cloudflareForm.apiToken.trim()) return;
+ setDeploying(true);
+ try {
+ const res = await fetch("/api/proxy-pools/cloudflare-deploy", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(cloudflareForm),
+ });
+ const data = await res.json();
+ if (res.ok) {
+ await fetchProxyPools();
+ closeCloudflareModal();
+ notify.success(`Deployed: ${data.deployUrl}`);
+ } else {
+ notify.error(data.error || "Deploy failed");
+ }
+ } catch (error) {
+ console.log("Error deploying Cloudflare relay:", error);
+ notify.error("Deploy failed");
+ } finally {
+ setDeploying(false);
+ }
+ };
+
+ const handleDenoDeploy = async () => {
+ if (!denoForm.denoToken.trim()) return;
+ setDeploying(true);
+ try {
+ const res = await fetch("/api/proxy-pools/deno-deploy", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(denoForm),
+ });
+ const data = await res.json();
+ if (res.ok) {
+ await fetchProxyPools();
+ closeDenoModal();
+ notify.success(`Deployed: ${data.deployUrl}`);
+ } else {
+ notify.error(data.error || "Deploy failed");
+ }
+ } catch (error) {
+ console.log("Error deploying Deno relay:", error);
+ notify.error("Deploy failed");
+ } finally {
+ setDeploying(false);
+ }
+ };
+
+ const handleBatchImport = async () => {
+ let lines = [];
+ try {
+ lines = extractProxyImportLines(batchImportText);
+ } catch (error) {
+ notify.error(error.message || "Invalid import file");
+ return;
+ }
+
+ if (lines.length === 0) {
+ notify.warning("Please paste at least one proxy line.");
+ return;
+ }
+
+ const parsedEntries = [];
+ const invalidLines = [];
+
+ lines.forEach((line, index) => {
+ try {
+ const parsed = parseProxyLine(line);
+ if (parsed) {
+ parsedEntries.push({
+ ...parsed,
+ lineNumber: index + 1,
+ });
+ }
+ } catch (error) {
+ invalidLines.push(`Line ${index + 1}: ${error.message}`);
+ }
+ });
+
+ if (invalidLines.length > 0) {
+ notify.error(`Invalid proxy format:\n${invalidLines.join("\n")}`);
+ return;
+ }
+
+ setImporting(true);
+ try {
+ const existingKeys = new Set(
+ proxyPools.map((pool) => `${(pool.proxyUrl || "").trim()}|||${(pool.noProxy || "").trim()}`)
+ );
+
+ let created = 0;
+ let skipped = 0;
+ let failed = 0;
+
+ const toCreate = parsedEntries.filter(entry => {
+ const dedupeKey = `${entry.proxyUrl}|||`;
+ if (existingKeys.has(dedupeKey)) { skipped += 1; return false; }
+ return true;
+ });
+
+ const results = await Promise.all(toCreate.map(async (entry) => {
+ const res = await fetch("/api/proxy-pools", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: entry.name,
+ proxyUrl: entry.proxyUrl,
+ noProxy: "",
+ isActive: true,
+ }),
+ });
+ return res.ok;
+ }));
+
+ for (const ok of results) {
+ if (ok) created += 1; else failed += 1;
+ }
+
+ await fetchProxyPools();
+ setShowBatchImportModal(false);
+ notify.success(`Batch import completed: Created ${created}, Skipped ${skipped}, Failed ${failed}`);
+ } catch (error) {
+ console.log("Error batch importing proxies:", error);
+ notify.error("Batch import failed");
+ } finally {
+ setImporting(false);
+ }
+ };
+
+ const activeCount = useMemo(
+ () => proxyPools.filter((pool) => pool.isActive === true).length,
+ [proxyPools]
+ );
+ const inactiveCount = proxyPools.length - activeCount;
+
+ if (loading) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
Proxy Pools
+
+
+
+
+
Level 1 · Relay Deploy
+
Deploy proxy relay
+
+ Create a Cloudflare, Vercel, or Deno relay endpoint that proxy pools can use.
+
+
+
+
setShowRelayMenu(!showRelayMenu)}
+ className="w-full justify-center sm:w-auto"
+ aria-label="Deploy relay"
+ title="Deploy relay"
+ >
+ Deploy Relay
+
+
+ {showRelayMenu && (
+
+ {
+ openCloudflareModal();
+ setShowRelayMenu(false);
+ }}
+ className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-text-main transition-colors hover:bg-black/5 dark:hover:bg-white/5"
+ >
+ cloud
+ Cloudflare Relay
+
+ {
+ openVercelModal();
+ setShowRelayMenu(false);
+ }}
+ className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-text-main transition-colors hover:bg-black/5 dark:hover:bg-white/5"
+ >
+ cloud_upload
+ Vercel Relay
+
+ {
+ openDenoModal();
+ setShowRelayMenu(false);
+ }}
+ className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-text-main transition-colors hover:bg-black/5 dark:hover:bg-white/5"
+ >
+ terminal
+ Deno Relay
+
+
+ )}
+
+
+
+
+
Level 2 · SmartHealth
+
Check proxy health in parallel
+
+ Tests multiple proxies at once, schedules automatic checks, and lets you keep, confirm, or auto-delete dead proxies.
+
+
+
+ updateSmartHealthInterval(event.target.value)}
+ className="min-w-36 rounded-xl border border-border bg-surface px-3 py-2 text-sm text-text-main"
+ aria-label="SmartHealth schedule"
+ >
+ Manual
+ {SMART_HEALTH_INTERVAL_OPTIONS.map((option) => (
+ {option.label}
+ ))}
+
+ updateSmartHealthDeleteMode(event.target.value)}
+ className="min-w-44 rounded-xl border border-border bg-surface px-3 py-2 text-sm text-text-main"
+ aria-label="SmartHealth dead proxy action"
+ >
+ {SMART_HEALTH_DELETE_MODES.map((option) => (
+ {option.label}
+ ))}
+
+
+ {healthChecking ? `Checking ${healthProgress.current}/${healthProgress.total}` : "SmartHealth"}
+
+
+ Import
+
+ Add
+
+
+
+
+
+
+
+ {proxyPools.length > 0 && (
+
+
+ {allSelected ? "Unselect all" : "Select all"}
+
+ )}
+ Total: {proxyPools.length}
+ Active: {activeCount}
+ Inactive: {inactiveCount}
+ {inactiveCount > 0 && (
+
+ Delete Inactive
+
+ )}
+
+
+ {(selectedIds.length > 0 || healthChecking) && (
+
+
checklist
+
+ {selectedIds.length > 0 ? `${selectedIds.length} selected` : "All pools"}
+
+
+
+ {healthChecking ? `Checking ${healthProgress.current}/${healthProgress.total}` : "Health Check"}
+
+ {selectedIds.length > 0 && (
+ <>
+ bulkSetActive(true)} disabled={bulkBusy || healthChecking}>
+ Activate
+
+ bulkSetActive(false)} disabled={bulkBusy || healthChecking}>
+ Deactivate
+
+
+ Delete
+
+
+ Clear
+
+ >
+ )}
+
+
+ )}
+
+ {proxyPools.length === 0 ? (
+
+
No proxy pool entries yet
+
+ Create a proxy pool entry, then assign it to connections.
+
+
Add Proxy Pool
+
+ ) : (
+
+ {proxyPools.map((pool) => (
+
+
+
toggleSelect(pool.id)}
+ aria-label={`Select proxy ${pool.name || pool.id}`}
+ className="mt-1 size-4 shrink-0 rounded border-black/20 dark:border-white/20"
+ />
+
+
+
{pool.name}
+
+ {pool.testStatus || "unknown"}
+
+
+ {pool.isActive ? "active" : "inactive"}
+
+ {pool.type === "vercel" && (
+
vercel relay
+ )}
+ {pool.type === "cloudflare" && (
+
cloudflare relay
+ )}
+
+ {pool.boundConnectionCount || 0} bound
+
+
+
{pool.proxyUrl}
+ {pool.noProxy ? (
+
No proxy: {pool.noProxy}
+ ) : null}
+
+ Last tested: {formatDateTime(pool.lastTestedAt)}
+ {pool.lastError ? ` · ${pool.lastError}` : ""}
+
+
+
+
+
+ handleToggleActive(pool)}
+ title={pool.isActive ? "Disable" : "Enable"}
+ />
+ handleTest(pool.id)}
+ className="p-2 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-primary"
+ title="Test proxy"
+ disabled={testingId === pool.id}
+ >
+
+ {testingId === pool.id ? "progress_activity" : "science"}
+
+
+ openEditModal(pool)}
+ className="p-2 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-primary"
+ title="Edit"
+ >
+ edit
+
+ handleDelete(pool)}
+ className="p-2 rounded hover:bg-red-500/10 text-red-500"
+ title="Delete"
+ >
+ delete
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ Paste proxies or import JSON/TXT
+
+ Import file
+
+
+
+
+
+
+
+ {importing ? "Importing..." : "Import"}
+
+
+ Cancel
+
+
+
+
+
+
+
+
+
What is Vercel Relay?
+
+ Deploys an edge relay function to Vercel. All AI provider requests will be forwarded through Vercel's edge network, masking your real IP from providers.
+
+
+ Your IP is replaced by Vercel's dynamic edge IPs (hundreds of IPs across 20+ global regions)
+ Vercel serves millions of apps — providers can't block Vercel IPs without affecting legitimate traffic
+ Free tier: 100GB bandwidth/month, 500K edge invocations
+ Deploy multiple relays on different accounts for more IP diversity
+
+
+
setVercelForm((prev) => ({ ...prev, vercelToken: e.target.value }))}
+ placeholder="your-vercel-api-token"
+ hint={VERCEL_TOKEN_HINT}
+ type="password"
+ />
+
setVercelForm((prev) => ({ ...prev, projectName: e.target.value }))}
+ placeholder="my-relay"
+ hint="Unique name for your Vercel project. Leave empty for auto-generated name."
+ />
+
+
+ {deploying ? "Deploying... (may take ~1 min)" : "Deploy"}
+
+
+ Cancel
+
+
+
+
+
+
+
+
+
What is Cloudflare Relay?
+
+ Deploys a Cloudflare Worker as a proxy relay. All AI provider requests will be forwarded through Cloudflare's global edge network.
+
+
+ High performance global routing and IP masking via Cloudflare Workers
+ Free tier: 100,000 requests per day
+ Requires Cloudflare Account ID and a Workers API Token (Edit Workers permission)
+
+
+
How to generate your API Token:
+
+ Go to My Profile → API Tokens → Create Token
+ Scroll down to Custom Token and click Get started
+ Under Permissions : Account | Workers Scripts | Edit
+ Under Account Resources : Include | Account | Your Account Name
+ Click Continue to summary → Create Token
+
+
+
+
setCloudflareForm((prev) => ({ ...prev, accountId: e.target.value }))}
+ placeholder="your-cloudflare-account-id"
+ hint="Found on the right side of the Cloudflare dashboard overview page."
+ />
+
setCloudflareForm((prev) => ({ ...prev, apiToken: e.target.value }))}
+ placeholder="your-cloudflare-api-token"
+ hint={CF_TOKEN_HINT}
+ type="password"
+ />
+
setCloudflareForm((prev) => ({ ...prev, projectName: e.target.value }))}
+ placeholder="my-relay"
+ hint="Unique name for your Cloudflare Worker. Leave empty for auto-generated name."
+ />
+
+
+ {deploying ? "Deploying..." : "Deploy Worker"}
+
+
+ Cancel
+
+
+
+
+
+
+
+
+
What is Deno Relay?
+
+ Deploys a relay worker to Deno Deploy's global edge network. All AI provider requests are forwarded through Deno's edge, masking your real IP.
+
+
+ Deno Deploy v2 runs on a high-performance global edge network
+ Free tier: 1M requests & 100GiB outbound traffic per month
+ No per-request CPU time limits (unlike Vercel/Cloudflare)
+ Support up to 20 active apps & 50 custom domains
+ Deploy multiple relays for maximum IP diversity
+
+
+
How to generate API token:
+
+ Go to console.deno.com
+ Select your Organization → Settings → Organization Tokens
+ Create a Organization Token (prefix ddo_ )
+
+
+
+
setDenoForm((prev) => ({ ...prev, denoToken: e.target.value }))}
+ placeholder="ddo_xxxxxxxxxxxxxxxx"
+ hint="Token is used once for deployment, not stored. Found in Organization Settings."
+ type="password"
+ />
+
setDenoForm((prev) => ({ ...prev, orgDomain: e.target.value }))}
+ placeholder="your-org.deno.net"
+ hint="Organization's default domain. Your relay URL will be in the format: https://my-relay.your-org.deno.net"
+ />
+
setDenoForm((prev) => ({ ...prev, projectName: e.target.value }))}
+ placeholder="deno-relay"
+ hint="Unique app name. Leave empty for auto-generated name."
+ />
+
+
+ {deploying ? "Deploying..." : "Deploy Relay"}
+
+
+ Cancel
+
+
+
+
+
+
+
+
setFormData((prev) => ({ ...prev, name: e.target.value }))}
+ placeholder="Office Proxy"
+ />
+
setFormData((prev) => ({ ...prev, proxyUrl: e.target.value }))}
+ placeholder="http://127.0.0.1:7897"
+ />
+
setFormData((prev) => ({ ...prev, noProxy: e.target.value }))}
+ placeholder="localhost,127.0.0.1,.internal"
+ hint="Comma-separated hosts/domains to bypass proxy"
+ />
+
+
+
+
Active
+
Inactive pools are ignored by runtime resolution.
+
+
setFormData((prev) => ({ ...prev, isActive: !prev.isActive }))}
+ disabled={saving}
+ />
+
+
+
+
+
Strict Proxy
+
Fail request if proxy is unreachable instead of falling back to direct.
+
+
setFormData((prev) => ({ ...prev, strictProxy: !prev.strictProxy }))}
+ disabled={saving}
+ />
+
+
+
+
+ {saving ? "Saving..." : "Save"}
+
+
+ Cancel
+
+
+
+
+
+ {/* Confirm Modal */}
+
setConfirmState(null)}
+ onConfirm={confirmState?.onConfirm}
+ title={confirmState?.title || "Confirm"}
+ message={confirmState?.message}
+ variant="danger"
+ />
+
+ );
+}
diff --git a/app/src/app/(dashboard)/dashboard/proxy-pools/utils.js b/app/src/app/(dashboard)/dashboard/proxy-pools/utils.js
new file mode 100644
index 00000000..dc20aafe
--- /dev/null
+++ b/app/src/app/(dashboard)/dashboard/proxy-pools/utils.js
@@ -0,0 +1,85 @@
+export const SMART_HEALTH_INTERVAL_OPTIONS = [
+ { value: 15, label: "Every 15m" },
+ { value: 30, label: "Every 30m" },
+ { value: 60, label: "Every 1h" },
+ { value: 360, label: "Every 6h" },
+ { value: 720, label: "Every 12h" },
+ { value: 1440, label: "Every 24h" },
+];
+
+export const SMART_HEALTH_DELETE_MODES = [
+ { value: "confirm", label: "Ask before delete" },
+ { value: "auto", label: "Auto-delete dead" },
+ { value: "off", label: "Keep dead" },
+];
+
+export function getSmartHealthDeleteMode(value) {
+ return SMART_HEALTH_DELETE_MODES.some((option) => option.value === value) ? value : "confirm";
+}
+
+export function getSmartHealthIntervalMs(value) {
+ const minutes = Number(value || 0);
+ return SMART_HEALTH_INTERVAL_OPTIONS.some((option) => option.value === minutes) ? minutes * 60 * 1000 : 0;
+}
+
+export const SMART_HEALTH_CONCURRENCY = 25;
+
+export function summarizeProxyHealthResults(results) {
+ const deadIds = [];
+ let alive = 0;
+
+ for (const result of results) {
+ if (result.ok) {
+ alive += 1;
+ } else if (result.id) {
+ deadIds.push(result.id);
+ }
+ }
+
+ return { alive, deadIds };
+}
+
+export function parseProxyLine(line) {
+ const trimmed = line.trim();
+ if (!trimmed) return null;
+ if (trimmed.includes("://")) {
+ const parsed = new URL(trimmed);
+ const hostLabel = parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname;
+ return { proxyUrl: parsed.toString(), name: `Imported ${hostLabel}` };
+ }
+ const parts = trimmed.split(":");
+ if (parts.length === 4) {
+ const [host, port, username, password] = parts;
+ if (!host || !port || !username || !password) throw new Error("Invalid host:port:user:pass format");
+ const proxyUrl = `http://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}`;
+ const parsed = new URL(proxyUrl);
+ return { proxyUrl: parsed.toString(), name: `Imported ${host}:${port}` };
+ }
+ throw new Error("Unsupported format");
+}
+
+export function extractProxyImportLines(text) {
+ const trimmed = text.trim();
+ if (!trimmed) return [];
+
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
+ return trimmed.split(/\r?\n/).flatMap((line) => {
+ const value = line.trim();
+ return value ? [value] : [];
+ });
+ }
+
+ const parsed = JSON.parse(trimmed);
+ const items = Array.isArray(parsed) ? parsed : parsed.proxies;
+ if (!Array.isArray(items)) throw new Error("JSON must be an array or { proxies: [] }");
+
+ return items.flatMap((item) => {
+ if (typeof item === "string") return item.trim() ? [item.trim()] : [];
+ if (!item || typeof item !== "object") return [];
+ const proxyUrl = item.proxyUrl || item.url || item.proxy;
+ if (typeof proxyUrl === "string" && proxyUrl.trim()) return [proxyUrl.trim()];
+ const { host, port, username, password } = item;
+ if (host && port && username && password) return [`${host}:${port}:${username}:${password}`];
+ return [];
+ });
+}
diff --git a/app/src/app/(dashboard)/dashboard/token-saver/TokenSaverClient.js b/app/src/app/(dashboard)/dashboard/token-saver/TokenSaverClient.js
index 84cf1e8a..b06de625 100644
--- a/app/src/app/(dashboard)/dashboard/token-saver/TokenSaverClient.js
+++ b/app/src/app/(dashboard)/dashboard/token-saver/TokenSaverClient.js
@@ -9,10 +9,12 @@ import {
CAVEMAN_LEVELS,
TERSE_LEVELS,
PONYTAIL_LEVELS,
+ STYLE_INJECTION_METHODS,
} from "./tokenSaverConstants";
export default function TokenSaverClient() {
const [rtkEnabled, setRtkEnabledState] = useState(true);
+ const [webSearchSaverEnabled, setWebSearchSaverEnabled] = useState(true);
const [headroomEnabled, setHeadroomEnabled] = useState(false);
const [headroomUrl, setHeadroomUrl] = useState("http://localhost:8787");
const [headroomStatus, setHeadroomStatus] = useState({
@@ -31,6 +33,7 @@ export default function TokenSaverClient() {
const [cavemanLevel, setCavemanLevel] = useState("full");
const [ponytailEnabled, setPonytailEnabled] = useState(false);
const [ponytailLevel, setPonytailLevel] = useState("full");
+ const [styleInjectionMethod, setStyleInjectionMethod] = useState("old");
const [locale, setLocale] = useState("en");
const { copied, copy } = useCopyToClipboard();
@@ -55,32 +58,46 @@ export default function TokenSaverClient() {
const patchSetting = async (patch) => {
try {
- await fetch("/api/settings", {
+ const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
+ return res.ok;
} catch (error) {
console.log("Error updating setting:", error);
+ return false;
}
};
const handleRtkEnabled = async (value) => {
- try {
- const res = await fetch("/api/settings", {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ rtkEnabled: value }),
- });
- if (res.ok) setRtkEnabledState(value);
- } catch (error) {
- console.log("Error updating rtkEnabled:", error);
- }
+ const previous = rtkEnabled;
+ setRtkEnabledState(value);
+ const saved = await patchSetting({ rtkEnabled: value });
+ if (!saved) setRtkEnabledState(previous);
+ };
+
+ const handleWebSearchSaverEnabled = (value) => {
+ setWebSearchSaverEnabled(value);
+ patchSetting({ webSearchSaverEnabled: value });
+ };
+
+ const setExclusiveStyle = (style) => {
+ const nextTerseEnabled = style === "terse";
+ const nextCavemanEnabled = style === "caveman";
+ const nextPonytailEnabled = style === "ponytail";
+ setTerseEnabled(nextTerseEnabled);
+ setCavemanEnabled(nextCavemanEnabled);
+ setPonytailEnabled(nextPonytailEnabled);
+ patchSetting({
+ terseEnabled: nextTerseEnabled,
+ cavemanEnabled: nextCavemanEnabled,
+ ponytailEnabled: nextPonytailEnabled,
+ });
};
const handleTerseEnabled = (value) => {
- setTerseEnabled(value);
- patchSetting({ terseEnabled: value });
+ setExclusiveStyle(value ? "terse" : null);
};
const handleTerseLevel = (level) => {
@@ -89,8 +106,7 @@ export default function TokenSaverClient() {
};
const handleCavemanEnabled = (value) => {
- setCavemanEnabled(value);
- patchSetting({ cavemanEnabled: value });
+ setExclusiveStyle(value ? "caveman" : null);
};
const handleHeadroomEnabled = (value) => {
@@ -156,8 +172,7 @@ export default function TokenSaverClient() {
};
const handlePonytailEnabled = (value) => {
- setPonytailEnabled(value);
- patchSetting({ ponytailEnabled: value });
+ setExclusiveStyle(value ? "ponytail" : null);
};
const handlePonytailLevel = (level) => {
@@ -165,6 +180,11 @@ export default function TokenSaverClient() {
patchSetting({ ponytailLevel: level });
};
+ const handleStyleInjectionMethod = (method) => {
+ setStyleInjectionMethod(method);
+ patchSetting({ styleInjectionMethod: method });
+ };
+
useEffect(() => {
const loadSettings = async () => {
try {
@@ -172,14 +192,34 @@ export default function TokenSaverClient() {
if (res.ok) {
const data = await res.json();
setRtkEnabledState(data.rtkEnabled !== false);
+ setWebSearchSaverEnabled(data.webSearchSaverEnabled !== false);
setHeadroomEnabled(!!data.headroomEnabled);
setHeadroomUrl(data.headroomUrl || "http://localhost:8787");
- setTerseEnabled(!!data.terseEnabled);
+ const activeStyle = data.ponytailEnabled
+ ? "ponytail"
+ : data.cavemanEnabled
+ ? "caveman"
+ : data.terseEnabled
+ ? "terse"
+ : null;
+ setTerseEnabled(activeStyle === "terse");
+ setCavemanEnabled(activeStyle === "caveman");
+ setPonytailEnabled(activeStyle === "ponytail");
setTerseLevel(data.terseLevel || "medium");
- setCavemanEnabled(!!data.cavemanEnabled);
setCavemanLevel(data.cavemanLevel || "full");
- setPonytailEnabled(!!data.ponytailEnabled);
setPonytailLevel(data.ponytailLevel || "full");
+ if (
+ data.terseEnabled !== (activeStyle === "terse") ||
+ data.cavemanEnabled !== (activeStyle === "caveman") ||
+ data.ponytailEnabled !== (activeStyle === "ponytail")
+ ) {
+ patchSetting({
+ terseEnabled: activeStyle === "terse",
+ cavemanEnabled: activeStyle === "caveman",
+ ponytailEnabled: activeStyle === "ponytail",
+ });
+ }
+ setStyleInjectionMethod(data.styleInjectionMethod === "new" ? "new" : "old");
refreshHeadroomStatus();
}
} catch {}
@@ -213,6 +253,39 @@ export default function TokenSaverClient() {
Token Saver
+
+
+
+
Style injection method
+
+ Controls how Terse, Caveman, and Ponytail prompts are placed.
+
+
+
+ {styleInjectionMethod === "new" ? "Adaptive" : "Old"}
+
+
+
+ {STYLE_INJECTION_METHODS.map((method) => (
+
handleStyleInjectionMethod(method.id)}
+ className={`text-left rounded-lg border p-3 transition-colors ${
+ styleInjectionMethod === method.id
+ ? "border-primary bg-primary/10"
+ : "border-border bg-background hover:bg-surface-2"
+ }`}
+ >
+
+ {method.label}
+ {method.title}
+
+ {method.desc}
+
+ ))}
+
+
@@ -235,6 +308,18 @@ export default function TokenSaverClient() {
onChange={() => handleRtkEnabled(!rtkEnabled)}
/>
+
+
+
Web search saver
+
+ Injects query focus, top-5 rerank, cached summaries, citation-first output, and adaptive result budgets
+
+
+
handleWebSearchSaverEnabled(!webSearchSaverEnabled)}
+ />
+
diff --git a/app/src/app/(dashboard)/dashboard/token-saver/tokenSaverConstants.js b/app/src/app/(dashboard)/dashboard/token-saver/tokenSaverConstants.js
index 722ec437..dd3af693 100644
--- a/app/src/app/(dashboard)/dashboard/token-saver/tokenSaverConstants.js
+++ b/app/src/app/(dashboard)/dashboard/token-saver/tokenSaverConstants.js
@@ -20,3 +20,19 @@ export const PONYTAIL_LEVELS = [
{ id: "full", label: "Full", desc: "Ladder enforced: stdlib/native first" },
{ id: "ultra", label: "Ultra", desc: "YAGNI extremist, deletion first" },
];
+
+
+export const STYLE_INJECTION_METHODS = [
+ {
+ id: "old",
+ label: "Old",
+ title: "Current method",
+ desc: "Append style prompts to the existing system message, or create one at the top.",
+ },
+ {
+ id: "new",
+ label: "Adaptive",
+ title: "Adaptive placement",
+ desc: "Place OpenAI chat style prompts near the final user message; use reminders only where system-message placement is unsafe.",
+ },
+];
diff --git a/app/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/app/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js
index dfec68e3..d9b994ce 100644
--- a/app/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js
+++ b/app/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js
@@ -25,8 +25,11 @@ import {
getQuotaCache,
setQuotaCache,
QUOTA_CACHE_KEY,
- REFRESH_INTERVAL_MS,
- CLAUDE_REFRESH_INTERVAL_MS,
+ REFRESH_INTERVAL_STORAGE_KEY,
+ REFRESH_INTERVAL_OPTIONS,
+ DEFAULT_REFRESH_INTERVAL_MINUTES,
+ getRefreshIntervalSeconds,
+ shouldFetchQuotaOnTick,
DEPLETED_QUOTA_THRESHOLD,
AUTO_REFRESH_STORAGE_KEY,
CONNECTIONS_PAGE_SIZE,
@@ -94,11 +97,12 @@ export default function ProviderLimits() {
const [loading, setLoading] = useState({});
const [errors, setErrors] = useState({});
const [autoRefresh, setAutoRefresh] = useState(true);
+ const [refreshIntervalMinutes, setRefreshIntervalMinutes] = useState(DEFAULT_REFRESH_INTERVAL_MINUTES);
const [autoPingMap, setAutoPingMap] = useState({});
const [lastUpdated, setLastUpdated] = useState(null);
const [hasHydratedAutoRefresh, setHasHydratedAutoRefresh] = useState(false);
const [refreshingAll, setRefreshingAll] = useState(false);
- const [countdown, setCountdown] = useState(60);
+ const [countdown, setCountdown] = useState(getRefreshIntervalSeconds(DEFAULT_REFRESH_INTERVAL_MINUTES));
const [connectionsLoading, setConnectionsLoading] = useState(true);
const [deletingId, setDeletingId] = useState(null);
const [togglingId, setTogglingId] = useState(null);
@@ -134,6 +138,7 @@ export default function ProviderLimits() {
const countdownRef = useRef(null);
const tickCountRef = useRef(0);
+ const refreshIntervalSeconds = getRefreshIntervalSeconds(refreshIntervalMinutes);
const fetchConnections = useCallback(
async (targetPage = page) => {
try {
@@ -407,13 +412,11 @@ export default function ProviderLimits() {
if (refreshingAll) return;
setRefreshingAll(true);
- setCountdown(60);
+ setCountdown(refreshIntervalSeconds);
- // Throttle Claude: poll its quota every Nth auto-tick (manual force bypasses)
const tick = (tickCountRef.current += 1);
- const claudeEvery = Math.round(CLAUDE_REFRESH_INTERVAL_MS / REFRESH_INTERVAL_MS);
const shouldFetch = (conn) =>
- force || conn.provider !== "claude" || tick % claudeEvery === 0;
+ force || shouldFetchQuotaOnTick(conn, tick, refreshIntervalSeconds);
try {
const visibleConnections = await fetchConnections(page);
@@ -438,7 +441,7 @@ export default function ProviderLimits() {
} finally {
setRefreshingAll(false);
}
- }, [refreshingAll, fetchConnections, fetchQuota, page]);
+ }, [refreshingAll, refreshIntervalSeconds, fetchConnections, fetchQuota, page]);
useEffect(() => {
const initializeData = async () => {
@@ -466,16 +469,19 @@ export default function ProviderLimits() {
useEffect(() => {
if (typeof window === "undefined") return;
- const stored = window.localStorage.getItem(AUTO_REFRESH_STORAGE_KEY);
- setAutoRefresh(stored === null ? true : stored === "true");
+ const storedAutoRefresh = window.localStorage.getItem(AUTO_REFRESH_STORAGE_KEY);
+ const storedInterval = window.localStorage.getItem(REFRESH_INTERVAL_STORAGE_KEY);
+ setAutoRefresh(storedAutoRefresh === null ? true : storedAutoRefresh === "true");
+ setRefreshIntervalMinutes(storedInterval === null ? DEFAULT_REFRESH_INTERVAL_MINUTES : Number(storedInterval));
+ setCountdown(getRefreshIntervalSeconds(storedInterval === null ? DEFAULT_REFRESH_INTERVAL_MINUTES : storedInterval));
setHasHydratedAutoRefresh(true);
}, []);
- // Persist auto-refresh preference
useEffect(() => {
if (typeof window === "undefined" || !hasHydratedAutoRefresh) return;
window.localStorage.setItem(AUTO_REFRESH_STORAGE_KEY, String(autoRefresh));
- }, [autoRefresh, hasHydratedAutoRefresh]);
+ window.localStorage.setItem(REFRESH_INTERVAL_STORAGE_KEY, String(refreshIntervalMinutes));
+ }, [autoRefresh, refreshIntervalMinutes, hasHydratedAutoRefresh]);
// Load Claude auto-ping per-connection map
useEffect(() => {
@@ -516,15 +522,14 @@ export default function ProviderLimits() {
return;
}
- // Main refresh interval
+ setCountdown(refreshIntervalSeconds);
intervalRef.current = setInterval(() => {
refreshAll();
- }, REFRESH_INTERVAL_MS);
+ }, refreshIntervalSeconds * 1000);
- // Countdown interval
countdownRef.current = setInterval(() => {
setCountdown((prev) => {
- if (prev <= 1) return 60;
+ if (prev <= 1) return refreshIntervalSeconds;
return prev - 1;
});
}, 1000);
@@ -533,7 +538,7 @@ export default function ProviderLimits() {
if (intervalRef.current) clearInterval(intervalRef.current);
if (countdownRef.current) clearInterval(countdownRef.current);
};
- }, [autoRefresh, refreshAll, hasHydratedAutoRefresh]);
+ }, [autoRefresh, refreshAll, hasHydratedAutoRefresh, refreshIntervalSeconds]);
// Pause auto-refresh when tab is hidden (Page Visibility API)
useEffect(() => {
@@ -548,10 +553,10 @@ export default function ProviderLimits() {
countdownRef.current = null;
}
} else if (autoRefresh && hasHydratedAutoRefresh) {
- // Resume auto-refresh when tab becomes visible
- intervalRef.current = setInterval(() => refreshAll(), REFRESH_INTERVAL_MS);
+ setCountdown(refreshIntervalSeconds);
+ intervalRef.current = setInterval(() => refreshAll(), refreshIntervalSeconds * 1000);
countdownRef.current = setInterval(() => {
- setCountdown((prev) => (prev <= 1 ? 60 : prev - 1));
+ setCountdown((prev) => (prev <= 1 ? refreshIntervalSeconds : prev - 1));
}, 1000);
}
};
@@ -560,7 +565,7 @@ export default function ProviderLimits() {
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
- }, [autoRefresh, refreshAll, hasHydratedAutoRefresh]);
+ }, [autoRefresh, refreshAll, hasHydratedAutoRefresh, refreshIntervalSeconds]);
const sortedConnections = useMemo(
() =>
@@ -849,6 +854,17 @@ export default function ProviderLimits() {
Turn on Available
+
setRefreshIntervalMinutes(Number(event.target.value))}
+ className="h-8 rounded-lg border border-black/10 bg-black/[0.02] px-2 text-xs text-text-primary outline-none transition-colors hover:bg-black/5 dark:border-white/10 dark:bg-white/[0.03] dark:hover:bg-white/10"
+ aria-label="Auto-refresh interval"
+ title="Auto-refresh interval"
+ >
+ {REFRESH_INTERVAL_OPTIONS.map((minutes) => (
+ {minutes}m
+ ))}
+
{/* Auto-refresh toggle */}
setAutoRefresh((prev) => !prev)}
diff --git a/app/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/app/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js
index 2a61d668..8db7237e 100644
--- a/app/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js
+++ b/app/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js
@@ -7,6 +7,9 @@ export const REFRESH_INTERVAL_MS = 60000;
export const CLAUDE_REFRESH_INTERVAL_MS = 180000;
export const DEPLETED_QUOTA_THRESHOLD = 5;
export const AUTO_REFRESH_STORAGE_KEY = "quotaAutoRefresh";
+export const REFRESH_INTERVAL_STORAGE_KEY = "quotaRefreshIntervalMinutes";
+export const REFRESH_INTERVAL_OPTIONS = [1, 5, 10, 15, 30];
+export const DEFAULT_REFRESH_INTERVAL_MINUTES = 1;
export const CONNECTIONS_PAGE_SIZE = 20;
export const ACCOUNT_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
export const ACCOUNT_PAGE_SIZE_MAX = 500;
@@ -22,6 +25,22 @@ export const QUOTA_SORT_OPTIONS = [
];
// ─── Pure helpers ─────────────────────────────────────────────────────────────
+export function getRefreshIntervalSeconds(value) {
+ const minutes = Number(value);
+ return (REFRESH_INTERVAL_OPTIONS.includes(minutes)
+ ? minutes
+ : DEFAULT_REFRESH_INTERVAL_MINUTES) * 60;
+}
+
+export function shouldFetchQuotaOnTick(connection, tick, refreshIntervalSeconds) {
+ if (connection.provider !== "claude") return true;
+ const claudeEvery = Math.max(
+ 1,
+ Math.round(CLAUDE_REFRESH_INTERVAL_MS / (refreshIntervalSeconds * 1000)),
+ );
+ return tick % claudeEvery === 0;
+}
+
export function getConnectionLabel(connection) {
return connection.name?.trim()
|| connection.email?.trim()
diff --git a/app/src/app/(dashboard)/dashboard/usage/page.js b/app/src/app/(dashboard)/dashboard/usage/page.js
index 2681e1f4..9370e381 100644
--- a/app/src/app/(dashboard)/dashboard/usage/page.js
+++ b/app/src/app/(dashboard)/dashboard/usage/page.js
@@ -2,7 +2,7 @@
import { Suspense, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
-import { CardSkeleton, SegmentedControl } from "@/shared/components";
+import { CardSkeleton, RequestLogger, SegmentedControl, UsageStats } from "@/shared/components";
import KeiUsageView from "./components/KeiUsageView";
import RequestDetailsTab from "./components/RequestDetailsTab";
@@ -28,10 +28,11 @@ function UsageContent() {
const [period, setPeriod] = useState("today");
+ const viewFromUrl = searchParams.get("view");
+ const usageView = viewFromUrl === "old" ? "old" : "new";
const tabFromUrl = searchParams.get("tab");
- const activeTab = tabFromUrl && ["overview", "logs", "details"].includes(tabFromUrl)
- ? tabFromUrl
- : "overview";
+ const allowedTabs = usageView === "old" ? ["overview", "logs", "details"] : ["overview", "details"];
+ const activeTab = tabFromUrl && allowedTabs.includes(tabFromUrl) ? tabFromUrl : "overview";
const handleTabChange = (value) => {
if (value === activeTab) return;
@@ -40,37 +41,63 @@ function UsageContent() {
router.push(`/dashboard/usage?${params.toString()}`, { scroll: false });
};
+ const handleViewChange = (value) => {
+ if (value === usageView) return;
+ const params = new URLSearchParams(searchParams);
+ params.set("view", value);
+ if (value === "new" && tabFromUrl === "logs") params.set("tab", "overview");
+ router.push(`/dashboard/usage?${params.toString()}`, { scroll: false });
+ };
+
return (
- {/* Tabs + period selector on same row */}
-
+
- {activeTab === "overview" && (
-
-
-
- )}
+
+
+
+ {activeTab === "overview" && (
+
+
+
+ )}
+
- {activeTab === "overview" && (
+ {activeTab === "overview" && usageView === "new" && (
}>
)}
+ {activeTab === "overview" && usageView === "old" && (
+
}>
+
+
+ )}
+ {activeTab === "logs" && usageView === "old" &&
}
{activeTab === "details" &&
}
);
diff --git a/app/src/app/api/auth/oidc/start/route.js b/app/src/app/api/auth/oidc/start/route.js
index be49ff26..ed538d4f 100644
--- a/app/src/app/api/auth/oidc/start/route.js
+++ b/app/src/app/api/auth/oidc/start/route.js
@@ -15,8 +15,8 @@ import { shouldUseSecureCookie } from "@/lib/auth/dashboardSession";
* CSRF/prefetch mitigation for cookie-setting GET handler.
*
* This route sets HttpOnly cookies (oidc_state, nonce, pkce_verifier) then
- * redirects to the IdP. It MUST remain GET because the /masuk page triggers
- * it via window.location.href (top-level navigation).
+ * redirects to the IdP. It MUST remain GET because the login page triggers it
+ * via window.location.href (top-level navigation).
*
* To prevent browsers from prefetching (speculation rules,
,
* Chromium predictive preconnect) or cross-origin CSRF triggering cookie writes:
diff --git a/app/src/app/api/headroom/start/route.js b/app/src/app/api/headroom/start/route.js
index 56af456c..9b150a18 100644
--- a/app/src/app/api/headroom/start/route.js
+++ b/app/src/app/api/headroom/start/route.js
@@ -25,7 +25,7 @@ export async function POST() {
const result = await startHeadroomProxy({ port });
return NextResponse.json({ success: true, ...result });
} catch (error) {
- const status = error.code === "NOT_INSTALLED" ? 400 : 500;
+ const status = error.code === "NOT_INSTALLED" ? 400 : error.code === "PORT_IN_USE" ? 409 : 500;
return NextResponse.json({ error: error.message, code: error.code || null }, { status });
}
}
diff --git a/app/src/app/api/oauth/antigravity/bulk-import/[[...parts]]/route.js b/app/src/app/api/oauth/antigravity/bulk-import/[[...parts]]/route.js
new file mode 100644
index 00000000..695a04eb
--- /dev/null
+++ b/app/src/app/api/oauth/antigravity/bulk-import/[[...parts]]/route.js
@@ -0,0 +1,112 @@
+import { NextResponse } from "next/server";
+import {
+ buildLookupResponse,
+ getAntigravityBulkImportManager,
+ parseKiroBulkAccounts,
+} from "@/lib/oauth/services/antigravityBulkImportManager";
+import { resolveBulkImportProxy } from "@/lib/oauth/services/bulkImportProxyResolver";
+
+export const dynamic = "force-dynamic";
+
+function getParts(params) {
+ return Array.isArray(params?.parts) ? params.parts : [];
+}
+
+async function startJob(request) {
+ const body = await request.json();
+ const accounts = Array.isArray(body?.accounts) ? body.accounts : [];
+ const { parsed, invalidLines } = parseKiroBulkAccounts(accounts);
+
+ if (!parsed.length) {
+ return NextResponse.json({ error: "At least one account entry is required" }, { status: 400 });
+ }
+
+ if (invalidLines.length > 0) {
+ return NextResponse.json(
+ {
+ error: "Invalid account format. Use one account per line: email@gmail.com:password or email@gmail.com|password",
+ invalidLines,
+ },
+ { status: 400 }
+ );
+ }
+
+ const { proxyUrl, proxyUrls, proxyMode, proxyPoolId, proxySource, error: proxyError } = await resolveBulkImportProxy({
+ proxyPoolId: body?.proxyPoolId,
+ proxyUrl: body?.proxyUrl,
+ });
+ if (proxyError) return NextResponse.json({ error: proxyError }, { status: 400 });
+
+ const manager = getAntigravityBulkImportManager();
+ const job = await manager.startJob({
+ accounts,
+ concurrency: body?.concurrency,
+ engine: body?.engine,
+ proxyUrl,
+ proxyUrls,
+ proxyMode,
+ proxyPoolId,
+ proxySource,
+ jobFields: { redirectUri: "http://localhost:8080/callback" },
+ });
+
+ return NextResponse.json({ success: true, job });
+}
+
+async function latestJob(request) {
+ const manager = getAntigravityBulkImportManager();
+ const scope = new URL(request.url).searchParams.get("scope");
+ const job = await manager.getLatestJobWithPreview({ includeRecentTerminal: scope === "recent" || scope === "all" });
+
+ if (!job) {
+ return NextResponse.json({ success: false, ...buildLookupResponse(null), error: "Bulk import job not found" }, { status: 404 });
+ }
+
+ return NextResponse.json({ success: true, ...buildLookupResponse(job) });
+}
+
+export async function GET(request, { params }) {
+ const { parts = [] } = await params;
+ const routeParts = getParts({ parts });
+
+ if (routeParts.length === 0 || routeParts[0] === "latest") return latestJob(request);
+
+ const manager = getAntigravityBulkImportManager();
+
+ if (routeParts.length === 1) {
+ const job = await manager.getJobWithPreview(routeParts[0]);
+ if (!job) {
+ return NextResponse.json({ success: false, ...buildLookupResponse(null, { stale: true }), error: "Bulk import job not found" }, { status: 404 });
+ }
+ return NextResponse.json({ success: true, ...buildLookupResponse(job) });
+ }
+
+ return NextResponse.json({ error: "Unknown bulk import route" }, { status: 404 });
+}
+
+export async function POST(request, { params }) {
+ try {
+ const { parts = [] } = await params;
+ const routeParts = getParts({ parts });
+
+ if (routeParts.length === 0) return startJob(request);
+
+ if (routeParts.length === 2 && routeParts[1] === "cancel") {
+ const manager = getAntigravityBulkImportManager();
+ const job = manager.cancelJob(routeParts[0]);
+ if (!job) return NextResponse.json({ error: "Bulk import job not found" }, { status: 404 });
+ return NextResponse.json({ success: true, job });
+ }
+
+ return NextResponse.json({ error: "Unknown bulk import route" }, { status: 404 });
+ } catch (error) {
+ const status = Array.isArray(error?.invalidLines) ? 400 : 500;
+ return NextResponse.json(
+ {
+ error: error?.error || error?.message || "Failed to run Antigravity bulk import",
+ ...(Array.isArray(error?.invalidLines) ? { invalidLines: error.invalidLines } : {}),
+ },
+ { status }
+ );
+ }
+}
diff --git a/app/src/app/api/providers/[id]/route.js b/app/src/app/api/providers/[id]/route.js
index fa638b83..67c826ea 100644
--- a/app/src/app/api/providers/[id]/route.js
+++ b/app/src/app/api/providers/[id]/route.js
@@ -59,6 +59,14 @@ function shouldMergeProviderSpecificData(existing, incoming, hasLegacyProxy, has
return existing !== undefined || incoming !== undefined || hasLegacyProxy || hasProxyPoolField;
}
+export function mergeProviderSpecificData(existing = {}, incoming = {}) {
+ const merged = { ...existing, ...incoming };
+ for (const [key, value] of Object.entries(incoming)) {
+ if (value === null || value === undefined || value === "") delete merged[key];
+ }
+ return merged;
+}
+
// GET /api/providers/[id] - Get single connection
export async function GET(request, { params }) {
try {
@@ -135,10 +143,10 @@ export async function PUT(request, { params }) {
proxyPoolResult.hasProxyPoolField
)
) {
- updateData.providerSpecificData = {
- ...(existing.providerSpecificData || {}),
- ...(providerSpecificData || {}),
- };
+ updateData.providerSpecificData = mergeProviderSpecificData(
+ existing.providerSpecificData || {},
+ providerSpecificData || {}
+ );
if (proxyConfig.hasAnyProxyField) {
updateData.providerSpecificData.connectionProxyEnabled = proxyConfig.connectionProxyEnabled;
diff --git a/app/src/app/api/usage/providers/route.js b/app/src/app/api/usage/providers/route.js
index 840b2515..5b1363f7 100644
--- a/app/src/app/api/usage/providers/route.js
+++ b/app/src/app/api/usage/providers/route.js
@@ -1,20 +1,17 @@
-import { NextResponse } from "next/server";
-import { getRequestDetails } from "@/lib/requestDetailsDb";
-import { getProviderNodes } from "@/lib/localDb";
-import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
+import { NextResponse } from "next/server";
+import { getRequestDetailProviders } from "@/lib/requestDetailsDb";
+import { getProviderNodes } from "@/lib/localDb";
+import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
/**
* GET /api/usage/providers
* Returns list of unique providers from request details
*/
-export async function GET() {
- try {
- const { details } = await getRequestDetails({ pageSize: 9999 });
-
- // Extract unique providers
- const providerIds = [...new Set(details.flatMap(r => r.provider ? [r.provider] : []))].toSorted();
-
- const providerNodes = await getProviderNodes();
+export async function GET() {
+ try {
+ const providerIds = await getRequestDetailProviders();
+
+ const providerNodes = await getProviderNodes();
const nodeMap = {};
for (const node of providerNodes) {
nodeMap[node.id] = node.name;
diff --git a/app/src/app/api/usage/stream/route.js b/app/src/app/api/usage/stream/route.js
index d3f6d218..73714301 100644
--- a/app/src/app/api/usage/stream/route.js
+++ b/app/src/app/api/usage/stream/route.js
@@ -1,34 +1,91 @@
-import { getUsageStats, statsEmitter, getActiveRequests } from "@/lib/usageDb";
-
-export const dynamic = "force-dynamic";
-
-export async function GET() {
- const encoder = new TextEncoder();
- const state = { closed: false, keepalive: null, send: null, sendPending: null, cachedStats: null };
-
- const stream = new ReadableStream({
- async start(controller) {
- // Full stats refresh (heavy) + immediate lightweight push
- state.send = async () => {
- if (state.closed) return;
- try {
- // Push lightweight update immediately so UI reflects changes fast
- if (state.cachedStats) {
- const { activeRequests, recentRequests, errorProvider } = await getActiveRequests();
- const quickStats = { ...state.cachedStats, activeRequests, recentRequests, errorProvider };
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(quickStats)}\n\n`));
- }
- // Then do full recalc and update cache
- const stats = await getUsageStats();
- state.cachedStats = stats;
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(stats)}\n\n`));
- } catch {
- state.closed = true;
- statsEmitter.off("update", state.send);
- statsEmitter.off("pending", state.sendPending);
- clearInterval(state.keepalive);
- }
- };
+import { getUsageStats, statsEmitter, getActiveRequests } from "@/lib/usageDb";
+
+export const dynamic = "force-dynamic";
+const VALID_PERIODS = new Set(["today", "24h", "7d", "30d", "60d", "all"]);
+const FULL_STATS_MIN_INTERVAL_MS = 1000;
+
+if (!global.__usageStreamStatsCache) {
+ global.__usageStreamStatsCache = new Map();
+}
+
+async function getSharedUsageStats(period) {
+ const now = Date.now();
+ const cached = global.__usageStreamStatsCache.get(period);
+ if (cached?.stats && now - cached.ts < FULL_STATS_MIN_INTERVAL_MS) {
+ return cached.stats;
+ }
+ if (cached?.promise) return cached.promise;
+
+ const promise = getUsageStats(period)
+ .then((stats) => {
+ global.__usageStreamStatsCache.set(period, { stats, ts: Date.now(), promise: null });
+ return stats;
+ })
+ .catch((err) => {
+ global.__usageStreamStatsCache.delete(period);
+ throw err;
+ });
+
+ global.__usageStreamStatsCache.set(period, { stats: cached?.stats || null, ts: cached?.ts || 0, promise });
+ return promise;
+}
+
+export async function GET(request) {
+ const { searchParams } = new URL(request.url);
+ const requestedPeriod = searchParams.get("period") || "all";
+ const period = VALID_PERIODS.has(requestedPeriod) ? requestedPeriod : "all";
+ const encoder = new TextEncoder();
+ const state = {
+ closed: false,
+ keepalive: null,
+ send: null,
+ sendPending: null,
+ cachedStats: null,
+ sending: false,
+ queued: false,
+ cleanup: null,
+ };
+
+ state.cleanup = () => {
+ if (state.closed) return;
+ state.closed = true;
+ statsEmitter.off("update", state.send);
+ statsEmitter.off("pending", state.sendPending);
+ clearInterval(state.keepalive);
+ request.signal?.removeEventListener?.("abort", state.cleanup);
+ };
+
+ const stream = new ReadableStream({
+ async start(controller) {
+ // Full stats refresh (heavy) + immediate lightweight push
+ state.send = async () => {
+ if (state.closed) return;
+ if (state.sending) {
+ state.queued = true;
+ return;
+ }
+ state.sending = true;
+ try {
+ // Push lightweight update immediately so UI reflects changes fast
+ if (state.cachedStats) {
+ const { activeRequests, recentRequests, errorProvider } = await getActiveRequests();
+ const quickStats = { ...state.cachedStats, activeRequests, recentRequests, errorProvider };
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(quickStats)}\n\n`));
+ }
+ // Then do full recalc and update cache
+ const stats = await getSharedUsageStats(period);
+ state.cachedStats = stats;
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(stats)}\n\n`));
+ } catch {
+ state.cleanup();
+ } finally {
+ state.sending = false;
+ if (!state.closed && state.queued) {
+ state.queued = false;
+ setTimeout(state.send, FULL_STATS_MIN_INTERVAL_MS).unref?.();
+ }
+ }
+ };
// Lightweight push: only refresh activeRequests + recentRequests on pending changes
state.sendPending = async () => {
@@ -37,37 +94,32 @@ export async function GET() {
const { activeRequests, recentRequests, errorProvider } = await getActiveRequests();
const stats = { ...state.cachedStats, activeRequests, recentRequests, errorProvider };
controller.enqueue(encoder.encode(`data: ${JSON.stringify(stats)}\n\n`));
- } catch {
- state.closed = true;
- statsEmitter.off("update", state.send);
- statsEmitter.off("pending", state.sendPending);
- clearInterval(state.keepalive);
- }
- };
-
- await state.send();
+ } catch {
+ state.cleanup();
+ }
+ };
+
+ request.signal?.addEventListener?.("abort", state.cleanup, { once: true });
+ await state.send();
statsEmitter.on("update", state.send);
statsEmitter.on("pending", state.sendPending);
state.keepalive = setInterval(() => {
if (state.closed) { clearInterval(state.keepalive); return; }
- try {
- controller.enqueue(encoder.encode(": ping\n\n"));
- } catch {
- state.closed = true;
- clearInterval(state.keepalive);
- }
- }, 25000);
- },
-
- cancel() {
- state.closed = true;
- statsEmitter.off("update", state.send);
- statsEmitter.off("pending", state.sendPending);
- clearInterval(state.keepalive);
- },
- });
+ try {
+ controller.enqueue(encoder.encode(": ping\n\n"));
+ } catch {
+ state.cleanup();
+ }
+ }, 25000);
+ state.keepalive?.unref?.();
+ },
+
+ cancel() {
+ state.cleanup();
+ },
+ });
return new Response(stream, {
headers: {
diff --git a/app/src/app/landing/components/Features.js b/app/src/app/landing/components/Features.js
deleted file mode 100644
index 9bbba70a..00000000
--- a/app/src/app/landing/components/Features.js
+++ /dev/null
@@ -1,133 +0,0 @@
-"use client";
-
-const FEATURES = [
- {
- icon: "link",
- title: "Unified Endpoint",
- desc: "Access all providers via a single standard API URL.",
- colors: {
- border: "hover:border-blue-500/50",
- bg: "hover:bg-blue-500/5",
- iconBg: "bg-blue-500/10",
- iconText: "text-blue-500",
- titleHover: "group-hover:text-blue-400"
- }
- },
- {
- icon: "bolt",
- title: "Easy Setup",
- desc: "Get up and running in minutes with npx command.",
- colors: {
- border: "hover:border-orange-500/50",
- bg: "hover:bg-orange-500/5",
- iconBg: "bg-orange-500/10",
- iconText: "text-orange-500",
- titleHover: "group-hover:text-orange-400"
- }
- },
- {
- icon: "shield_with_heart",
- title: "Model Fallback",
- desc: "Automatically switch providers on failure or high latency.",
- colors: {
- border: "hover:border-rose-500/50",
- bg: "hover:bg-rose-500/5",
- iconBg: "bg-rose-500/10",
- iconText: "text-rose-500",
- titleHover: "group-hover:text-rose-400"
- }
- },
- {
- icon: "monitoring",
- title: "Usage Tracking",
- desc: "Detailed analytics and cost monitoring across all models.",
- colors: {
- border: "hover:border-purple-500/50",
- bg: "hover:bg-purple-500/5",
- iconBg: "bg-purple-500/10",
- iconText: "text-purple-500",
- titleHover: "group-hover:text-purple-400"
- }
- },
- {
- icon: "key",
- title: "OAuth & API Keys",
- desc: "Securely manage credentials in one vault.",
- colors: {
- border: "hover:border-amber-500/50",
- bg: "hover:bg-amber-500/5",
- iconBg: "bg-amber-500/10",
- iconText: "text-amber-500",
- titleHover: "group-hover:text-amber-400"
- }
- },
- {
- icon: "cloud_sync",
- title: "Cloud Sync",
- desc: "Sync your configurations across devices instantly.",
- colors: {
- border: "hover:border-sky-500/50",
- bg: "hover:bg-sky-500/5",
- iconBg: "bg-sky-500/10",
- iconText: "text-sky-500",
- titleHover: "group-hover:text-sky-400"
- }
- },
- {
- icon: "terminal",
- title: "CLI Support",
- desc: "Works with Claude Code, Codex, Cline, Cursor, and more.",
- colors: {
- border: "hover:border-emerald-500/50",
- bg: "hover:bg-emerald-500/5",
- iconBg: "bg-emerald-500/10",
- iconText: "text-emerald-500",
- titleHover: "group-hover:text-emerald-400"
- }
- },
- {
- icon: "dashboard",
- title: "Dashboard",
- desc: "Visual dashboard for real-time traffic analysis.",
- colors: {
- border: "hover:border-fuchsia-500/50",
- bg: "hover:bg-fuchsia-500/5",
- iconBg: "bg-fuchsia-500/10",
- iconText: "text-fuchsia-500",
- titleHover: "group-hover:text-fuchsia-400"
- }
- },
-];
-
-export default function Features() {
- return (
-
-
-
-
Powerful Features
-
- Everything you need to manage your AI infrastructure in one place, built for scale.
-
-
-
-
- {FEATURES.map((feature) => (
-
-
- {feature.icon}
-
-
- {feature.title}
-
-
{feature.desc}
-
- ))}
-
-
-
- );
-}
-
diff --git a/app/src/app/landing/components/FlowAnimation.js b/app/src/app/landing/components/FlowAnimation.js
deleted file mode 100644
index 96ed6b46..00000000
--- a/app/src/app/landing/components/FlowAnimation.js
+++ /dev/null
@@ -1,178 +0,0 @@
-"use client";
-import { useEffect, useState } from "react";
-import ProviderIcon from "@/shared/components/ProviderIcon";
-
-const CLI_TOOLS = [
- { id: "claude", name: "Claude Code", image: "/providers/claude.png" },
- { id: "codex", name: "OpenAI Codex", image: "/providers/codex.png" },
- { id: "cline", name: "Cline", image: "/providers/cline.png" },
- { id: "cursor", name: "Cursor", image: "/providers/cursor.png" },
-];
-
-const PROVIDERS = [
- {
- id: "openai",
- name: "OpenAI",
- color: "bg-emerald-500",
- textColor: "text-white",
- },
- {
- id: "anthropic",
- name: "Anthropic",
- color: "bg-orange-400",
- textColor: "text-white",
- },
- {
- id: "gemini",
- name: "Gemini",
- color: "bg-blue-500",
- textColor: "text-white",
- },
- {
- id: "github",
- name: "GitHub Copilot",
- color: "bg-gray-700",
- textColor: "text-white",
- },
-];
-
-export default function FlowAnimation() {
- const [activeFlow, setActiveFlow] = useState(0);
-
- useEffect(() => {
- const interval = setInterval(() => {
- setActiveFlow((prev) => (prev + 1) % PROVIDERS.length);
- }, 2000);
- return () => clearInterval(interval);
- }, []);
-
- return (
-
- {/* 9Router Hub - Center */}
-
-
-
-
-
-
-
-
- xscope0 Modifed
-
-
-
-
- {/* CLI Tools - Left side */}
-
- {CLI_TOOLS.map((tool) => (
-
- ))}
-
-
- {/* SVG Lines from CLI to 9Router */}
-
-
-
-
-
-
-
- {/* SVG Lines from 9Router to Providers */}
-
-
-
-
-
-
-
- {/* AI Providers - Right side */}
-
- {PROVIDERS.map((provider, idx) => (
-
- {provider.name}
-
- ))}
-
-
- {/* Mobile fallback */}
-
-
- Interactive diagram visible on desktop
-
-
-
- );
-}
diff --git a/app/src/app/landing/components/Footer.js b/app/src/app/landing/components/Footer.js
deleted file mode 100644
index 32f07b00..00000000
--- a/app/src/app/landing/components/Footer.js
+++ /dev/null
@@ -1,68 +0,0 @@
-"use client";
-
-import Link from "next/link";
-
-export default function Footer() {
- return (
-
-
-
- {/* Brand */}
-
-
-
- The unified endpoint for AI generation. Connect, route, and manage your AI providers with ease.
-
-
-
-
- {/* Product */}
-
-
- {/* Resources */}
-
-
- {/* Legal */}
-
-
-
- {/* Bottom */}
-
-
© 2025 xscope0 Modifed. All rights reserved.
-
-
-
-
- );
-}
-
diff --git a/app/src/app/landing/components/GetStarted.js b/app/src/app/landing/components/GetStarted.js
deleted file mode 100644
index 37abd2b5..00000000
--- a/app/src/app/landing/components/GetStarted.js
+++ /dev/null
@@ -1,99 +0,0 @@
-"use client";
-import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
-
-export default function GetStarted() {
- const { copied, copy } = useCopyToClipboard();
-
- const handleCopy = (text) => {
- copy(text, "landing");
- };
-
- return (
-
-
-
- {/* Left: Steps */}
-
-
Get Started in 30 Seconds
-
- Install xscope0 Modifed, configure your providers via web dashboard, and start routing AI requests.
-
-
-
-
-
1
-
-
Install xscope0 Modifed
-
Run npx command to start the server instantly
-
-
-
-
-
2
-
-
Open Dashboard
-
Configure providers and API keys via web interface
-
-
-
-
-
3
-
-
Route Requests
-
Point your CLI tools to http://127.0.0.1:20128
-
-
-
-
-
- {/* Right: Code block */}
-
-
- {/* Terminal header */}
-
-
- {/* Terminal content */}
-
-
handleCopy("9router")}
- aria-label="Copy command: 9router"
- >
- $
- 9router
-
- {copied === "landing" ? "✓ Copied" : "Copy"}
-
-
-
-
- > Starting xscope0 Modifed...
- > Server running on http://localhost:20128
- > Dashboard: http://localhost:20128/dashboard
- > Ready to route! ✓
-
-
-
- 📝 Configure providers in dashboard or use environment variables
-
-
-
- Data Location:
- macOS/Linux: ~/.9router/db/data.sqlite
- Windows: %APPDATA%/9router/db/data.sqlite
-
-
-
-
-
-
-
- );
-}
-
diff --git a/app/src/app/landing/components/HeroSection.js b/app/src/app/landing/components/HeroSection.js
deleted file mode 100644
index 31083f3b..00000000
--- a/app/src/app/landing/components/HeroSection.js
+++ /dev/null
@@ -1,47 +0,0 @@
-"use client";
-
-export default function HeroSection() {
- return (
-
- {/* Glow effect */}
-
-
-
- {/* Version badge */}
-
-
- v1.0 is now live
-
-
- {/* Main heading */}
-
- One Endpoint for
- All AI Providers
-
-
- {/* Description */}
-
- AI endpoint proxy with web dashboard - A JavaScript port of CLIProxyAPI. Works seamlessly with Claude Code, OpenAI Codex, Cline, RooCode, and other CLI tools.
-
-
- {/* CTA Buttons */}
-
-
-
- );
-}
-
diff --git a/app/src/app/landing/components/HowItWorks.js b/app/src/app/landing/components/HowItWorks.js
deleted file mode 100644
index 0f1a30aa..00000000
--- a/app/src/app/landing/components/HowItWorks.js
+++ /dev/null
@@ -1,71 +0,0 @@
-"use client";
-
-export default function HowItWorks() {
- return (
-
-
-
-
How xscope0 Modifed Works
-
- Data flows seamlessly from your application through our intelligent routing layer to the best provider for the job.
-
-
-
-
- {/* Connection line */}
-
-
- {/* Step 1: CLI & SDKs */}
-
-
- terminal
-
-
-
1. CLI & SDKs
-
- Your requests start from your favorite tools or our unified SDK. Just change the base URL.
-
-
-
-
- {/* Step 2: 9Router Hub */}
-
-
-
-
2. xscope0 Modifed Hub
-
- Our engine analyzes the prompt, checks provider health, and routes for lowest latency or cost.
-
-
-
-
- {/* Step 3: AI Providers */}
-
-
-
-
3. AI Providers
-
- The request is fulfilled by OpenAI, Anthropic, Gemini, or others instantly.
-
-
-
-
-
-
- );
-}
-
diff --git a/app/src/app/landing/components/Navigation.js b/app/src/app/landing/components/Navigation.js
deleted file mode 100644
index 764e0996..00000000
--- a/app/src/app/landing/components/Navigation.js
+++ /dev/null
@@ -1,77 +0,0 @@
-"use client";
-import { useState } from "react";
-import { useRouter } from "next/navigation";
-
-export default function Navigation() {
- const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
- const router = useRouter();
-
- return (
-
-
- {/* Logo */}
-
router.push("/")}
- aria-label="Navigate to home"
- >
-
- xscope0 Modifed
-
-
- {/* Desktop menu */}
-
-
- {/* CTA + Mobile menu */}
-
- router.push("/dashboard")}
- className="hidden sm:flex h-9 items-center justify-center rounded-lg px-4 bg-[#6366f1] hover:bg-[#4f46e5] transition-all text-white text-sm font-bold shadow-[0_0_15px_rgba(99,102,241,0.4)] hover:shadow-[0_0_20px_rgba(99,102,241,0.6)]"
- >
- Get Started
-
- setMobileMenuOpen(!mobileMenuOpen)}
- >
- {mobileMenuOpen ? "close" : "menu"}
-
-
-
-
- {/* Mobile menu dropdown */}
- {mobileMenuOpen && (
-
- )}
-
- );
-}
-
diff --git a/app/src/app/landing/page.js b/app/src/app/landing/page.js
deleted file mode 100644
index 20e83c95..00000000
--- a/app/src/app/landing/page.js
+++ /dev/null
@@ -1,81 +0,0 @@
-"use client";
-import { useRouter } from "next/navigation";
-import Navigation from "./components/Navigation";
-import HeroSection from "./components/HeroSection";
-import FlowAnimation from "./components/FlowAnimation";
-import HowItWorks from "./components/HowItWorks";
-import Features from "./components/Features";
-import GetStarted from "./components/GetStarted";
-import Footer from "./components/Footer";
-
-export default function LandingPage() {
- const router = useRouter();
- return (
-
- {/* Animated Background */}
-
- {/* Grid pattern */}
-
-
- {/* Animated gradient orbs */}
-
-
-
-
- {/* Vignette effect */}
-
-
-
-
-
-
-
- {/* Hero with Flow Animation */}
-
-
-
-
-
-
- {/* CTA Section */}
-
-
-
-
Ready to Simplify Your AI Infrastructure?
-
- Join developers who are streamlining their AI integrations with xscope0 Modifed. Open source and free to start.
-
-
- router.push("/dashboard")}
- className="w-full sm:w-auto h-14 px-10 rounded-lg bg-[#6366f1] hover:bg-[#4f46e5] text-white text-lg font-bold transition-all shadow-[0_0_20px_rgba(99,102,241,0.5)]"
- >
- Start Free
-
- window.open("https://github.com/decolua/9router#readme", "_blank")}
- className="w-full sm:w-auto h-14 px-10 rounded-lg border border-[#3a2f27] hover:bg-[#1a1433] text-white text-lg font-bold transition-all"
- >
- Read Documentation
-
-
-
-
-
-
-
-
-
- );
-}
-
diff --git a/app/src/app/masuk/MasukClient.js b/app/src/app/masuk/MasukClient.js
deleted file mode 100644
index ad8401e3..00000000
--- a/app/src/app/masuk/MasukClient.js
+++ /dev/null
@@ -1,175 +0,0 @@
-"use client";
-
-import { useState, useEffect, useReducer } from "react";
-import { Card, Button, Input } from "@/shared/components";
-import { useRouter } from "next/navigation";
-
-function handleOidcLogin() {
- window.location.href = "/api/auth/oidc/start";
-}
-
-function loginReducer(state, action) {
- switch (action.type) {
- case "SUBMIT": return { ...state, loading: true, error: "", resetHint: "" };
- case "ERROR": return { ...state, loading: false, error: action.error, resetHint: action.resetHint || "", retryAfter: action.retryAfter || 0 };
- case "DONE": return { ...state, loading: false };
- case "TICK": return { ...state, retryAfter: state.retryAfter > 0 ? state.retryAfter - 1 : 0 };
- default: return state;
- }
-}
-
-export default function MasukClient({ initialAuth }) {
- const [password, setPassword] = useState("");
- const [state, dispatch] = useReducer(loginReducer, { error: "", resetHint: "", retryAfter: 0, loading: false });
- const { error, resetHint, retryAfter, loading } = state;
- const hasPassword = initialAuth?.hasPassword ?? null;
- const authMode = initialAuth?.authMode || "password";
- const oidcConfigured = initialAuth?.oidcConfigured || false;
- const oidcLoginLabel = initialAuth?.oidcLoginLabel || "Masuk dengan OIDC";
- const router = useRouter();
-
- useEffect(() => {
- if (retryAfter <= 0) return;
- const id = setInterval(() => dispatch({ type: "TICK" }), 1000);
- return () => clearInterval(id);
- }, [retryAfter]);
-
- if (initialAuth?.requireLogin === false) {
- router.push("/dashboard");
- return null;
- }
-
- const handleLogin = async (e) => {
- e.preventDefault();
- dispatch({ type: "SUBMIT" });
-
- try {
- const res = await fetch("/api/auth/login", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ password }),
- });
-
- if (res.ok) {
- router.push("/dashboard");
- router.refresh();
- } else {
- const data = await res.json();
- dispatch({ type: "ERROR", error: data.error || "Password salah", resetHint: data.resetHint, retryAfter: data.retryAfter ? Number(data.retryAfter) : 0 });
- }
- } catch (err) {
- dispatch({ type: "ERROR", error: "Terjadi kesalahan. Silakan coba lagi." });
- }
- };
-
- const oidcAvailable = oidcConfigured && ["oidc", "both"].includes(authMode);
- const passwordAvailable = authMode !== "oidc" || !oidcConfigured;
-
- if (hasPassword === null) {
- return (
-
- );
- }
-
- return (
-
-
-
-
-
-
xscope0 Modifed
-
- {authMode === "oidc" && oidcConfigured
- ? "Masuk dengan OIDC provider untuk mengakses dashboard"
- : "Masukkan password untuk mengakses dashboard"}
-
-
-
-
-
- {oidcAvailable && (
-
- {oidcLoginLabel}
-
- )}
-
- {oidcAvailable && passwordAvailable &&
}
-
- {passwordAvailable ? (
-
- ) : (
- error &&
{error}
- )}
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/app/src/app/masuk/page.js b/app/src/app/masuk/page.js
deleted file mode 100644
index ce46e97f..00000000
--- a/app/src/app/masuk/page.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { cookies } from "next/headers";
-import { getSettings } from "@/lib/localDb";
-import { isOidcConfigured } from "@/lib/auth/oidc";
-import { getDashboardAuthSession } from "@/lib/auth/dashboardSession";
-import MasukClient from "./MasukClient";
-
-export default async function MasukPage() {
- let initialAuth = { hasPassword: true, authMode: "password", oidcConfigured: false, oidcLoginLabel: "Masuk dengan OIDC", requireLogin: true };
- try {
- const settings = await getSettings();
- const cookieStore = await cookies();
- const session = await getDashboardAuthSession(cookieStore.get("auth_token")?.value);
- const requireLogin = settings.requireLogin !== false;
- initialAuth = {
- requireLogin,
- authMode: settings.authMode || "password",
- oidcConfigured: isOidcConfigured(settings),
- oidcLoginLabel: (settings.oidcLoginLabel || "Sign in with OIDC").trim() || "Sign in with OIDC",
- hasPassword: !!settings.password,
- isLoggedIn: !!session,
- };
- } catch {}
- return
;
-}
diff --git a/app/src/dashboardGuard.js b/app/src/dashboardGuard.js
index 193b60b3..0caecda2 100644
--- a/app/src/dashboardGuard.js
+++ b/app/src/dashboardGuard.js
@@ -43,6 +43,10 @@ const ALWAYS_PROTECTED = [
"/api/oauth/cursor/auto-import",
"/api/oauth/kiro/auto-import",
];
+const LOCAL_REAUTH_PATHS = [
+ "/api/settings/database",
+];
+
const PROTECTED_API_PATHS = [
"/api/settings",
@@ -196,6 +200,10 @@ export async function proxy(request) {
return NextResponse.json({ error: "Local only: CLI token required" }, { status: 403 });
}
}
+ // Local backup/import prompts for the dashboard password again in its handler.
+ if (LOCAL_REAUTH_PATHS.some((p) => pathname.startsWith(p)) && isLocalRequest(request)) {
+ return NextResponse.next();
+ }
// Always protected - require valid JWT or local CLI token (machineId-based)
if (ALWAYS_PROTECTED.some((p) => pathname.startsWith(p))) {
@@ -217,19 +225,6 @@ export async function proxy(request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
- // Deny-by-default for /api/* — public allow-list bypasses, everything else requires auth.
- if (pathname === "/masuk" || pathname === "/masuk/") {
- if (await isAuthenticated(request)) {
- return NextResponse.redirect(new URL("/dashboard", request.url));
- }
- return NextResponse.next();
- }
-
- // /login - always redirect to /masuk
- if (pathname === "/login" || pathname === "/login/") {
- return NextResponse.redirect(new URL("/masuk", request.url));
- }
-
// / - redirect to dashboard if authenticated, otherwise return JSON welcome
if (pathname === "/") {
if (await isAuthenticated(request)) {
@@ -270,7 +265,7 @@ export async function proxy(request) {
const tunnelHost = settings.tunnelUrl ? new URL(settings.tunnelUrl).hostname.toLowerCase() : "";
const tailscaleHost = settings.tailscaleUrl ? new URL(settings.tailscaleUrl).hostname.toLowerCase() : "";
if ((tunnelHost && host === tunnelHost) || (tailscaleHost && host === tailscaleHost)) {
- return NextResponse.redirect(new URL("/masuk", request.url));
+ return NextResponse.redirect(new URL("/login", request.url));
}
}
}
@@ -283,11 +278,11 @@ export async function proxy(request) {
if (await verifyDashboardAuthToken(token)) {
return NextResponse.next();
} else {
- return NextResponse.redirect(new URL("/masuk", request.url));
+ return NextResponse.redirect(new URL("/login", request.url));
}
}
- return NextResponse.redirect(new URL("/masuk", request.url));
+ return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
diff --git a/app/src/i18n/config.js b/app/src/i18n/config.js
index f5741d8b..b7965ca0 100644
--- a/app/src/i18n/config.js
+++ b/app/src/i18n/config.js
@@ -1,146 +1,19 @@
-export const LOCALES = ["en", "vi", "zh-CN", "zh-TW", "ja", "pt-BR", "pt-PT", "ko", "es", "de", "fr", "he", "ar", "ru", "pl", "cs", "nl", "tr", "uk", "tl", "id", "th", "hi", "bn", "ur", "ro", "sv", "it", "el", "hu", "fi", "da", "no"];
-export const DEFAULT_LOCALE = "en";
-export const LOCALE_COOKIE = "locale";
-
-const LOCALE_NAMES = {
- "en": "English",
- "vi": "Tiếng Việt",
- "zh-CN": "简体中文",
- "zh-TW": "繁體中文",
- "ja": "日本語",
- "pt-BR": "Português (Brasil)",
- "pt-PT": "Português (Portugal)",
- "ko": "한국어",
- "es": "Español",
- "de": "Deutsch",
- "fr": "Français",
- "he": "עברית",
- "ar": "العربية",
- "ru": "Русский",
- "pl": "Polski",
- "cs": "Čeština",
- "nl": "Nederlands",
- "tr": "Türkçe",
- "uk": "Українська",
- "tl": "Tagalog",
- "id": "Indonesia",
- "th": "ไทย",
- "hi": "हिन्दी",
- "bn": "বাংলা",
- "ur": "اردو",
- "ro": "Română",
- "sv": "Svenska",
- "it": "Italiano",
- "el": "Ελληνικά",
- "hu": "Magyar",
- "fi": "Suomi",
- "da": "Dansk",
- "no": "Norsk"
-};
-
-export function normalizeLocale(locale) {
- if (locale === "zh" || locale === "zh-CN") {
- return "zh-CN";
- }
- if (locale === "en") {
- return "en";
- }
- if (locale === "vi") {
- return "vi";
- }
- if (locale === "zh-TW") {
- return "zh-TW";
- }
- if (locale === "ja") {
- return "ja";
- }
- if (locale === "pt-BR") {
- return "pt-BR";
- }
- if (locale === "pt-PT") {
- return "pt-PT";
- }
- if (locale === "ko") {
- return "ko";
- }
- if (locale === "es") {
- return "es";
- }
- if (locale === "de") {
- return "de";
- }
- if (locale === "fr") {
- return "fr";
- }
- if (locale === "he") {
- return "he";
- }
- if (locale === "ar") {
- return "ar";
- }
- if (locale === "ru") {
- return "ru";
- }
- if (locale === "pl") {
- return "pl";
- }
- if (locale === "cs") {
- return "cs";
- }
- if (locale === "nl") {
- return "nl";
- }
- if (locale === "tr") {
- return "tr";
- }
- if (locale === "uk") {
- return "uk";
- }
- if (locale === "tl") {
- return "tl";
- }
- if (locale === "id") {
- return "id";
- }
- if (locale === "th") {
- return "th";
- }
- if (locale === "hi") {
- return "hi";
- }
- if (locale === "bn") {
- return "bn";
- }
- if (locale === "ur") {
- return "ur";
- }
- if (locale === "ro") {
- return "ro";
- }
- if (locale === "sv") {
- return "sv";
- }
- if (locale === "it") {
- return "it";
- }
- if (locale === "el") {
- return "el";
- }
- if (locale === "hu") {
- return "hu";
- }
- if (locale === "fi") {
- return "fi";
- }
- if (locale === "da") {
- return "da";
- }
- if (locale === "no") {
- return "no";
- }
- return DEFAULT_LOCALE;
-}
-
-export function isSupportedLocale(locale) {
- return LOCALES.includes(locale);
-}
+export const LOCALES = ["en", "zh-CN", "id"];
+export const DEFAULT_LOCALE = "en";
+export const LOCALE_COOKIE = "locale";
+
+const LOCALE_NAMES = {
+ "en": "English",
+ "zh-CN": "简体中文",
+ "id": "Indonesia",
+};
+
+export function normalizeLocale(locale) {
+ if (locale === "zh" || locale === "zh-CN") return "zh-CN";
+ if (locale === "id") return "id";
+ return DEFAULT_LOCALE;
+}
+
+export function isSupportedLocale(locale) {
+ return LOCALES.includes(locale);
+}
diff --git a/app/src/lib/db/index.js b/app/src/lib/db/index.js
index 0c1cb2a3..a965aafc 100644
--- a/app/src/lib/db/index.js
+++ b/app/src/lib/db/index.js
@@ -63,9 +63,9 @@ export {
} from "./repos/usageRepo.js";
// Request details
-export {
- saveRequestDetail, getRequestDetails, getRequestDetailById,
-} from "./repos/requestDetailsRepo.js";
+export {
+ saveRequestDetail, getRequestDetails, getRequestDetailProviders, getRequestDetailById,
+} from "./repos/requestDetailsRepo.js";
// Export/import full DB
export async function exportDb() {
diff --git a/app/src/lib/db/repos/requestDetailsRepo.js b/app/src/lib/db/repos/requestDetailsRepo.js
index 6da3b2c9..b7bf26a6 100644
--- a/app/src/lib/db/repos/requestDetailsRepo.js
+++ b/app/src/lib/db/repos/requestDetailsRepo.js
@@ -2,29 +2,61 @@ import { getAdapter } from "../driver.js";
import { parseJson, stringifyJson } from "../helpers/jsonCol.js";
const DEFAULT_MAX_RECORDS = 200;
-const DEFAULT_BATCH_SIZE = 20;
-const DEFAULT_FLUSH_INTERVAL_MS = 5000;
-const DEFAULT_MAX_JSON_SIZE = 5 * 1024;
+const DEFAULT_BATCH_SIZE = 5;
+const DEFAULT_FLUSH_INTERVAL_MS = 3000;
+const DEFAULT_MAX_JSON_SIZE = 16 * 1024;
+const HARD_MAX_RECORDS = 300;
+const HARD_BATCH_SIZE = 10;
+const HARD_MAX_JSON_SIZE = 64 * 1024;
+const MAX_BUFFER_MULTIPLIER = 5;
const CONFIG_CACHE_TTL_MS = 5000;
let cachedConfig = null;
let cachedConfigTs = 0;
+function readPositiveInt(value, fallback) {
+ const parsed = Number.parseInt(value, 10);
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
+}
+
+function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value));
+}
+
async function getObservabilityConfig() {
if (cachedConfig && (Date.now() - cachedConfigTs) < CONFIG_CACHE_TTL_MS) return cachedConfig;
try {
const { getSettings } = await import("./settingsRepo.js");
const settings = await getSettings();
- const envEnabled = process.env.OBSERVABILITY_ENABLED !== "false";
- const enabled = typeof settings.enableObservability2 === "boolean"
- ? settings.enableObservability2
- : envEnabled;
+ const envEnabled = process.env.OBSERVABILITY_ENABLED === "true";
+ const settingEnabled =
+ typeof settings.enableObservability === "boolean" ? settings.enableObservability :
+ typeof settings.observabilityEnabled === "boolean" ? settings.observabilityEnabled :
+ typeof settings.enableObservability2 === "boolean" ? settings.enableObservability2 :
+ undefined;
+ const enabled = typeof settingEnabled === "boolean" ? settingEnabled : envEnabled;
+ const maxRecords = clamp(
+ readPositiveInt(settings.observabilityMaxRecords ?? process.env.OBSERVABILITY_MAX_RECORDS, DEFAULT_MAX_RECORDS),
+ 1,
+ HARD_MAX_RECORDS
+ );
+ const batchSize = clamp(
+ readPositiveInt(settings.observabilityBatchSize ?? process.env.OBSERVABILITY_BATCH_SIZE, DEFAULT_BATCH_SIZE),
+ 1,
+ HARD_BATCH_SIZE
+ );
+ const flushIntervalMs = clamp(
+ readPositiveInt(settings.observabilityFlushIntervalMs ?? process.env.OBSERVABILITY_FLUSH_INTERVAL_MS, DEFAULT_FLUSH_INTERVAL_MS),
+ 250,
+ 10000
+ );
+ const maxJsonSizeKb = readPositiveInt(settings.observabilityMaxJsonSize ?? process.env.OBSERVABILITY_MAX_JSON_SIZE, Math.ceil(DEFAULT_MAX_JSON_SIZE / 1024));
cachedConfig = {
enabled,
- maxRecords: settings.observabilityMaxRecords || parseInt(process.env.OBSERVABILITY_MAX_RECORDS || String(DEFAULT_MAX_RECORDS), 10),
- batchSize: settings.observabilityBatchSize || parseInt(process.env.OBSERVABILITY_BATCH_SIZE || String(DEFAULT_BATCH_SIZE), 10),
- flushIntervalMs: settings.observabilityFlushIntervalMs || parseInt(process.env.OBSERVABILITY_FLUSH_INTERVAL_MS || String(DEFAULT_FLUSH_INTERVAL_MS), 10),
- maxJsonSize: (settings.observabilityMaxJsonSize || parseInt(process.env.OBSERVABILITY_MAX_JSON_SIZE || "5", 10)) * 1024,
+ maxRecords,
+ batchSize,
+ flushIntervalMs,
+ maxJsonSize: clamp(maxJsonSizeKb * 1024, 1024, HARD_MAX_JSON_SIZE),
};
} catch {
cachedConfig = {
@@ -61,9 +93,21 @@ function generateDetailId(model) {
}
function truncateField(obj, maxSize) {
- const str = JSON.stringify(obj || {});
+ if (typeof obj === "string") {
+ if (obj.length > maxSize) {
+ return { _truncated: true, _originalSize: obj.length, _preview: obj.substring(0, Math.min(1000, maxSize)) };
+ }
+ return obj;
+ }
+
+ let str;
+ try {
+ str = JSON.stringify(obj || {});
+ } catch {
+ return { _truncated: true, _preview: "[Unserializable value]" };
+ }
if (str.length > maxSize) {
- return { _truncated: true, _originalSize: str.length, _preview: str.substring(0, 200) };
+ return { _truncated: true, _originalSize: str.length, _preview: str.substring(0, Math.min(1000, maxSize)) };
}
return obj || {};
}
@@ -75,9 +119,9 @@ async function flushToDatabase() {
try {
// Drain entire buffer (loop in case more pushed during await)
while (writeBuffer.length > 0) {
- const items = writeBuffer.splice(0, writeBuffer.length);
- const db = await getAdapter();
const config = await getObservabilityConfig();
+ const items = writeBuffer.splice(0, config.batchSize);
+ const db = await getAdapter();
db.transaction(() => {
for (const item of items) {
@@ -129,6 +173,10 @@ export async function saveRequestDetail(detail) {
if (!config.enabled) return;
writeBuffer.push(detail);
+ const maxBuffered = config.batchSize * MAX_BUFFER_MULTIPLIER;
+ if (writeBuffer.length > maxBuffered) {
+ writeBuffer.splice(0, writeBuffer.length - maxBuffered);
+ }
// Trigger immediate flush if batch threshold reached.
// flushToDatabase() drains entire buffer in a loop, so all pushes during await are persisted.
@@ -176,6 +224,13 @@ export async function getRequestDetails(filter = {}) {
};
}
+export async function getRequestDetailProviders() {
+ const db = await getAdapter();
+ return db.all(
+ `SELECT DISTINCT provider FROM requestDetails WHERE provider IS NOT NULL AND provider != '' ORDER BY provider ASC`
+ ).map((r) => r.provider);
+}
+
export async function getRequestDetailById(id) {
const db = await getAdapter();
const row = db.get(`SELECT data FROM requestDetails WHERE id = ?`, [id]);
diff --git a/app/src/lib/db/repos/settingsRepo.js b/app/src/lib/db/repos/settingsRepo.js
index 39b2ca54..164b6de5 100644
--- a/app/src/lib/db/repos/settingsRepo.js
+++ b/app/src/lib/db/repos/settingsRepo.js
@@ -26,17 +26,18 @@ const DEFAULT_SETTINGS = {
oidcClientSecret: "",
oidcScopes: "openid profile email",
oidcLoginLabel: "Sign in with OIDC",
- enableObservability: true,
- observabilityMaxRecords: 1000,
- observabilityBatchSize: 20,
+ enableObservability: false,
+ observabilityMaxRecords: 200,
+ observabilityBatchSize: 5,
observabilityFlushIntervalMs: 5000,
- observabilityMaxJsonSize: 5,
+ observabilityMaxJsonSize: 16,
outboundProxyEnabled: false,
outboundProxyUrl: "",
outboundNoProxy: "",
mitmRouterBaseUrl: DEFAULT_MITM_ROUTER_BASE,
dnsToolEnabled: {},
rtkEnabled: true,
+ webSearchSaverEnabled: true,
headroomEnabled: false,
headroomUrl: DEFAULT_HEADROOM_URL,
headroomCompressUserMessages: false,
@@ -46,6 +47,7 @@ const DEFAULT_SETTINGS = {
cavemanLevel: "full",
ponytailEnabled: false,
ponytailLevel: "full",
+ styleInjectionMethod: "old",
};
async function readRaw() {
diff --git a/app/src/lib/db/repos/usageRepo.js b/app/src/lib/db/repos/usageRepo.js
index b0d6bff0..6ebffa78 100644
--- a/app/src/lib/db/repos/usageRepo.js
+++ b/app/src/lib/db/repos/usageRepo.js
@@ -13,6 +13,7 @@ const PENDING_TIMEOUT_MS = 60 * 1000;
const RING_CAP = 50;
const CONN_CACHE_TTL_MS = 30 * 1000;
const PERIOD_MS = { "24h": 86400000, "7d": 604800000, "30d": 2592000000, "60d": 5184000000 };
+const LOG_PENDING_EVENTS = process.env.USAGE_LOG_PENDING === "true";
// In-memory state shared across Next.js modules
if (!global._pendingRequests) global._pendingRequests = { byModel: {}, byAccount: {} };
@@ -50,6 +51,22 @@ function getLocalDateKey(timestamp) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
+function fastTimeString(date = new Date()) {
+ const pad = (n) => String(n).padStart(2, "0");
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
+}
+
+function fastHourMinute(timestampMs) {
+ const d = new Date(timestampMs);
+ const pad = (n) => String(n).padStart(2, "0");
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
+
+function fastMonthDay(date) {
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
+ return `${months[date.getMonth()]} ${date.getDate()}`;
+}
+
function addToCounter(target, key, values) {
if (!target[key]) target[key] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 };
target[key].requests += values.requests || 1;
@@ -189,18 +206,28 @@ export function trackPendingRequest(model, provider, connectionId, started, erro
}
if (started) {
- clearTimeout(pendingTimers[timerKey]);
- pendingTimers[timerKey] = setTimeout(() => {
+ if (!pendingTimers[timerKey]) {
+ pendingTimers[timerKey] = setTimeout(() => {
delete pendingTimers[timerKey];
- if (pendingRequests.byModel[modelKey] > 0) pendingRequests.byModel[modelKey] = 0;
+ if (pendingRequests.byModel[modelKey] > 0) delete pendingRequests.byModel[modelKey];
if (connectionId && pendingRequests.byAccount[connectionId]?.[modelKey] > 0) {
- pendingRequests.byAccount[connectionId][modelKey] = 0;
+ delete pendingRequests.byAccount[connectionId][modelKey];
+ if (Object.keys(pendingRequests.byAccount[connectionId]).length === 0) {
+ delete pendingRequests.byAccount[connectionId];
+ }
}
scheduleStatsEvent("pending");
- }, PENDING_TIMEOUT_MS);
+ }, PENDING_TIMEOUT_MS);
+ pendingTimers[timerKey]?.unref?.();
+ }
} else {
- clearTimeout(pendingTimers[timerKey]);
- delete pendingTimers[timerKey];
+ const stillActive =
+ (pendingRequests.byModel[modelKey] || 0) > 0 ||
+ (connectionId && (pendingRequests.byAccount[connectionId]?.[modelKey] || 0) > 0);
+ if (!stillActive) {
+ clearTimeout(pendingTimers[timerKey]);
+ delete pendingTimers[timerKey];
+ }
}
if (!started && error && provider) {
@@ -208,8 +235,9 @@ export function trackPendingRequest(model, provider, connectionId, started, erro
lastErrorProvider.ts = Date.now();
}
- const t = new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
- console.log(`[${t}] [PENDING] ${started ? "START" : "END"}${error ? " (ERROR)" : ""} | provider=${provider} | model=${model}`);
+ if (LOG_PENDING_EVENTS) {
+ console.log(`[${fastTimeString()}] [PENDING] ${started ? "START" : "END"}${error ? " (ERROR)" : ""} | provider=${provider} | model=${model}`);
+ }
scheduleStatsEvent("pending");
}
@@ -559,22 +587,22 @@ export async function getUsageStats(period = "all") {
for (const e of histRows) {
const ts = e.timestamp;
const modelKey = e.provider ? `${e.model} (${e.provider})` : e.model;
- if (stats.byModel[modelKey] && new Date(ts) > new Date(stats.byModel[modelKey].lastUsed)) stats.byModel[modelKey].lastUsed = ts;
+ if (stats.byModel[modelKey] && ts > (stats.byModel[modelKey].lastUsed || "")) stats.byModel[modelKey].lastUsed = ts;
if (e.connectionId) {
const accountName = connectionMap[e.connectionId] || `Account ${e.connectionId.slice(0, 8)}...`;
const accountKey = `${e.model} (${e.provider} - ${accountName})`;
- if (stats.byAccount[accountKey] && new Date(ts) > new Date(stats.byAccount[accountKey].lastUsed)) stats.byAccount[accountKey].lastUsed = ts;
+ if (stats.byAccount[accountKey] && ts > (stats.byAccount[accountKey].lastUsed || "")) stats.byAccount[accountKey].lastUsed = ts;
}
const apiKeyKey = (e.apiKey && typeof e.apiKey === "string")
? `${e.apiKey}|${e.model}|${e.provider || "unknown"}`
: "local-no-key";
- if (stats.byApiKey[apiKeyKey] && new Date(ts) > new Date(stats.byApiKey[apiKeyKey].lastUsed)) stats.byApiKey[apiKeyKey].lastUsed = ts;
+ if (stats.byApiKey[apiKeyKey] && ts > (stats.byApiKey[apiKeyKey].lastUsed || "")) stats.byApiKey[apiKeyKey].lastUsed = ts;
const endpoint = e.endpoint || "Unknown";
const endpointKey = `${endpoint}|${e.model}|${e.provider || "unknown"}`;
- if (stats.byEndpoint[endpointKey] && new Date(ts) > new Date(stats.byEndpoint[endpointKey].lastUsed)) stats.byEndpoint[endpointKey].lastUsed = ts;
+ if (stats.byEndpoint[endpointKey] && ts > (stats.byEndpoint[endpointKey].lastUsed || "")) stats.byEndpoint[endpointKey].lastUsed = ts;
}
} else {
// 24h / today: live history
@@ -587,14 +615,13 @@ export async function getUsageStats(period = "all") {
cutoff = new Date(Date.now() - PERIOD_MS["24h"]).toISOString();
}
const filtered = db.all(
- `SELECT timestamp, provider, model, connectionId, apiKey, endpoint, promptTokens, completionTokens, cost, tokens FROM usageHistory WHERE timestamp >= ?`,
+ `SELECT timestamp, provider, model, connectionId, apiKey, endpoint, promptTokens, completionTokens, cost FROM usageHistory WHERE timestamp >= ?`,
[cutoff]
);
for (const r of filtered) {
- const tokens = parseJson(r.tokens, {}) || {};
- const promptTokens = tokens.prompt_tokens || 0;
- const completionTokens = tokens.completion_tokens || 0;
+ const promptTokens = r.promptTokens || 0;
+ const completionTokens = r.completionTokens || 0;
const entryCost = r.cost || 0;
const providerDisplayName = providerNodeNameMap[r.provider] || r.provider;
@@ -616,7 +643,7 @@ export async function getUsageStats(period = "all") {
stats.byModel[modelKey].promptTokens += promptTokens;
stats.byModel[modelKey].completionTokens += completionTokens;
stats.byModel[modelKey].cost += entryCost;
- if (new Date(r.timestamp) > new Date(stats.byModel[modelKey].lastUsed)) stats.byModel[modelKey].lastUsed = r.timestamp;
+ if (r.timestamp > (stats.byModel[modelKey].lastUsed || "")) stats.byModel[modelKey].lastUsed = r.timestamp;
if (r.connectionId) {
const accountName = connectionMap[r.connectionId] || `Account ${r.connectionId.slice(0, 8)}...`;
@@ -628,7 +655,7 @@ export async function getUsageStats(period = "all") {
stats.byAccount[accountKey].promptTokens += promptTokens;
stats.byAccount[accountKey].completionTokens += completionTokens;
stats.byAccount[accountKey].cost += entryCost;
- if (new Date(r.timestamp) > new Date(stats.byAccount[accountKey].lastUsed)) stats.byAccount[accountKey].lastUsed = r.timestamp;
+ if (r.timestamp > (stats.byAccount[accountKey].lastUsed || "")) stats.byAccount[accountKey].lastUsed = r.timestamp;
}
if (r.apiKey && typeof r.apiKey === "string") {
@@ -641,14 +668,14 @@ export async function getUsageStats(period = "all") {
}
const ake = stats.byApiKey[akKey];
ake.requests++; ake.promptTokens += promptTokens; ake.completionTokens += completionTokens; ake.cost += entryCost;
- if (new Date(r.timestamp) > new Date(ake.lastUsed)) ake.lastUsed = r.timestamp;
+ if (r.timestamp > (ake.lastUsed || "")) ake.lastUsed = r.timestamp;
} else {
if (!stats.byApiKey["local-no-key"]) {
stats.byApiKey["local-no-key"] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, apiKeyMasked: null, keyName: "Local (No API Key)", apiKeyKey: "local-no-key", lastUsed: r.timestamp };
}
const ake = stats.byApiKey["local-no-key"];
ake.requests++; ake.promptTokens += promptTokens; ake.completionTokens += completionTokens; ake.cost += entryCost;
- if (new Date(r.timestamp) > new Date(ake.lastUsed)) ake.lastUsed = r.timestamp;
+ if (r.timestamp > (ake.lastUsed || "")) ake.lastUsed = r.timestamp;
}
const endpoint = r.endpoint || "Unknown";
@@ -658,7 +685,7 @@ export async function getUsageStats(period = "all") {
}
const epe = stats.byEndpoint[epKey];
epe.requests++; epe.promptTokens += promptTokens; epe.completionTokens += completionTokens; epe.cost += entryCost;
- if (new Date(r.timestamp) > new Date(epe.lastUsed)) epe.lastUsed = r.timestamp;
+ if (r.timestamp > (epe.lastUsed || "")) epe.lastUsed = r.timestamp;
}
}
@@ -677,7 +704,7 @@ export async function getChartData(period = "7d") {
startOfDay.setHours(0, 0, 0, 0);
const startTime = startOfDay.getTime();
const endTime = startTime + bucketCount * bucketMs;
- const labelFn = (ts) => new Date(ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
+ const labelFn = fastHourMinute;
const buckets = Array.from({ length: bucketCount }, (_, i) => ({ label: labelFn(startTime + i * bucketMs), tokens: 0, cost: 0 }));
const rows = db.all(
@@ -699,7 +726,7 @@ export async function getChartData(period = "7d") {
if (period === "24h") {
const bucketCount = 24;
const bucketMs = 3600000;
- const labelFn = (ts) => new Date(ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
+ const labelFn = fastHourMinute;
const startTime = now - bucketCount * bucketMs;
const buckets = Array.from({ length: bucketCount }, (_, i) => ({ label: labelFn(startTime + i * bucketMs), tokens: 0, cost: 0 }));
@@ -719,7 +746,7 @@ export async function getChartData(period = "7d") {
const bucketCount = period === "7d" ? 7 : period === "30d" ? 30 : 60;
const today = new Date();
- const labelFn = (d) => d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
+ const labelFn = fastMonthDay;
// Build map of dateKey → day data
const dayRows = loadDaysInRange(db, bucketCount);
diff --git a/app/src/lib/headroom/process.js b/app/src/lib/headroom/process.js
index d50bc7ec..7e2e711f 100644
--- a/app/src/lib/headroom/process.js
+++ b/app/src/lib/headroom/process.js
@@ -1,6 +1,7 @@
import fs from "fs";
import path from "path";
import { spawn } from "child_process";
+import net from "net";
import { DATA_DIR } from "@/lib/dataDir.js";
import { findHeadroomBinary } from "./detect.js";
@@ -41,6 +42,26 @@ export function getManagedPid() {
return pid && isPidAlive(pid) ? pid : null;
}
+export function isPortInUse(port, host = "127.0.0.1") {
+ return new Promise((resolve, reject) => {
+ const server = net.createServer();
+
+ server.once("error", (error) => {
+ if (error.code === "EADDRINUSE") {
+ resolve(true);
+ return;
+ }
+ reject(error);
+ });
+
+ server.once("listening", () => {
+ server.close(() => resolve(false));
+ });
+
+ server.listen(port, host);
+ });
+}
+
export async function startHeadroomProxy({ port = DEFAULT_PORT } = {}) {
const safePort = Number(port) > 0 && Number(port) < 65536 ? Number(port) : DEFAULT_PORT;
const binary = findHeadroomBinary();
@@ -53,6 +74,12 @@ export async function startHeadroomProxy({ port = DEFAULT_PORT } = {}) {
const existing = getManagedPid();
if (existing) return { pid: existing, alreadyRunning: true };
+ if (await isPortInUse(safePort)) {
+ const err = new Error(`Port ${safePort} is already in use by another Headroom proxy or process`);
+ err.code = "PORT_IN_USE";
+ throw err;
+ }
+
ensureDir();
// spawn stdio requires fd numbers, not WriteStream objects.
const outFd = fs.openSync(LOG_FILE, "a");
diff --git a/app/src/lib/network/connectionProxy.js b/app/src/lib/network/connectionProxy.js
index f4a0ae6b..5f770610 100644
--- a/app/src/lib/network/connectionProxy.js
+++ b/app/src/lib/network/connectionProxy.js
@@ -1,188 +1,195 @@
-import { getProxyPoolById } from "@/models";
-
-// Safely normalize any value into a trimmed string.
-function normalizeString(value) {
- if (value === undefined || value === null) return "";
- return String(value).trim();
-}
-
-/**
- * Normalize legacy proxy configuration.
- */
-function normalizeLegacyProxy(providerSpecificData = {}) {
- const connectionProxyEnabled =
- providerSpecificData?.connectionProxyEnabled === true;
-
- const connectionProxyUrl = normalizeString(
- providerSpecificData?.connectionProxyUrl
- );
-
- const connectionNoProxy = normalizeString(
- providerSpecificData?.connectionNoProxy
- );
-
- return {
- connectionProxyEnabled,
- connectionProxyUrl,
- connectionNoProxy,
- };
-}
-
-/**
- * Resolve final proxy configuration.
- *
- * Priority:
- * 1. Proxy Pool
- * 2. Legacy Proxy
- * 3. No Proxy
- */
-export async function resolveConnectionProxyConfig(
- providerSpecificData = {}
-) {
- try {
- const proxyPoolIdRaw = normalizeString(
- providerSpecificData?.proxyPoolId
- );
-
- // "__none__" means explicitly disabled
- const proxyPoolId =
- proxyPoolIdRaw === "__none__" ? "" : proxyPoolIdRaw;
-
- const legacy = normalizeLegacyProxy(providerSpecificData);
-
- /**
- * -----------------------------
- * Proxy Pool Resolution
- * -----------------------------
- */
- if (proxyPoolId) {
- const proxyPool = await getProxyPoolById(proxyPoolId);
-
- const proxyUrl = normalizeString(proxyPool?.proxyUrl);
- const noProxy = normalizeString(proxyPool?.noProxy);
-
- const isValidPool =
- proxyPool &&
- proxyPool.isActive === true &&
- proxyUrl;
-
- if (isValidPool) {
- /**
- * Vercel/Cloudflare relay proxies use base URL rewriting
- * instead of HTTP_PROXY environment variables.
- */
- if (proxyPool.type === "vercel" || proxyPool.type === "cloudflare" || proxyPool.type === "deno") {
- return {
- source: proxyPool.type,
-
- proxyPoolId,
- proxyPool,
-
- connectionProxyEnabled: false,
- connectionProxyUrl: "",
- connectionNoProxy: noProxy,
-
- strictProxy: proxyPool.strictProxy === true,
-
- vercelRelayUrl: proxyUrl, // Still mapped to vercelRelayUrl in the unified payload since they use the exact same header spec
- };
- }
-
- /**
- * Standard proxy pool
- */
- return {
- source: "pool",
-
- proxyPoolId,
- proxyPool,
-
- connectionProxyEnabled: true,
- connectionProxyUrl: proxyUrl,
- connectionNoProxy: noProxy,
-
- strictProxy: proxyPool.strictProxy === true,
- };
- }
- }
-
- /**
- * -----------------------------
- * Legacy Proxy Fallback
- * -----------------------------
- */
- if (
- legacy.connectionProxyEnabled &&
- legacy.connectionProxyUrl
- ) {
- return {
- source: "legacy",
-
- proxyPoolId: proxyPoolId || null,
- proxyPool: null,
-
- ...legacy,
- };
- }
-
- /**
- * -----------------------------
- * No Proxy Config
- * -----------------------------
- */
- return {
- source: "none",
-
- proxyPoolId: proxyPoolId || null,
- proxyPool: null,
-
- ...legacy,
- };
- } catch (error) {
- console.error(
- "[resolveConnectionProxyConfig] Failed to resolve proxy config:",
- error
- );
-
- return {
- source: "error",
-
- proxyPoolId: null,
- proxyPool: null,
-
- connectionProxyEnabled: false,
- connectionProxyUrl: "",
- connectionNoProxy: "",
-
- strictProxy: false,
- };
- }
-}
-
-/**
- * Stable djb2 hash for short string fingerprints (non-cryptographic).
- */
-function djb2(str) {
- let hash = 5381;
- for (let i = 0; i < str.length; i++) {
- hash = ((hash << 5) + hash) + str.charCodeAt(i);
- hash = hash & hash;
- }
- return Math.abs(hash).toString(36);
-}
-
-/**
- * Compute a stable proxy bucket key for an account.
- * Groups accounts by the proxy they share so the semaphore and circuit
- * breaker can isolate failures per proxy.
- * @param {object} providerSpecificData
- * @returns {string} "direct" if no proxy, "proxy-
" if explicit proxy, "pool-" if proxy pool
- */
-export function getProxyHash(providerSpecificData = {}) {
- const enabled = providerSpecificData?.connectionProxyEnabled === true;
- const url = enabled ? normalizeString(providerSpecificData?.connectionProxyUrl) : "";
- if (url) return `proxy-${djb2(url)}`;
- const poolId = normalizeString(providerSpecificData?.proxyPoolId);
- if (poolId) return `pool-${djb2(poolId)}`;
- return "direct";
-}
+import { getProxyPoolById } from "@/models";
+
+// Safely normalize any value into a trimmed string.
+function normalizeString(value) {
+ if (value === undefined || value === null) return "";
+ return String(value).trim();
+}
+
+/**
+ * Normalize legacy proxy configuration.
+ */
+function normalizeLegacyProxy(providerSpecificData = {}) {
+ const connectionProxyEnabled =
+ providerSpecificData?.connectionProxyEnabled === true;
+
+ const connectionProxyUrl = normalizeString(
+ providerSpecificData?.connectionProxyUrl
+ );
+
+ const connectionNoProxy = normalizeString(
+ providerSpecificData?.connectionNoProxy
+ );
+
+ return {
+ connectionProxyEnabled,
+ connectionProxyUrl,
+ connectionNoProxy,
+ };
+}
+
+/**
+ * Resolve final proxy configuration.
+ *
+ * Priority:
+ * 1. Proxy Pool
+ * 2. Legacy Proxy
+ * 3. No Proxy
+ */
+export async function resolveConnectionProxyConfig(
+ providerSpecificData = {}
+) {
+ try {
+ const proxyPoolIdRaw = normalizeString(
+ providerSpecificData?.proxyPoolId
+ );
+
+ // "__none__" means explicitly disabled
+ const proxyPoolId =
+ proxyPoolIdRaw === "__none__" ? "" : proxyPoolIdRaw;
+
+ const legacy = normalizeLegacyProxy(providerSpecificData);
+
+ /**
+ * -----------------------------
+ * Proxy Pool Resolution
+ * -----------------------------
+ */
+ if (proxyPoolId) {
+ const proxyPool = await getProxyPoolById(proxyPoolId);
+
+ const proxyUrls = Array.isArray(proxyPool?.proxyUrls)
+ ? proxyPool.proxyUrls.map(normalizeString).filter(Boolean)
+ : [];
+ const proxyUrl = normalizeString(proxyPool?.proxyUrl) || proxyUrls[0] || "";
+ const connectionProxyUrls = proxyUrl
+ ? [proxyUrl, ...proxyUrls.filter((url) => url !== proxyUrl)]
+ : [];
+ const noProxy = normalizeString(proxyPool?.noProxy);
+
+ const isValidPool =
+ proxyPool &&
+ proxyPool.isActive === true &&
+ proxyUrl;
+
+ if (isValidPool) {
+ /**
+ * Vercel/Cloudflare relay proxies use base URL rewriting
+ * instead of HTTP_PROXY environment variables.
+ */
+ if (proxyPool.type === "vercel" || proxyPool.type === "cloudflare" || proxyPool.type === "deno") {
+ return {
+ source: proxyPool.type,
+
+ proxyPoolId,
+ proxyPool,
+
+ connectionProxyEnabled: false,
+ connectionProxyUrl: "",
+ connectionNoProxy: noProxy,
+
+ strictProxy: proxyPool.strictProxy === true,
+
+ vercelRelayUrl: proxyUrl, // Still mapped to vercelRelayUrl in the unified payload since they use the exact same header spec
+ };
+ }
+
+ /**
+ * Standard proxy pool
+ */
+ return {
+ source: "pool",
+
+ proxyPoolId,
+ proxyPool,
+
+ connectionProxyEnabled: true,
+ connectionProxyUrl: proxyUrl,
+ connectionProxyUrls,
+ connectionNoProxy: noProxy,
+
+ strictProxy: proxyPool.strictProxy === true,
+ };
+ }
+ }
+
+ /**
+ * -----------------------------
+ * Legacy Proxy Fallback
+ * -----------------------------
+ */
+ if (
+ legacy.connectionProxyEnabled &&
+ legacy.connectionProxyUrl
+ ) {
+ return {
+ source: "legacy",
+
+ proxyPoolId: proxyPoolId || null,
+ proxyPool: null,
+
+ ...legacy,
+ };
+ }
+
+ /**
+ * -----------------------------
+ * No Proxy Config
+ * -----------------------------
+ */
+ return {
+ source: "none",
+
+ proxyPoolId: proxyPoolId || null,
+ proxyPool: null,
+
+ ...legacy,
+ };
+ } catch (error) {
+ console.error(
+ "[resolveConnectionProxyConfig] Failed to resolve proxy config:",
+ error
+ );
+
+ return {
+ source: "error",
+
+ proxyPoolId: null,
+ proxyPool: null,
+
+ connectionProxyEnabled: false,
+ connectionProxyUrl: "",
+ connectionNoProxy: "",
+
+ strictProxy: false,
+ };
+ }
+}
+
+/**
+ * Stable djb2 hash for short string fingerprints (non-cryptographic).
+ */
+function djb2(str) {
+ let hash = 5381;
+ for (let i = 0; i < str.length; i++) {
+ hash = ((hash << 5) + hash) + str.charCodeAt(i);
+ hash = hash & hash;
+ }
+ return Math.abs(hash).toString(36);
+}
+
+/**
+ * Compute a stable proxy bucket key for an account.
+ * Groups accounts by the proxy they share so the semaphore and circuit
+ * breaker can isolate failures per proxy.
+ * @param {object} providerSpecificData
+ * @returns {string} "direct" if no proxy, "proxy-" if explicit proxy, "pool-" if proxy pool
+ */
+export function getProxyHash(providerSpecificData = {}) {
+ const enabled = providerSpecificData?.connectionProxyEnabled === true;
+ const url = enabled ? normalizeString(providerSpecificData?.connectionProxyUrl) : "";
+ if (url) return `proxy-${djb2(url)}`;
+ const poolId = normalizeString(providerSpecificData?.proxyPoolId);
+ if (poolId) return `pool-${djb2(poolId)}`;
+ return "direct";
+}
diff --git a/app/src/lib/oauth/services/antigravityBulkImportManager.js b/app/src/lib/oauth/services/antigravityBulkImportManager.js
new file mode 100644
index 00000000..0d811437
--- /dev/null
+++ b/app/src/lib/oauth/services/antigravityBulkImportManager.js
@@ -0,0 +1,144 @@
+import {
+ KiroBulkImportManager,
+ buildLookupResponse,
+ createFreshContext,
+ parseKiroBulkAccounts,
+} from "./kiroBulkImportManager.js";
+import { runGoogleAccountAutomation } from "./googleAutomation.js";
+import { createKiroCallbackMonitor } from "./kiroGoogleAutomation.js";
+import { exchangeTokens, generateAuthData } from "@/lib/oauth/providers";
+
+const PROVIDER_ID = "antigravity";
+const PROVIDER_LABEL = "Antigravity";
+const POLL_TIMEOUT_MS = 3 * 60_000;
+
+async function defaultBrowserLauncher(job) {
+ const { launchBulkImportBrowser } = await import("./bulkImportBrowserEngine.js");
+ return launchBulkImportBrowser({ engine: job?.engine || "chromium", proxyUrl: job?.proxyUrl || undefined });
+}
+
+function parseLocalCallbackUrl(rawUrl) {
+ try {
+ const url = new URL(rawUrl || "");
+ if (url.pathname !== "/callback") return null;
+ const code = url.searchParams.get("code");
+ return code ? { callbackUrl: rawUrl, code, state: url.searchParams.get("state") } : null;
+ } catch {
+ return null;
+ }
+}
+
+async function saveConnection({ tokenData, email }) {
+ const { createProviderConnection } = await import("../../../models/index.js");
+ const connection = await createProviderConnection({
+ provider: PROVIDER_ID,
+ authType: "oauth",
+ accessToken: tokenData.accessToken,
+ refreshToken: tokenData.refreshToken || "",
+ email: tokenData.email || email,
+ displayName: tokenData.email || email,
+ expiresAt: tokenData.expiresIn ? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString() : null,
+ providerSpecificData: {
+ authMethod: "google-bulk",
+ projectId: tokenData.projectId || "",
+ automation: "gsuite-bulk",
+ },
+ testStatus: "active",
+ });
+ return { connection };
+}
+
+class AntigravityBulkImportManager extends KiroBulkImportManager {
+ constructor({ browserLauncher = defaultBrowserLauncher, googleAutomation = runGoogleAccountAutomation } = {}) {
+ super({ browserLauncher, googleAutomation, storageName: "antigravity-bulk-import" });
+ }
+
+ async processAccount(job, account, workerId, browser = job.browser) {
+ if (job.cancelRequested || !browser) {
+ this.finalizeAccount(account, "cancelled", { error: "Job cancelled" });
+ return;
+ }
+
+ const redirectUri = job.redirectUri;
+ if (!redirectUri) {
+ this.finalizeAccount(account, "failed", { error: "Missing Antigravity OAuth redirect URI" });
+ return;
+ }
+
+ const authData = await generateAuthData(PROVIDER_ID, redirectUri);
+ const { context, page } = await createFreshContext(browser);
+ const callbackPromise = createKiroCallbackMonitor(context, page, POLL_TIMEOUT_MS, parseLocalCallbackUrl);
+ account.runtimeSession = { context, page, proxyUrl: browser.__ninerouterProxyUrl || job.proxyUrl || null };
+
+ try {
+ this.setAccountStep(account, "preparing_worker", `Worker ${workerId} is preparing Antigravity Google OAuth`);
+ await this.persistJobSnapshot(job, { forcePreview: true });
+ const automationResult = await this.googleAutomation({
+ page,
+ authUrl: authData.authUrl,
+ email: account.email,
+ password: account.password,
+ successPromise: callbackPromise,
+ shortTimeoutMs: POLL_TIMEOUT_MS,
+ serviceLabel: PROVIDER_LABEL,
+ openingStep: "opening_antigravity_oauth",
+ openingMessage: "Opening Antigravity Google OAuth",
+ successStep: "antigravity_callback_received",
+ successMessage: "Antigravity OAuth callback received",
+ onStep: (step, message) => {
+ this.setAccountStep(account, step, message);
+ void this.persistJobSnapshot(job, { forcePreview: false });
+ },
+ });
+
+ if (automationResult.status !== "success") {
+ const status = automationResult.status === "needs_manual" ? "failed" : (automationResult.status || "failed");
+ this.finalizeAccount(account, status, {
+ error: automationResult.error || "Antigravity Google automation failed.",
+ step: status,
+ message: automationResult.error || "Antigravity Google automation failed.",
+ });
+ return;
+ }
+
+ if (automationResult.state !== authData.state) {
+ throw new Error("Invalid Antigravity OAuth state");
+ }
+
+ this.setAccountStep(account, "exchanging_tokens", "Exchanging Antigravity OAuth code");
+ await this.persistJobSnapshot(job, { forcePreview: true });
+ const tokenData = await exchangeTokens(PROVIDER_ID, automationResult.code, redirectUri, null, automationResult.state);
+ const { connection } = await saveConnection({ tokenData, email: account.email });
+ this.finalizeAccount(account, "success", {
+ connectionId: connection.id,
+ step: "connection_saved",
+ message: "Antigravity connection saved successfully",
+ });
+ await this.persistJobSnapshot(job, { forcePreview: true });
+ } catch (error) {
+ this.finalizeAccount(account, "failed", {
+ error: error.message || "Unexpected Antigravity bulk import failure.",
+ step: "failed",
+ message: error.message || "Unexpected Antigravity bulk import failure.",
+ });
+ await this.persistJobSnapshot(job, { forcePreview: true });
+ } finally {
+ account.password = undefined;
+ account.runtimeSession = null;
+ await context.close().catch(() => null);
+ }
+ }
+}
+
+function getSingletonStore() {
+ if (!globalThis.__antigravityBulkImportSingleton) {
+ globalThis.__antigravityBulkImportSingleton = { manager: new AntigravityBulkImportManager() };
+ }
+ return globalThis.__antigravityBulkImportSingleton;
+}
+
+export function getAntigravityBulkImportManager() {
+ return getSingletonStore().manager;
+}
+
+export { AntigravityBulkImportManager, buildLookupResponse, parseKiroBulkAccounts };
diff --git a/app/src/lib/oauth/services/kiroGoogleAutomation.js b/app/src/lib/oauth/services/kiroGoogleAutomation.js
index ea6ca028..25df5b4d 100644
--- a/app/src/lib/oauth/services/kiroGoogleAutomation.js
+++ b/app/src/lib/oauth/services/kiroGoogleAutomation.js
@@ -1140,7 +1140,7 @@ async function handleProviderLoginGate(page, reportStep) {
return false;
}
-export function createKiroCallbackMonitor(context, page, timeoutMs = DEFAULT_MANUAL_TIMEOUT_MS) {
+export function createKiroCallbackMonitor(context, page, timeoutMs = DEFAULT_MANUAL_TIMEOUT_MS, parseCallback = parseCallbackUrl) {
let resolveOuter;
let rejectOuter;
const promise = new Promise((resolve, reject) => {
@@ -1174,19 +1174,19 @@ export function createKiroCallbackMonitor(context, page, timeoutMs = DEFAULT_MAN
trackedPages.add(trackedPage);
const onFrame = (frame) => {
- const parsed = parseCallbackUrl(frame?.url?.() || "");
+ const parsed = parseCallback(frame?.url?.() || "");
if (parsed) settle(parsed);
};
const onRequest = (request) => {
- const parsed = parseCallbackUrl(request?.url?.() || "");
+ const parsed = parseCallback(request?.url?.() || "");
if (parsed) settle(parsed);
};
const onRequestFailed = (request) => {
- const parsed = parseCallbackUrl(request?.url?.() || "");
+ const parsed = parseCallback(request?.url?.() || "");
if (parsed) settle(parsed);
};
const onLoadState = () => {
- const parsed = parseCallbackUrl(trackedPage.url?.() || "");
+ const parsed = parseCallback(trackedPage.url?.() || "");
if (parsed) settle(parsed);
};
@@ -1204,7 +1204,7 @@ export function createKiroCallbackMonitor(context, page, timeoutMs = DEFAULT_MAN
trackedPage.off("load", onLoadState);
});
- const current = parseCallbackUrl(trackedPage.url?.() || "");
+ const current = parseCallback(trackedPage.url?.() || "");
if (current) settle(current);
}
diff --git a/app/src/lib/requestDetailsDb.js b/app/src/lib/requestDetailsDb.js
index c7dcbc35..c72b7793 100644
--- a/app/src/lib/requestDetailsDb.js
+++ b/app/src/lib/requestDetailsDb.js
@@ -1,4 +1,4 @@
// Shim → re-export from new SQLite-based DB layer (src/lib/db/)
-export {
- saveRequestDetail, getRequestDetails, getRequestDetailById,
-} from "@/lib/db/index.js";
+export {
+ saveRequestDetail, getRequestDetails, getRequestDetailProviders, getRequestDetailById,
+} from "@/lib/db/index.js";
diff --git a/app/src/shared/components/Header.js b/app/src/shared/components/Header.js
index 34004250..e4ab0561 100644
--- a/app/src/shared/components/Header.js
+++ b/app/src/shared/components/Header.js
@@ -5,7 +5,6 @@ import { usePathname } from "next/navigation";
import Link from "next/link";
import ProviderIcon from "@/shared/components/ProviderIcon";
import HeaderMenu from "@/shared/components/HeaderMenu";
-import HeaderLanguage from "@/shared/components/HeaderLanguage";
import ThemeToggle from "@/shared/components/ThemeToggle";
import DonateModal from "@/shared/components/DonateModal";
import { useHeaderSearchStore } from "@/store/headerSearchStore";
@@ -312,7 +311,6 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
)}
-
diff --git a/app/src/shared/components/LanguageSwitcher.js b/app/src/shared/components/LanguageSwitcher.js
index 62d4267a..9c5e6b1b 100644
--- a/app/src/shared/components/LanguageSwitcher.js
+++ b/app/src/shared/components/LanguageSwitcher.js
@@ -18,38 +18,8 @@ function getLocaleFromCookie() {
const getLocaleInfo = (locale) => {
const locales = {
"en": { name: "English" },
- "vi": { name: "Tiếng Việt" },
"zh-CN": { name: "简体中文" },
- "zh-TW": { name: "繁體中文" },
- "ja": { name: "日本語" },
- "pt-BR": { name: "Português (Brasil)" },
- "pt-PT": { name: "Português (Portugal)" },
- "ko": { name: "한국어" },
- "es": { name: "Español" },
- "de": { name: "Deutsch" },
- "fr": { name: "Français" },
- "he": { name: "עברית" },
- "ar": { name: "العربية" },
- "ru": { name: "Русский" },
- "pl": { name: "Polski" },
- "cs": { name: "Čeština" },
- "nl": { name: "Nederlands" },
- "tr": { name: "Türkçe" },
- "uk": { name: "Українська" },
- "tl": { name: "Tagalog" },
"id": { name: "Indonesia" },
- "th": { name: "ไทย" },
- "hi": { name: "हिन्दी" },
- "bn": { name: "বাংলা" },
- "ur": { name: "اردو" },
- "ro": { name: "Română" },
- "sv": { name: "Svenska" },
- "it": { name: "Italiano" },
- "el": { name: "Ελληνικά" },
- "hu": { name: "Magyar" },
- "fi": { name: "Suomi" },
- "da": { name: "Dansk" },
- "no": { name: "Norsk" }
};
return locales[locale] || { name: locale };
};
diff --git a/app/src/shared/components/NoAuthProxyCard.js b/app/src/shared/components/NoAuthProxyCard.js
index bcddd4d7..84b13140 100644
--- a/app/src/shared/components/NoAuthProxyCard.js
+++ b/app/src/shared/components/NoAuthProxyCard.js
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import Card from "./Card";
import Select from "./Select";
import Badge from "./Badge";
+import { PROXY_ROTATION_INTERVAL_OPTIONS } from "@/shared/constants/proxyRotation.js";
const NONE_PROXY_POOL_VALUE = "__none__";
@@ -85,19 +86,16 @@ export default function NoAuthProxyCard({ providerId }) {
...proxyPools.map((pool) => ({ value: pool.id, label: pool.name })),
]}
/>
- {["mimo-free", "opencode"].includes(providerId) && (
saveStrategy({ autoRotateProxyMinutes: e.target.value ? Number(e.target.value) : null })}
disabled={saving}
- options={[
- { value: "", label: "Off" },
- { value: 5, label: "Every 5m" },
- { value: 10, label: "Every 10m" },
- { value: 15, label: "Every 15m" },
- ]}
+ options={PROXY_ROTATION_INTERVAL_OPTIONS.map((minutes) => ({
+ value: minutes ?? "",
+ label: minutes ? `Every ${minutes}m` : "Off",
+ }))}
/>
saveStrategy({ autoRotateProxyOnError: strategy.autoRotateProxyOnError !== true })}
@@ -109,7 +107,6 @@ export default function NoAuthProxyCard({ providerId }) {
- )}
);
}
diff --git a/app/src/shared/constants/locales.js b/app/src/shared/constants/locales.js
index ca1caa20..753fb7ad 100644
--- a/app/src/shared/constants/locales.js
+++ b/app/src/shared/constants/locales.js
@@ -1,36 +1,6 @@
-// Centralized locale display flags (shared across UI components)
-export const LOCALE_FLAGS = {
- "en": "🇺🇸",
- "vi": "🇻🇳",
- "zh-CN": "🇨🇳",
- "zh-TW": "🇹🇼",
- "ja": "🇯🇵",
- "pt-BR": "🇧🇷",
- "pt-PT": "🇵🇹",
- "ko": "🇰🇷",
- "es": "🇪🇸",
- "de": "🇩🇪",
- "fr": "🇫🇷",
- "he": "🇮🇱",
- "ar": "🇸🇦",
- "ru": "🇷🇺",
- "pl": "🇵🇱",
- "cs": "🇨🇿",
- "nl": "🇳🇱",
- "tr": "🇹🇷",
- "uk": "🇺🇦",
- "tl": "🇵🇭",
- "id": "🇮🇩",
- "th": "🇹🇭",
- "hi": "🇮🇳",
- "bn": "🇧🇩",
- "ur": "🇵🇰",
- "ro": "🇷🇴",
- "sv": "🇸🇪",
- "it": "🇮🇹",
- "el": "🇬🇷",
- "hu": "🇭🇺",
- "fi": "🇫🇮",
- "da": "🇩🇰",
- "no": "🇳🇴",
-};
+// Centralized locale display flags (shared across UI components)
+export const LOCALE_FLAGS = {
+ "en": "🇺🇸",
+ "zh-CN": "🇨🇳",
+ "id": "🇮🇩",
+};
diff --git a/app/src/shared/constants/proxyRotation.js b/app/src/shared/constants/proxyRotation.js
new file mode 100644
index 00000000..a319c796
--- /dev/null
+++ b/app/src/shared/constants/proxyRotation.js
@@ -0,0 +1,5 @@
+export const PROXY_ROTATION_INTERVAL_OPTIONS = [null, 1, 5, 10, 15, 30];
+
+export function isSupportedProxyRotationInterval(minutes) {
+ return PROXY_ROTATION_INTERVAL_OPTIONS.includes(minutes) && minutes !== null;
+}
diff --git a/app/src/sse/handlers/chat.js b/app/src/sse/handlers/chat.js
index 96d2711b..7e923e8c 100644
--- a/app/src/sse/handlers/chat.js
+++ b/app/src/sse/handlers/chat.js
@@ -437,6 +437,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
apiKey,
ccFilterNaming: !!chatSettings.ccFilterNaming,
rtkEnabled: chatSettings.rtkEnabled !== false,
+ webSearchSaverEnabled: chatSettings.webSearchSaverEnabled !== false,
headroomEnabled: chatSettings.headroomEnabled === true,
headroomUrl: chatSettings.headroomUrl || DEFAULT_HEADROOM_URL,
headroomCompressUserMessages: chatSettings.headroomCompressUserMessages === true,
@@ -446,6 +447,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
cavemanLevel: chatSettings.cavemanLevel || "full",
ponytailEnabled: chatSettings.ponytailEnabled === true,
ponytailLevel: chatSettings.ponytailLevel || "full",
+ styleInjectionMethod: chatSettings.styleInjectionMethod || "old",
providerThinking,
externalSignal,
// Detect source format by endpoint + body
diff --git a/app/src/sse/handlers/fetch.js b/app/src/sse/handlers/fetch.js
index 3892465a..82211ab1 100644
--- a/app/src/sse/handlers/fetch.js
+++ b/app/src/sse/handlers/fetch.js
@@ -128,7 +128,8 @@ async function handleSingleProviderFetch(body, providerInput, request, apiKey, s
const targetUrl = body.url;
const format = body.format;
const maxCharacters = body.max_characters;
- const providerId = resolveProviderId(providerInput);
+ const requestedProviderId = resolveProviderId(providerInput);
+ const providerId = requestedProviderId === "exa" ? "tavily" : requestedProviderId;
const resolvedProvider = AI_PROVIDERS[providerId];
if (!resolvedProvider) {
diff --git a/app/src/sse/handlers/search.js b/app/src/sse/handlers/search.js
index c23b980a..26a790dd 100644
--- a/app/src/sse/handlers/search.js
+++ b/app/src/sse/handlers/search.js
@@ -1,236 +1,237 @@
-import {
- getProviderCredentials,
- markAccountUnavailable,
- clearAccountError,
- extractApiKey,
- isValidApiKey,
- isProviderAllowed,
- isComboAllowed,
- isKindAllowed,
- isTrustedInternalRequest,
-} from "../services/auth.js";
-import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
-import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
-import * as log from "../utils/logger.js";
-import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
-import { handleComboChat, getComboModelsFromData, stripComboPrefix } from "open-sse/services/combo.js";
-import { getSettings, getCombos } from "@/lib/localDb";
-import { AI_PROVIDERS, resolveProviderId } from "@/shared/constants/providers.js";
-import { isModelAllowed } from "../services/allowedModels.js";
-import { handleSearchCore } from "open-sse/handlers/search/index.js";
-
-/**
- * Handle web search request for the SSE/Next.js server.
- * Provider IS the model (no model field). Mirrors handleEmbeddings auth + fallback flow.
- *
- * @param {Request} request
- */
-export async function handleSearch(request) {
- let body;
- try {
- body = await request.json();
- } catch {
- log.warn("SEARCH", "Invalid JSON body");
- return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
- }
-
- const url = new URL(request.url);
- // Accept either `provider` or `model` (UI sends `model` since provider IS the model for webSearch)
- const providerInput = body.provider || body.model;
- const query = body.query;
-
- log.request("POST", `${url.pathname} | ${providerInput}`);
-
- // Log API key (masked)
- const apiKey = extractApiKey(request);
- if (apiKey) {
- log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`);
- } else {
- log.debug("AUTH", "No API key provided (local mode)");
- }
-
- // Enforce API key if enabled in settings
- const settings = await getSettings();
- let apiKeyInfo = null;
- // Trusted internal (dashboard/CLI) requests act as the local owner — bypass ACL.
- const trustedInternal = await isTrustedInternalRequest(request);
- if (!trustedInternal && settings.requireApiKey) {
- if (!apiKey) {
- log.warn("AUTH", "Missing API key (requireApiKey=true)");
- return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
- }
- apiKeyInfo = await isValidApiKey(apiKey);
- if (!apiKeyInfo) {
- log.warn("AUTH", "Invalid API key (requireApiKey=true)");
- return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
- }
- }
-
- if (!providerInput || typeof providerInput !== "string") {
- log.warn("SEARCH", "Missing provider/model");
- return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: provider (or model)");
- }
-
- if (!isKindAllowed(apiKeyInfo, "web")) {
- log.warn("AUTH", "Web search kind not allowed for API key");
- return errorResponse(HTTP_STATUS.FORBIDDEN, "Web search requests are not allowed for this API key");
- }
-
- if (!query || typeof query !== "string" || !query.trim()) {
- log.warn("SEARCH", "Missing query");
- return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: query");
- }
-
- // Combo expansion: providerInput may be a combo name → run fallback/round-robin across providers
- const combos = await getCombos();
- const comboModels = getComboModelsFromData(providerInput, combos);
- if (comboModels) {
- if (!isComboAllowed(apiKeyInfo, providerInput)) {
- return errorResponse(HTTP_STATUS.FORBIDDEN, `Combo "${providerInput}" is not allowed for this API key`);
- }
- const comboNameSearch = stripComboPrefix(providerInput);
- const comboStrategies = settings.comboStrategies || {};
- const comboStrategy = comboStrategies[comboNameSearch]?.fallbackStrategy || settings.comboStrategy || "fallback";
- const comboStickyLimit = settings.comboStickyRoundRobinLimit;
- log.info("SEARCH", `Combo "${comboNameSearch}" with ${comboModels.length} providers (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`);
- return handleComboChat({
-
- body,
- models: comboModels,
- handleSingleModel: (b, m) => handleSingleProviderSearch(b, m, request, apiKey, settings, apiKeyInfo),
- log,
- comboName: comboNameSearch,
- comboStrategy,
- comboStickyLimit
- });
- }
-
- return handleSingleProviderSearch(body, providerInput, request, apiKey, settings, apiKeyInfo);
-}
-
-async function handleSingleProviderSearch(body, providerInput, request, apiKey, settings, apiKeyInfo = null) {
- const query = body.query;
- const providerId = resolveProviderId(providerInput);
- const resolvedProvider = AI_PROVIDERS[providerId];
-
- if (!resolvedProvider) {
- log.warn("SEARCH", "Unknown provider", { provider: providerInput });
- return errorResponse(HTTP_STATUS.BAD_REQUEST, `Unknown provider: ${providerInput}`);
- }
-
- const providerConfig = resolvedProvider.searchConfig;
- const supportsSearch = !!providerConfig || !!resolvedProvider.searchViaChat;
-
- if (!supportsSearch) {
- log.warn("SEARCH", "Provider does not support web search", { provider: providerId });
- return errorResponse(HTTP_STATUS.BAD_REQUEST, `Provider ${providerId} does not support web search`);
- }
-
- if (!(await isProviderAllowed(apiKeyInfo, providerId))) {
- log.warn("AUTH", `Provider "${providerId}" not allowed for API key`, { provider: providerId });
- return errorResponse(HTTP_STATUS.FORBIDDEN, `Provider "${providerId}" is not allowed for this API key`);
- }
-
- const alias = AI_PROVIDERS[providerId]?.alias || providerId;
- const searchModelId = `${alias}/search`;
- if (!(await isModelAllowed(searchModelId, apiKeyInfo))) {
- log.warn("SEARCH", `Search model not in available models list`, { model: searchModelId });
- return errorResponse(HTTP_STATUS.NOT_FOUND, `Model "${searchModelId}" is not available. Only models listed in /v1/models can be used.`);
- }
-
- if (providerInput !== providerId) {
- log.info("ROUTING", `${providerInput} → ${providerId}`);
- } else {
- log.info("ROUTING", `Provider: ${providerId}`);
- }
-
- // Sanitized body forwarded to core
- const coreBody = {
- query: query.trim(),
- provider: providerId,
- max_results: body.max_results,
- search_type: body.search_type,
- country: body.country,
- language: body.language,
- time_range: body.time_range,
- offset: body.offset,
- domain_filter: body.domain_filter,
- content_options: body.content_options,
- provider_options: body.provider_options
- };
-
- // No-auth providers (e.g. searxng) bypass credential lookup
- if (resolvedProvider.noAuth) {
- log.info("AUTH", `\x1b[32m${providerId} no-auth mode\x1b[0m`);
- const result = await handleSearchCore({
- body: coreBody,
- provider: resolvedProvider,
- providerConfig,
- credentials: null,
- log
- });
- if (result.success) return result.response;
- return result.response;
- }
-
- // Credential + fallback loop
- const excludeConnectionIds = new Set();
- let lastError = null;
- let lastStatus = null;
-
- while (true) {
- const credentials = await getProviderCredentials(providerId, excludeConnectionIds);
-
- if (!credentials || credentials.allRateLimited) {
- if (credentials?.allRateLimited) {
- const errorMsg = lastError || credentials.lastError || "Unavailable";
- const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE;
- log.warn("SEARCH", `[${providerId}] ${errorMsg} (${credentials.retryAfterHuman})`);
- return unavailableResponse(status, `[${providerId}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman);
- }
- if (excludeConnectionIds.size === 0) {
- log.error("AUTH", `No credentials for provider: ${providerId}`);
- return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${providerId}`);
- }
- log.warn("SEARCH", "No more accounts available", { provider: providerId });
- return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable");
- }
-
- log.info("AUTH", `\x1b[32mUsing ${providerId} account: ${credentials.connectionName}\x1b[0m`);
-
- const refreshedCredentials = await checkAndRefreshToken(providerId, credentials);
-
- const result = await handleSearchCore({
- body: coreBody,
- provider: resolvedProvider,
- providerConfig,
- credentials: refreshedCredentials,
- log,
- onCredentialsRefreshed: async (newCreds) => {
- await updateProviderCredentials(credentials.connectionId, {
- accessToken: newCreds.accessToken,
- refreshToken: newCreds.refreshToken,
- providerSpecificData: newCreds.providerSpecificData,
- testStatus: "active"
- });
- },
- onRequestSuccess: async () => {
- await clearAccountError(credentials.connectionId, credentials);
- }
- });
-
- if (result.success) return result.response;
-
- const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, providerId);
-
- if (shouldFallback) {
- log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`);
- excludeConnectionIds.add(credentials.connectionId);
- lastError = result.error;
- lastStatus = result.status;
- continue;
- }
-
- return result.response;
- }
-}
+import {
+ getProviderCredentials,
+ markAccountUnavailable,
+ clearAccountError,
+ extractApiKey,
+ isValidApiKey,
+ isProviderAllowed,
+ isComboAllowed,
+ isKindAllowed,
+ isTrustedInternalRequest,
+} from "../services/auth.js";
+import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
+import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
+import * as log from "../utils/logger.js";
+import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
+import { handleComboChat, getComboModelsFromData, stripComboPrefix } from "open-sse/services/combo.js";
+import { getSettings, getCombos } from "@/lib/localDb";
+import { AI_PROVIDERS, resolveProviderId } from "@/shared/constants/providers.js";
+import { isModelAllowed } from "../services/allowedModels.js";
+import { handleSearchCore } from "open-sse/handlers/search/index.js";
+
+/**
+ * Handle web search request for the SSE/Next.js server.
+ * Provider IS the model (no model field). Mirrors handleEmbeddings auth + fallback flow.
+ *
+ * @param {Request} request
+ */
+export async function handleSearch(request) {
+ let body;
+ try {
+ body = await request.json();
+ } catch {
+ log.warn("SEARCH", "Invalid JSON body");
+ return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
+ }
+
+ const url = new URL(request.url);
+ // Accept either `provider` or `model` (UI sends `model` since provider IS the model for webSearch)
+ const providerInput = body.provider || body.model;
+ const query = body.query;
+
+ log.request("POST", `${url.pathname} | ${providerInput}`);
+
+ // Log API key (masked)
+ const apiKey = extractApiKey(request);
+ if (apiKey) {
+ log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`);
+ } else {
+ log.debug("AUTH", "No API key provided (local mode)");
+ }
+
+ // Enforce API key if enabled in settings
+ const settings = await getSettings();
+ let apiKeyInfo = null;
+ // Trusted internal (dashboard/CLI) requests act as the local owner — bypass ACL.
+ const trustedInternal = await isTrustedInternalRequest(request);
+ if (!trustedInternal && settings.requireApiKey) {
+ if (!apiKey) {
+ log.warn("AUTH", "Missing API key (requireApiKey=true)");
+ return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
+ }
+ apiKeyInfo = await isValidApiKey(apiKey);
+ if (!apiKeyInfo) {
+ log.warn("AUTH", "Invalid API key (requireApiKey=true)");
+ return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
+ }
+ }
+
+ if (!providerInput || typeof providerInput !== "string") {
+ log.warn("SEARCH", "Missing provider/model");
+ return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: provider (or model)");
+ }
+
+ if (!isKindAllowed(apiKeyInfo, "web")) {
+ log.warn("AUTH", "Web search kind not allowed for API key");
+ return errorResponse(HTTP_STATUS.FORBIDDEN, "Web search requests are not allowed for this API key");
+ }
+
+ if (!query || typeof query !== "string" || !query.trim()) {
+ log.warn("SEARCH", "Missing query");
+ return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: query");
+ }
+
+ // Combo expansion: providerInput may be a combo name → run fallback/round-robin across providers
+ const combos = await getCombos();
+ const comboModels = getComboModelsFromData(providerInput, combos);
+ if (comboModels) {
+ if (!isComboAllowed(apiKeyInfo, providerInput)) {
+ return errorResponse(HTTP_STATUS.FORBIDDEN, `Combo "${providerInput}" is not allowed for this API key`);
+ }
+ const comboNameSearch = stripComboPrefix(providerInput);
+ const comboStrategies = settings.comboStrategies || {};
+ const comboStrategy = comboStrategies[comboNameSearch]?.fallbackStrategy || settings.comboStrategy || "fallback";
+ const comboStickyLimit = settings.comboStickyRoundRobinLimit;
+ log.info("SEARCH", `Combo "${comboNameSearch}" with ${comboModels.length} providers (strategy: ${comboStrategy}, sticky: ${comboStickyLimit})`);
+ return handleComboChat({
+
+ body,
+ models: comboModels,
+ handleSingleModel: (b, m) => handleSingleProviderSearch(b, m, request, apiKey, settings, apiKeyInfo),
+ log,
+ comboName: comboNameSearch,
+ comboStrategy,
+ comboStickyLimit
+ });
+ }
+
+ return handleSingleProviderSearch(body, providerInput, request, apiKey, settings, apiKeyInfo);
+}
+
+async function handleSingleProviderSearch(body, providerInput, request, apiKey, settings, apiKeyInfo = null) {
+ const query = body.query;
+ const requestedProviderId = resolveProviderId(providerInput);
+ const providerId = requestedProviderId === "exa" ? "tavily" : requestedProviderId;
+ const resolvedProvider = AI_PROVIDERS[providerId];
+
+ if (!resolvedProvider) {
+ log.warn("SEARCH", "Unknown provider", { provider: providerInput });
+ return errorResponse(HTTP_STATUS.BAD_REQUEST, `Unknown provider: ${providerInput}`);
+ }
+
+ const providerConfig = resolvedProvider.searchConfig;
+ const supportsSearch = !!providerConfig || !!resolvedProvider.searchViaChat;
+
+ if (!supportsSearch) {
+ log.warn("SEARCH", "Provider does not support web search", { provider: providerId });
+ return errorResponse(HTTP_STATUS.BAD_REQUEST, `Provider ${providerId} does not support web search`);
+ }
+
+ if (!(await isProviderAllowed(apiKeyInfo, providerId))) {
+ log.warn("AUTH", `Provider "${providerId}" not allowed for API key`, { provider: providerId });
+ return errorResponse(HTTP_STATUS.FORBIDDEN, `Provider "${providerId}" is not allowed for this API key`);
+ }
+
+ const alias = AI_PROVIDERS[providerId]?.alias || providerId;
+ const searchModelId = `${alias}/search`;
+ if (!(await isModelAllowed(searchModelId, apiKeyInfo))) {
+ log.warn("SEARCH", `Search model not in available models list`, { model: searchModelId });
+ return errorResponse(HTTP_STATUS.NOT_FOUND, `Model "${searchModelId}" is not available. Only models listed in /v1/models can be used.`);
+ }
+
+ if (providerInput !== providerId) {
+ log.info("ROUTING", `${providerInput} → ${providerId}`);
+ } else {
+ log.info("ROUTING", `Provider: ${providerId}`);
+ }
+
+ // Sanitized body forwarded to core
+ const coreBody = {
+ query: query.trim(),
+ provider: providerId,
+ max_results: body.max_results,
+ search_type: body.search_type,
+ country: body.country,
+ language: body.language,
+ time_range: body.time_range,
+ offset: body.offset,
+ domain_filter: body.domain_filter,
+ content_options: body.content_options,
+ provider_options: body.provider_options
+ };
+
+ // No-auth providers (e.g. searxng) bypass credential lookup
+ if (resolvedProvider.noAuth) {
+ log.info("AUTH", `\x1b[32m${providerId} no-auth mode\x1b[0m`);
+ const result = await handleSearchCore({
+ body: coreBody,
+ provider: resolvedProvider,
+ providerConfig,
+ credentials: null,
+ log
+ });
+ if (result.success) return result.response;
+ return result.response;
+ }
+
+ // Credential + fallback loop
+ const excludeConnectionIds = new Set();
+ let lastError = null;
+ let lastStatus = null;
+
+ while (true) {
+ const credentials = await getProviderCredentials(providerId, excludeConnectionIds);
+
+ if (!credentials || credentials.allRateLimited) {
+ if (credentials?.allRateLimited) {
+ const errorMsg = lastError || credentials.lastError || "Unavailable";
+ const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE;
+ log.warn("SEARCH", `[${providerId}] ${errorMsg} (${credentials.retryAfterHuman})`);
+ return unavailableResponse(status, `[${providerId}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman);
+ }
+ if (excludeConnectionIds.size === 0) {
+ log.error("AUTH", `No credentials for provider: ${providerId}`);
+ return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${providerId}`);
+ }
+ log.warn("SEARCH", "No more accounts available", { provider: providerId });
+ return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable");
+ }
+
+ log.info("AUTH", `\x1b[32mUsing ${providerId} account: ${credentials.connectionName}\x1b[0m`);
+
+ const refreshedCredentials = await checkAndRefreshToken(providerId, credentials);
+
+ const result = await handleSearchCore({
+ body: coreBody,
+ provider: resolvedProvider,
+ providerConfig,
+ credentials: refreshedCredentials,
+ log,
+ onCredentialsRefreshed: async (newCreds) => {
+ await updateProviderCredentials(credentials.connectionId, {
+ accessToken: newCreds.accessToken,
+ refreshToken: newCreds.refreshToken,
+ providerSpecificData: newCreds.providerSpecificData,
+ testStatus: "active"
+ });
+ },
+ onRequestSuccess: async () => {
+ await clearAccountError(credentials.connectionId, credentials);
+ }
+ });
+
+ if (result.success) return result.response;
+
+ const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, providerId);
+
+ if (shouldFallback) {
+ log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`);
+ excludeConnectionIds.add(credentials.connectionId);
+ lastError = result.error;
+ lastStatus = result.status;
+ continue;
+ }
+
+ return result.response;
+ }
+}
diff --git a/app/src/sse/services/auth.js b/app/src/sse/services/auth.js
index 288df5a6..456e3ac0 100644
--- a/app/src/sse/services/auth.js
+++ b/app/src/sse/services/auth.js
@@ -1,494 +1,498 @@
-import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings, updateSettings, getProviderNodeById, getProxyPools } from "@/lib/localDb";
-import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
-import { formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate, getEarliestModelLockUntil } from "open-sse/services/accountFallback.js";
-import { classify429 } from "open-sse/utils/classify429.js";
-import { MAX_RATE_LIMIT_COOLDOWN_MS } from "open-sse/config/errorConfig.js";
-import { resolveProviderId, FREE_PROVIDERS, AI_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isCustomEmbeddingProvider } from "@/shared/constants/providers.js";
-import * as log from "../utils/logger.js";
-
-// Re-export the internal-trust gate so handlers can import it alongside the
-// other ACL helpers. Implementation lives in internalTrust.js (dependency-light
-// + independently unit-tested for exploit resistance).
-export { isTrustedInternalRequest } from "./internalTrust.js";
-
-// Per-provider mutex — allows parallel credential selection across different providers
-// while preventing races within the same provider's account rotation.
-const _providerMutexes = new Map();
-
-function getProviderMutex(provider) {
- if (!_providerMutexes.has(provider)) {
- _providerMutexes.set(provider, Promise.resolve());
- }
- return _providerMutexes.get(provider);
-}
-
-/**
- * Get provider credentials from localDb
- * Filters out unavailable accounts and returns the selected account based on strategy
- * @param {string} provider - Provider name
- * @param {Set|string|null} excludeConnectionIds - Connection ID(s) to exclude (for retry with next account)
- * @param {string|null} model - Model name for per-model rate limit filtering
- */
-export async function getProviderCredentials(provider, excludeConnectionIds = null, model = null, options = {}) {
- // Normalize to Set for consistent handling
- const excludeSet = excludeConnectionIds instanceof Set
- ? excludeConnectionIds
- : (excludeConnectionIds ? new Set([excludeConnectionIds]) : new Set());
- const preferredConnectionId = options?.preferredConnectionId || null;
- // Acquire per-provider mutex to prevent race conditions within same provider
- const currentMutex = getProviderMutex(provider);
- let resolveMutex;
- _providerMutexes.set(provider, new Promise(resolve => { resolveMutex = resolve; }));
-
- try {
- await currentMutex;
-
- // Resolve alias to provider ID (e.g., "kc" -> "kilocode")
- const providerId = resolveProviderId(provider);
-
- // Inject a virtual connection for no-auth free providers (with optional proxy pool from settings)
- if (FREE_PROVIDERS[providerId]?.noAuth) {
- const settings = await getSettings();
- const override = (settings.providerStrategies || {})[providerId] || {};
- if (override.inactive === true) {
- log.warn("AUTH", `${providerId} public provider inactive until manual re-enable`);
- return null;
- }
- const virtualConn = await maybeRotateProxyByTimer({ id: "noauth", providerSpecificData: override, name: "Public" }, providerId);
- const resolvedProxy = await resolveConnectionProxyConfig({ proxyPoolId: virtualConn.providerSpecificData?.proxyPoolId || "" });
- return {
- id: "noauth",
- connectionName: "Public",
- isActive: true,
- accessToken: "public",
- providerSpecificData: {
- ...virtualConn.providerSpecificData,
- connectionProxyEnabled: resolvedProxy.connectionProxyEnabled,
- connectionProxyUrl: resolvedProxy.connectionProxyUrl,
- connectionNoProxy: resolvedProxy.connectionNoProxy,
- connectionProxyPoolId: resolvedProxy.proxyPoolId || null,
- vercelRelayUrl: resolvedProxy.vercelRelayUrl || "",
- },
- connectionId: "noauth",
- };
- }
-
- const connections = await getProviderConnections({ provider: providerId, isActive: true });
- log.debug("AUTH", `${provider} | total connections: ${connections.length}, excludeIds: ${excludeSet.size > 0 ? [...excludeSet].join(",") : "none"}, model: ${model || "any"}`);
-
- if (connections.length === 0) {
- log.warn("AUTH", `No credentials for ${provider}`);
- return null;
- }
-
- // Filter out model-locked and excluded connections
- const availableConnections = connections.filter(c => {
- if (excludeSet.has(c.id)) return false;
- if (isModelLockActive(c, model)) return false;
- return true;
- });
-
- log.debug("AUTH", `${provider} | available: ${availableConnections.length}/${connections.length}`);
- connections.forEach(c => {
- const excluded = excludeSet.has(c.id);
- const locked = isModelLockActive(c, model);
- if (excluded || locked) {
- const lockUntil = getEarliestModelLockUntil(c);
- log.debug("AUTH", ` → ${c.id?.slice(0, 8)} | ${excluded ? "excluded" : ""} ${locked ? `modelLocked(${model}) until ${lockUntil}` : ""}`);
- }
- });
-
- if (availableConnections.length === 0) {
- // Find earliest lock expiry across all connections for retry timing
- const lockedConns = connections.filter(c => isModelLockActive(c, model));
- const expiries = lockedConns.flatMap(c => { const t = getEarliestModelLockUntil(c); return t ? [t] : []; });
- const earliest = expiries.length > 0 ? expiries.reduce((a, b) => a < b ? a : b) : null;
- if (earliest) {
- const earliestConn = lockedConns[0];
- log.warn("AUTH", `${provider} | all ${connections.length} accounts locked for ${model || "all"} (${formatRetryAfter(earliest)}) | lastError=${earliestConn?.lastError?.slice(0, 50)}`);
- return {
- allRateLimited: true,
- connectionId: earliestConn?.id || null,
- retryAfter: earliest,
- retryAfterHuman: formatRetryAfter(earliest),
- lastError: earliestConn?.lastError || null,
- lastErrorCode: earliestConn?.errorCode || null
- };
- }
- log.warn("AUTH", `${provider} | all ${connections.length} accounts unavailable`);
- return null;
- }
-
- const settings = await getSettings();
- // Per-provider strategy overrides global setting
- const providerOverride = (settings.providerStrategies || {})[providerId] || {};
- const strategy = providerOverride.fallbackStrategy || settings.fallbackStrategy || "fill-first";
-
- let connection;
- // Pin to preferred connection if specified and available
- if (preferredConnectionId) {
- connection = availableConnections.find((c) => c.id === preferredConnectionId);
- if (connection) {
- log.info("AUTH", `${provider} | pinned to ${connection.id?.slice(0, 8)} (${connection.name || connection.email || "unnamed"})`);
- }
- }
- if (connection) {
- // skip strategy
- } else if (strategy === "round-robin") {
- const stickyLimit = providerOverride.stickyRoundRobinLimit || settings.stickyRoundRobinLimit || 3;
-
- // Sort by lastUsed (most recent first) to find current candidate
- const byRecency = availableConnections.toSorted((a, b) => {
- if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999);
- if (!a.lastUsedAt) return 1;
- if (!b.lastUsedAt) return -1;
- return new Date(b.lastUsedAt) - new Date(a.lastUsedAt);
- });
-
- const current = byRecency[0];
- const currentCount = current?.consecutiveUseCount || 0;
-
- if (current && current.lastUsedAt && currentCount < stickyLimit) {
- // Stay with current account
- connection = current;
- // Update lastUsedAt and increment count (await to ensure persistence)
- await updateProviderConnection(connection.id, {
- lastUsedAt: new Date().toISOString(),
- consecutiveUseCount: (connection.consecutiveUseCount || 0) + 1
- });
- } else {
- // Pick the least recently used (excluding current if possible)
- const sortedByOldest = availableConnections.toSorted((a, b) => {
- if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999);
- if (!a.lastUsedAt) return -1;
- if (!b.lastUsedAt) return 1;
- return new Date(a.lastUsedAt) - new Date(b.lastUsedAt);
- });
-
- connection = sortedByOldest[0];
-
- // Update lastUsedAt and reset count to 1 (await to ensure persistence)
- await updateProviderConnection(connection.id, {
- lastUsedAt: new Date().toISOString(),
- consecutiveUseCount: 1
- });
- }
- } else {
- // Default: fill-first (already sorted by priority in getProviderConnections)
- connection = availableConnections[0];
- }
-
- connection = await maybeRotateProxyByTimer(connection, providerId);
- const resolvedProxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {});
-
- return {
- authType: connection.authType,
- apiKey: connection.apiKey,
- accessToken: connection.accessToken,
- refreshToken: connection.refreshToken,
- idToken: connection.idToken,
- expiresAt: connection.expiresAt,
- expiresIn: connection.expiresIn,
- lastRefreshAt: connection.lastRefreshAt,
- projectId: connection.projectId,
- connectionName: connection.displayName || connection.name || connection.email || connection.id,
- copilotToken: connection.providerSpecificData?.copilotToken,
- providerSpecificData: {
- ...(connection.providerSpecificData || {}),
- connectionProxyEnabled: resolvedProxy.connectionProxyEnabled,
- connectionProxyUrl: resolvedProxy.connectionProxyUrl,
- connectionNoProxy: resolvedProxy.connectionNoProxy,
- connectionProxyPoolId: resolvedProxy.proxyPoolId || null,
- vercelRelayUrl: resolvedProxy.vercelRelayUrl || "",
- },
- connectionId: connection.id,
- // Include current status for optimization check
- testStatus: connection.testStatus,
- lastError: connection.lastError,
- // Pass full connection for clearAccountError to read modelLock_* keys
- _connection: connection
- };
- } finally {
- if (resolveMutex) resolveMutex();
- }
-}
-
-/**
- * Mark account+model as unavailable — locks modelLock_${model} in DB.
- * All errors (429, 401, 5xx, etc.) lock per model, not per account.
- * @param {string} connectionId
- * @param {number} status - HTTP status code from upstream
- * @param {string} errorText
- * @param {string|null} provider
- * @param {string|null} model - The specific model that triggered the error
- * @returns {{ shouldFallback: boolean, cooldownMs: number }}
- */
-export async function markAccountUnavailable(connectionId, status, errorText, provider = null, model = null, resetsAtMs = null) {
- if (!connectionId) return { shouldFallback: false, cooldownMs: 0 };
- const providerId = provider ? resolveProviderId(provider) : provider;
- let conn;
- if (connectionId === "noauth") {
- const settings = await getSettings();
- conn = { id: "noauth", name: "Public", providerSpecificData: (settings.providerStrategies || {})[providerId] || {} };
- } else {
- const connections = await getProviderConnections({ provider: providerId });
- conn = connections.find(c => c.id === connectionId);
- }
- const backoffLevel = conn?.backoffLevel || 0;
-
- await maybeRotateProxyOnError(conn, providerId);
-
- if (await shouldAutoDeactivate(conn, providerId)) {
- const reason = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error";
- if (connectionId === "noauth") {
- const settings = await getSettings();
- const providerStrategies = { ...(settings.providerStrategies || {}) };
- providerStrategies[providerId] = { ...(providerStrategies[providerId] || {}), inactive: true, lastError: reason, errorCode: status, lastErrorAt: new Date().toISOString() };
- await updateSettings({ providerStrategies });
- } else {
- await updateProviderConnection(connectionId, {
- isActive: false,
- testStatus: "unavailable",
- lastError: reason,
- errorCode: status,
- lastErrorAt: new Date().toISOString(),
- });
- }
- log.warn("AUTH", `${conn?.name || conn?.email || connectionId.slice(0, 8)} disabled until manual re-enable [${status}]`);
- return { shouldFallback: true, cooldownMs: 0 };
- }
-
- // Provider-specific precise cooldown (e.g. codex usage_limit_reached resets_at) overrides backoff
- let shouldFallback, cooldownMs, newBackoffLevel;
- if (resetsAtMs && resetsAtMs > Date.now()) {
- shouldFallback = true;
- cooldownMs = Math.min(resetsAtMs - Date.now(), MAX_RATE_LIMIT_COOLDOWN_MS);
- newBackoffLevel = 0;
- } else if (status === 429) {
- // Use classify429 for all 429 responses so rate_limit, quota_exhausted,
- // and daily_quota get deterministic, semantically correct cooldowns
- // instead of generic exponential backoff. This also prevents the daily
- // quota lock set earlier in the request path from being overwritten with
- // a shorter backoff cooldown.
- const classification = classify429({ status, body: errorText });
- shouldFallback = true;
- cooldownMs = classification.cooldownMs;
- newBackoffLevel = backoffLevel;
- } else {
- ({ shouldFallback, cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel));
- }
- if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 };
-
- const reason = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error";
- const lockUpdate = buildModelLockUpdate(model, cooldownMs);
-
- if (connectionId === "noauth") {
- const settings = await getSettings();
- const providerStrategies = { ...(settings.providerStrategies || {}) };
- providerStrategies[providerId] = { ...(providerStrategies[providerId] || {}), lastError: reason, errorCode: status, lastErrorAt: new Date().toISOString() };
- await updateSettings({ providerStrategies });
- } else {
- await updateProviderConnection(connectionId, {
- ...lockUpdate,
- testStatus: "unavailable",
- lastError: reason,
- errorCode: status,
- lastErrorAt: new Date().toISOString(),
- backoffLevel: newBackoffLevel ?? backoffLevel
- });
- }
-
- const lockKey = Object.keys(lockUpdate)[0];
- const connName = conn?.displayName || conn?.name || conn?.email || connectionId.slice(0, 8);
- log.warn("AUTH", `${connName} locked ${lockKey} for ${Math.round(cooldownMs / 1000)}s [${status}]`);
-
- if (provider && status && reason) {
- console.error(`❌ ${provider} [${status}]: ${reason}`);
- }
-
- return { shouldFallback: true, cooldownMs };
-}
-
-/**
- * Clear account error status on successful request.
- * - Clears modelLock_${model} (the model that just succeeded)
- * - Lazy-cleans any other expired modelLock_* keys
- * - Resets error state only if no active locks remain
- * @param {string} connectionId
- * @param {object} currentConnection - credentials object (has _connection) or raw connection
- * @param {string|null} model - model that succeeded
- */
-export async function clearAccountError(connectionId, currentConnection, model = null) {
- if (!connectionId || connectionId === "noauth") return;
- const conn = currentConnection._connection || currentConnection;
- const now = Date.now();
- const allLockKeys = Object.keys(conn).filter(k => k.startsWith("modelLock_"));
-
- if (!conn.testStatus && !conn.lastError && allLockKeys.length === 0) return;
-
- // Keys to clear: current model's lock + all expired locks
- const keysToClear = allLockKeys.filter(k => {
- if (model && k === `modelLock_${model}`) return true; // succeeded model
- if (model && k === "modelLock___all") return true; // account-level lock
- const expiry = conn[k];
- return expiry && new Date(expiry).getTime() <= now; // expired
- });
-
- if (keysToClear.length === 0 && conn.testStatus !== "unavailable" && !conn.lastError) return;
-
- // Check if any active locks remain after clearing
- const remainingActiveLocks = allLockKeys.filter(k => {
- if (keysToClear.includes(k)) return false;
- const expiry = conn[k];
- return expiry && new Date(expiry).getTime() > now;
- });
-
- const clearObj = Object.fromEntries(keysToClear.map(k => [k, null]));
-
- // Only reset error state if no active locks remain
- if (remainingActiveLocks.length === 0) {
- Object.assign(clearObj, { testStatus: "active", lastError: null, lastErrorAt: null, backoffLevel: 0 });
- }
-
- await updateProviderConnection(connectionId, clearObj);
-}
-
-/**
- * Extract API key from request headers
- */
-export function extractApiKey(request) {
- // Check Authorization header first
- const authHeader = request.headers.get("Authorization");
- if (authHeader?.startsWith("Bearer ")) {
- return authHeader.slice(7);
- }
-
- // Check Anthropic x-api-key header
- const xApiKey = request.headers.get("x-api-key");
- if (xApiKey) {
- return xApiKey;
- }
-
- return null;
-}
-
-/**
- * Validate API key and return key info (including allowedProviders)
- * Returns null if invalid, or the key object if valid
- */
-export async function isValidApiKey(apiKey) {
- if (!apiKey) return null;
- return await validateApiKey(apiKey);
-}
-
-/**
- * Check if a provider is allowed for a given API key info object.
- * null = all allowed (default). [] = none allowed. [x] = only x.
- *
- * For openai-compatible / anthropic-compatible / custom-embedding providers
- * (whose ids embed a UUID suffix), the connection's node prefix is also
- * accepted as a match — the UUID-suffixed id is not user-meaningful and
- * /v1/models lists these under their prefix alias.
- */
-const ROTATE_PROXY_PROVIDERS = new Set(["mimo-free", "opencode"]);
-
-async function shouldAutoDeactivate(conn, provider) {
- if (conn?.providerSpecificData?.autoDeactivateOnError === true) return true;
- if (!provider) return false;
- const settings = await getSettings();
- return (settings.providerStrategies || {})[provider]?.autoDeactivateOnError === true;
-}
-
-async function rotateProxy(conn, provider) {
- const data = conn?.providerSpecificData || {};
- const pools = (await getProxyPools({ isActive: true })).filter((p) => p.isActive === true);
- if (pools.length < 2) return conn;
- const currentId = data.proxyPoolId || null;
- const currentIndex = pools.findIndex((p) => p.id === currentId);
- const next = pools[(currentIndex + 1) % pools.length] || pools[0];
- if (!next?.id || next.id === currentId) return conn;
- const providerSpecificData = { ...data, proxyPoolId: next.id, lastProxyRotateAt: new Date().toISOString() };
- if (conn.id === "noauth") {
- const settings = await getSettings();
- const providerStrategies = { ...(settings.providerStrategies || {}) };
- providerStrategies[provider] = { ...(providerStrategies[provider] || {}), ...providerSpecificData };
- await updateSettings({ providerStrategies });
- } else {
- await updateProviderConnection(conn.id, { providerSpecificData });
- }
- log.warn("AUTH", `${provider} rotated proxy for ${conn.name || conn.email || conn.id?.slice(0, 8)} → ${next.name || next.id}`);
- return { ...conn, providerSpecificData };
-}
-
-async function maybeRotateProxyOnError(conn, provider) {
- if (!ROTATE_PROXY_PROVIDERS.has(provider) || conn?.providerSpecificData?.autoRotateProxyOnError !== true) return;
- await rotateProxy(conn, provider);
-}
-
-async function maybeRotateProxyByTimer(conn, provider) {
- const data = conn?.providerSpecificData || {};
- const minutes = Number(data.autoRotateProxyMinutes || 0);
- if (!ROTATE_PROXY_PROVIDERS.has(provider) || ![5, 10, 15].includes(minutes)) return conn;
- const last = data.lastProxyRotateAt ? new Date(data.lastProxyRotateAt).getTime() : 0;
- if (last && Date.now() - last < minutes * 60 * 1000) return conn;
- return rotateProxy(conn, provider);
-}
-
-const _nodePrefixCache = new Map(); // id -> { prefix, expires }
-const NODE_PREFIX_CACHE_TTL_MS = 30000;
-async function getNodePrefix(providerId) {
- const cached = _nodePrefixCache.get(providerId);
- if (cached && cached.expires > Date.now()) return cached.prefix;
- try {
- const node = await getProviderNodeById(providerId);
- const prefix = node?.prefix || null;
- _nodePrefixCache.set(providerId, { prefix, expires: Date.now() + NODE_PREFIX_CACHE_TTL_MS });
- return prefix;
- } catch {
- _nodePrefixCache.set(providerId, { prefix: null, expires: Date.now() + NODE_PREFIX_CACHE_TTL_MS });
- return null;
- }
-}
-export async function isProviderAllowed(apiKeyInfo, providerIdOrAlias) {
- if (!apiKeyInfo) return true;
- const allowed = apiKeyInfo.allowedProviders;
- if (allowed === null || allowed === undefined) return true; // null = all
- if (!Array.isArray(allowed) || allowed.length === 0) return false; // [] = none
- if (allowed.includes(providerIdOrAlias)) return true;
- const alias = getProviderAlias(providerIdOrAlias);
- if (alias !== providerIdOrAlias && allowed.includes(alias)) return true;
- const resolvedId = resolveProviderId(providerIdOrAlias);
- if (resolvedId !== providerIdOrAlias && allowed.includes(resolvedId)) return true;
- if (isOpenAICompatibleProvider(providerIdOrAlias) || isAnthropicCompatibleProvider(providerIdOrAlias) || isCustomEmbeddingProvider(providerIdOrAlias)) {
- const prefix = await getNodePrefix(providerIdOrAlias);
- if (prefix && allowed.includes(prefix)) return true;
- }
- return false;
-}
-
-/**
- * Check if a combo name is allowed for a given API key.
- * null = all allowed (default). [] = none allowed. [x] = only x.
- */
-export function isComboAllowed(apiKeyInfo, comboName) {
- if (!apiKeyInfo) return true;
- const name = comboName.startsWith("combo/") ? comboName.slice(6) : comboName;
- const allowed = apiKeyInfo.allowedCombos;
- if (allowed === null || allowed === undefined) return true;
- if (!Array.isArray(allowed) || allowed.length === 0) return false;
- return allowed.includes(name);
-}
-
-/**
- * Check if a request kind is allowed for a given API key.
- * Kinds: "llm", "embedding", "image", "tts", "stt", "web"
- * null = all allowed (default). [] = none allowed. [x] = only x.
- */
-export function isKindAllowed(apiKeyInfo, kind) {
- if (!apiKeyInfo) return true;
- const allowed = apiKeyInfo.allowedKinds;
- if (allowed === null || allowed === undefined) return true; // null = all
- if (!Array.isArray(allowed) || allowed.length === 0) return false; // [] = none
- return allowed.includes(kind);
-}
-
+import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings, updateSettings, getProviderNodeById, getProxyPools } from "@/lib/localDb";
+import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
+import { formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate, getEarliestModelLockUntil } from "open-sse/services/accountFallback.js";
+import { classify429 } from "open-sse/utils/classify429.js";
+import { MAX_RATE_LIMIT_COOLDOWN_MS } from "open-sse/config/errorConfig.js";
+import { resolveProviderId, FREE_PROVIDERS, AI_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isCustomEmbeddingProvider } from "@/shared/constants/providers.js";
+import * as log from "../utils/logger.js";
+import { isSupportedProxyRotationInterval } from "@/shared/constants/proxyRotation.js";
+
+// Re-export the internal-trust gate so handlers can import it alongside the
+// other ACL helpers. Implementation lives in internalTrust.js (dependency-light
+// + independently unit-tested for exploit resistance).
+export { isTrustedInternalRequest } from "./internalTrust.js";
+
+// Per-provider mutex — allows parallel credential selection across different providers
+// while preventing races within the same provider's account rotation.
+const _providerMutexes = new Map();
+
+function getProviderMutex(provider) {
+ if (!_providerMutexes.has(provider)) {
+ _providerMutexes.set(provider, Promise.resolve());
+ }
+ return _providerMutexes.get(provider);
+}
+
+/**
+ * Get provider credentials from localDb
+ * Filters out unavailable accounts and returns the selected account based on strategy
+ * @param {string} provider - Provider name
+ * @param {Set|string|null} excludeConnectionIds - Connection ID(s) to exclude (for retry with next account)
+ * @param {string|null} model - Model name for per-model rate limit filtering
+ */
+export async function getProviderCredentials(provider, excludeConnectionIds = null, model = null, options = {}) {
+ // Normalize to Set for consistent handling
+ const excludeSet = excludeConnectionIds instanceof Set
+ ? excludeConnectionIds
+ : (excludeConnectionIds ? new Set([excludeConnectionIds]) : new Set());
+ const preferredConnectionId = options?.preferredConnectionId || null;
+ // Acquire per-provider mutex to prevent race conditions within same provider
+ const currentMutex = getProviderMutex(provider);
+ let resolveMutex;
+ _providerMutexes.set(provider, new Promise(resolve => { resolveMutex = resolve; }));
+
+ try {
+ await currentMutex;
+
+ // Resolve alias to provider ID (e.g., "kc" -> "kilocode")
+ const providerId = resolveProviderId(provider);
+
+ // Inject a virtual connection for no-auth free providers (with optional proxy pool from settings)
+ if (FREE_PROVIDERS[providerId]?.noAuth) {
+ const settings = await getSettings();
+ const override = (settings.providerStrategies || {})[providerId] || {};
+ if (override.inactive === true) {
+ log.warn("AUTH", `${providerId} public provider inactive until manual re-enable`);
+ return null;
+ }
+ const virtualConn = await maybeRotateProxyByTimer({ id: "noauth", providerSpecificData: override, name: "Public" }, providerId);
+ const resolvedProxy = await resolveConnectionProxyConfig({ proxyPoolId: virtualConn.providerSpecificData?.proxyPoolId || "" });
+ return {
+ id: "noauth",
+ connectionName: "Public",
+ isActive: true,
+ accessToken: "public",
+ providerSpecificData: {
+ ...virtualConn.providerSpecificData,
+ connectionProxyEnabled: resolvedProxy.connectionProxyEnabled,
+ connectionProxyUrl: resolvedProxy.connectionProxyUrl,
+ connectionProxyUrls: resolvedProxy.connectionProxyUrls || [],
+ connectionNoProxy: resolvedProxy.connectionNoProxy,
+ connectionProxyPoolId: resolvedProxy.proxyPoolId || null,
+ strictProxy: resolvedProxy.strictProxy === true,
+ vercelRelayUrl: resolvedProxy.vercelRelayUrl || "",
+ },
+ connectionId: "noauth",
+ };
+ }
+
+ const connections = await getProviderConnections({ provider: providerId, isActive: true });
+ log.debug("AUTH", `${provider} | total connections: ${connections.length}, excludeIds: ${excludeSet.size > 0 ? [...excludeSet].join(",") : "none"}, model: ${model || "any"}`);
+
+ if (connections.length === 0) {
+ log.warn("AUTH", `No credentials for ${provider}`);
+ return null;
+ }
+
+ // Filter out model-locked and excluded connections
+ const availableConnections = connections.filter(c => {
+ if (excludeSet.has(c.id)) return false;
+ if (isModelLockActive(c, model)) return false;
+ return true;
+ });
+
+ log.debug("AUTH", `${provider} | available: ${availableConnections.length}/${connections.length}`);
+ connections.forEach(c => {
+ const excluded = excludeSet.has(c.id);
+ const locked = isModelLockActive(c, model);
+ if (excluded || locked) {
+ const lockUntil = getEarliestModelLockUntil(c);
+ log.debug("AUTH", ` → ${c.id?.slice(0, 8)} | ${excluded ? "excluded" : ""} ${locked ? `modelLocked(${model}) until ${lockUntil}` : ""}`);
+ }
+ });
+
+ if (availableConnections.length === 0) {
+ // Find earliest lock expiry across all connections for retry timing
+ const lockedConns = connections.filter(c => isModelLockActive(c, model));
+ const expiries = lockedConns.flatMap(c => { const t = getEarliestModelLockUntil(c); return t ? [t] : []; });
+ const earliest = expiries.length > 0 ? expiries.reduce((a, b) => a < b ? a : b) : null;
+ if (earliest) {
+ const earliestConn = lockedConns[0];
+ log.warn("AUTH", `${provider} | all ${connections.length} accounts locked for ${model || "all"} (${formatRetryAfter(earliest)}) | lastError=${earliestConn?.lastError?.slice(0, 50)}`);
+ return {
+ allRateLimited: true,
+ connectionId: earliestConn?.id || null,
+ retryAfter: earliest,
+ retryAfterHuman: formatRetryAfter(earliest),
+ lastError: earliestConn?.lastError || null,
+ lastErrorCode: earliestConn?.errorCode || null
+ };
+ }
+ log.warn("AUTH", `${provider} | all ${connections.length} accounts unavailable`);
+ return null;
+ }
+
+ const settings = await getSettings();
+ // Per-provider strategy overrides global setting
+ const providerOverride = (settings.providerStrategies || {})[providerId] || {};
+ const strategy = providerOverride.fallbackStrategy || settings.fallbackStrategy || "fill-first";
+
+ let connection;
+ // Pin to preferred connection if specified and available
+ if (preferredConnectionId) {
+ connection = availableConnections.find((c) => c.id === preferredConnectionId);
+ if (connection) {
+ log.info("AUTH", `${provider} | pinned to ${connection.id?.slice(0, 8)} (${connection.name || connection.email || "unnamed"})`);
+ }
+ }
+ if (connection) {
+ // skip strategy
+ } else if (strategy === "round-robin") {
+ const stickyLimit = providerOverride.stickyRoundRobinLimit || settings.stickyRoundRobinLimit || 3;
+
+ // Sort by lastUsed (most recent first) to find current candidate
+ const byRecency = availableConnections.toSorted((a, b) => {
+ if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999);
+ if (!a.lastUsedAt) return 1;
+ if (!b.lastUsedAt) return -1;
+ return new Date(b.lastUsedAt) - new Date(a.lastUsedAt);
+ });
+
+ const current = byRecency[0];
+ const currentCount = current?.consecutiveUseCount || 0;
+
+ if (current && current.lastUsedAt && currentCount < stickyLimit) {
+ // Stay with current account
+ connection = current;
+ // Update lastUsedAt and increment count (await to ensure persistence)
+ await updateProviderConnection(connection.id, {
+ lastUsedAt: new Date().toISOString(),
+ consecutiveUseCount: (connection.consecutiveUseCount || 0) + 1
+ });
+ } else {
+ // Pick the least recently used (excluding current if possible)
+ const sortedByOldest = availableConnections.toSorted((a, b) => {
+ if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999);
+ if (!a.lastUsedAt) return -1;
+ if (!b.lastUsedAt) return 1;
+ return new Date(a.lastUsedAt) - new Date(b.lastUsedAt);
+ });
+
+ connection = sortedByOldest[0];
+
+ // Update lastUsedAt and reset count to 1 (await to ensure persistence)
+ await updateProviderConnection(connection.id, {
+ lastUsedAt: new Date().toISOString(),
+ consecutiveUseCount: 1
+ });
+ }
+ } else {
+ // Default: fill-first (already sorted by priority in getProviderConnections)
+ connection = availableConnections[0];
+ }
+
+ connection = await maybeRotateProxyByTimer(connection, providerId);
+ const resolvedProxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {});
+
+ return {
+ authType: connection.authType,
+ apiKey: connection.apiKey,
+ accessToken: connection.accessToken,
+ refreshToken: connection.refreshToken,
+ idToken: connection.idToken,
+ expiresAt: connection.expiresAt,
+ expiresIn: connection.expiresIn,
+ lastRefreshAt: connection.lastRefreshAt,
+ projectId: connection.projectId,
+ connectionName: connection.displayName || connection.name || connection.email || connection.id,
+ copilotToken: connection.providerSpecificData?.copilotToken,
+ providerSpecificData: {
+ ...(connection.providerSpecificData || {}),
+ connectionProxyEnabled: resolvedProxy.connectionProxyEnabled,
+ connectionProxyUrl: resolvedProxy.connectionProxyUrl,
+ connectionProxyUrls: resolvedProxy.connectionProxyUrls || [],
+ connectionNoProxy: resolvedProxy.connectionNoProxy,
+ connectionProxyPoolId: resolvedProxy.proxyPoolId || null,
+ strictProxy: resolvedProxy.strictProxy === true,
+ vercelRelayUrl: resolvedProxy.vercelRelayUrl || "",
+ },
+ connectionId: connection.id,
+ // Include current status for optimization check
+ testStatus: connection.testStatus,
+ lastError: connection.lastError,
+ // Pass full connection for clearAccountError to read modelLock_* keys
+ _connection: connection
+ };
+ } finally {
+ if (resolveMutex) resolveMutex();
+ }
+}
+
+/**
+ * Mark account+model as unavailable — locks modelLock_${model} in DB.
+ * All errors (429, 401, 5xx, etc.) lock per model, not per account.
+ * @param {string} connectionId
+ * @param {number} status - HTTP status code from upstream
+ * @param {string} errorText
+ * @param {string|null} provider
+ * @param {string|null} model - The specific model that triggered the error
+ * @returns {{ shouldFallback: boolean, cooldownMs: number }}
+ */
+export async function markAccountUnavailable(connectionId, status, errorText, provider = null, model = null, resetsAtMs = null) {
+ if (!connectionId) return { shouldFallback: false, cooldownMs: 0 };
+ const providerId = provider ? resolveProviderId(provider) : provider;
+ let conn;
+ if (connectionId === "noauth") {
+ const settings = await getSettings();
+ conn = { id: "noauth", name: "Public", providerSpecificData: (settings.providerStrategies || {})[providerId] || {} };
+ } else {
+ const connections = await getProviderConnections({ provider: providerId });
+ conn = connections.find(c => c.id === connectionId);
+ }
+ const backoffLevel = conn?.backoffLevel || 0;
+
+ await maybeRotateProxyOnError(conn, providerId);
+
+ if (await shouldAutoDeactivate(conn, providerId)) {
+ const reason = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error";
+ if (connectionId === "noauth") {
+ const settings = await getSettings();
+ const providerStrategies = { ...(settings.providerStrategies || {}) };
+ providerStrategies[providerId] = { ...(providerStrategies[providerId] || {}), inactive: true, lastError: reason, errorCode: status, lastErrorAt: new Date().toISOString() };
+ await updateSettings({ providerStrategies });
+ } else {
+ await updateProviderConnection(connectionId, {
+ isActive: false,
+ testStatus: "unavailable",
+ lastError: reason,
+ errorCode: status,
+ lastErrorAt: new Date().toISOString(),
+ });
+ }
+ log.warn("AUTH", `${conn?.name || conn?.email || connectionId.slice(0, 8)} disabled until manual re-enable [${status}]`);
+ return { shouldFallback: true, cooldownMs: 0 };
+ }
+
+ // Provider-specific precise cooldown (e.g. codex usage_limit_reached resets_at) overrides backoff
+ let shouldFallback, cooldownMs, newBackoffLevel;
+ if (resetsAtMs && resetsAtMs > Date.now()) {
+ shouldFallback = true;
+ cooldownMs = Math.min(resetsAtMs - Date.now(), MAX_RATE_LIMIT_COOLDOWN_MS);
+ newBackoffLevel = 0;
+ } else if (status === 429) {
+ // Use classify429 for all 429 responses so rate_limit, quota_exhausted,
+ // and daily_quota get deterministic, semantically correct cooldowns
+ // instead of generic exponential backoff. This also prevents the daily
+ // quota lock set earlier in the request path from being overwritten with
+ // a shorter backoff cooldown.
+ const classification = classify429({ status, body: errorText });
+ shouldFallback = true;
+ cooldownMs = classification.cooldownMs;
+ newBackoffLevel = backoffLevel;
+ } else {
+ ({ shouldFallback, cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel));
+ }
+ if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 };
+
+ const reason = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error";
+ const lockUpdate = buildModelLockUpdate(model, cooldownMs);
+
+ if (connectionId === "noauth") {
+ const settings = await getSettings();
+ const providerStrategies = { ...(settings.providerStrategies || {}) };
+ providerStrategies[providerId] = { ...(providerStrategies[providerId] || {}), lastError: reason, errorCode: status, lastErrorAt: new Date().toISOString() };
+ await updateSettings({ providerStrategies });
+ } else {
+ await updateProviderConnection(connectionId, {
+ ...lockUpdate,
+ testStatus: "unavailable",
+ lastError: reason,
+ errorCode: status,
+ lastErrorAt: new Date().toISOString(),
+ backoffLevel: newBackoffLevel ?? backoffLevel
+ });
+ }
+
+ const lockKey = Object.keys(lockUpdate)[0];
+ const connName = conn?.displayName || conn?.name || conn?.email || connectionId.slice(0, 8);
+ log.warn("AUTH", `${connName} locked ${lockKey} for ${Math.round(cooldownMs / 1000)}s [${status}]`);
+
+ if (provider && status && reason) {
+ console.error(`❌ ${provider} [${status}]: ${reason}`);
+ }
+
+ return { shouldFallback: true, cooldownMs };
+}
+
+/**
+ * Clear account error status on successful request.
+ * - Clears modelLock_${model} (the model that just succeeded)
+ * - Lazy-cleans any other expired modelLock_* keys
+ * - Resets error state only if no active locks remain
+ * @param {string} connectionId
+ * @param {object} currentConnection - credentials object (has _connection) or raw connection
+ * @param {string|null} model - model that succeeded
+ */
+export async function clearAccountError(connectionId, currentConnection, model = null) {
+ if (!connectionId || connectionId === "noauth") return;
+ const conn = currentConnection._connection || currentConnection;
+ const now = Date.now();
+ const allLockKeys = Object.keys(conn).filter(k => k.startsWith("modelLock_"));
+
+ if (!conn.testStatus && !conn.lastError && allLockKeys.length === 0) return;
+
+ // Keys to clear: current model's lock + all expired locks
+ const keysToClear = allLockKeys.filter(k => {
+ if (model && k === `modelLock_${model}`) return true; // succeeded model
+ if (model && k === "modelLock___all") return true; // account-level lock
+ const expiry = conn[k];
+ return expiry && new Date(expiry).getTime() <= now; // expired
+ });
+
+ if (keysToClear.length === 0 && conn.testStatus !== "unavailable" && !conn.lastError) return;
+
+ // Check if any active locks remain after clearing
+ const remainingActiveLocks = allLockKeys.filter(k => {
+ if (keysToClear.includes(k)) return false;
+ const expiry = conn[k];
+ return expiry && new Date(expiry).getTime() > now;
+ });
+
+ const clearObj = Object.fromEntries(keysToClear.map(k => [k, null]));
+
+ // Only reset error state if no active locks remain
+ if (remainingActiveLocks.length === 0) {
+ Object.assign(clearObj, { testStatus: "active", lastError: null, lastErrorAt: null, backoffLevel: 0 });
+ }
+
+ await updateProviderConnection(connectionId, clearObj);
+}
+
+/**
+ * Extract API key from request headers
+ */
+export function extractApiKey(request) {
+ // Check Authorization header first
+ const authHeader = request.headers.get("Authorization");
+ if (authHeader?.startsWith("Bearer ")) {
+ return authHeader.slice(7);
+ }
+
+ // Check Anthropic x-api-key header
+ const xApiKey = request.headers.get("x-api-key");
+ if (xApiKey) {
+ return xApiKey;
+ }
+
+ return null;
+}
+
+/**
+ * Validate API key and return key info (including allowedProviders)
+ * Returns null if invalid, or the key object if valid
+ */
+export async function isValidApiKey(apiKey) {
+ if (!apiKey) return null;
+ return await validateApiKey(apiKey);
+}
+
+/**
+ * Check if a provider is allowed for a given API key info object.
+ * null = all allowed (default). [] = none allowed. [x] = only x.
+ *
+ * For openai-compatible / anthropic-compatible / custom-embedding providers
+ * (whose ids embed a UUID suffix), the connection's node prefix is also
+ * accepted as a match — the UUID-suffixed id is not user-meaningful and
+ * /v1/models lists these under their prefix alias.
+ */
+
+async function shouldAutoDeactivate(conn, provider) {
+ if (conn?.providerSpecificData?.autoDeactivateOnError === true) return true;
+ if (!provider) return false;
+ const settings = await getSettings();
+ return (settings.providerStrategies || {})[provider]?.autoDeactivateOnError === true;
+}
+
+async function rotateProxy(conn, provider) {
+ const data = conn?.providerSpecificData || {};
+ const pools = (await getProxyPools({ isActive: true })).filter((p) => p.isActive === true);
+ if (pools.length < 2) return conn;
+ const currentId = data.proxyPoolId || null;
+ const currentIndex = pools.findIndex((p) => p.id === currentId);
+ const next = pools[(currentIndex + 1) % pools.length] || pools[0];
+ if (!next?.id || next.id === currentId) return conn;
+ const providerSpecificData = { ...data, proxyPoolId: next.id, lastProxyRotateAt: new Date().toISOString() };
+ if (conn.id === "noauth") {
+ const settings = await getSettings();
+ const providerStrategies = { ...(settings.providerStrategies || {}) };
+ providerStrategies[provider] = { ...(providerStrategies[provider] || {}), ...providerSpecificData };
+ await updateSettings({ providerStrategies });
+ } else {
+ await updateProviderConnection(conn.id, { providerSpecificData });
+ }
+ log.warn("AUTH", `${provider} rotated proxy for ${conn.name || conn.email || conn.id?.slice(0, 8)} → ${next.name || next.id}`);
+ return { ...conn, providerSpecificData };
+}
+
+async function maybeRotateProxyOnError(conn, provider) {
+ if (conn?.providerSpecificData?.autoRotateProxyOnError !== true) return;
+ await rotateProxy(conn, provider);
+}
+
+async function maybeRotateProxyByTimer(conn, provider) {
+ const data = conn?.providerSpecificData || {};
+ const minutes = Number(data.autoRotateProxyMinutes || 0);
+ if (!isSupportedProxyRotationInterval(minutes)) return conn;
+ const last = data.lastProxyRotateAt ? new Date(data.lastProxyRotateAt).getTime() : 0;
+ if (last && Date.now() - last < minutes * 60 * 1000) return conn;
+ return rotateProxy(conn, provider);
+}
+
+const _nodePrefixCache = new Map(); // id -> { prefix, expires }
+const NODE_PREFIX_CACHE_TTL_MS = 30000;
+async function getNodePrefix(providerId) {
+ const cached = _nodePrefixCache.get(providerId);
+ if (cached && cached.expires > Date.now()) return cached.prefix;
+ try {
+ const node = await getProviderNodeById(providerId);
+ const prefix = node?.prefix || null;
+ _nodePrefixCache.set(providerId, { prefix, expires: Date.now() + NODE_PREFIX_CACHE_TTL_MS });
+ return prefix;
+ } catch {
+ _nodePrefixCache.set(providerId, { prefix: null, expires: Date.now() + NODE_PREFIX_CACHE_TTL_MS });
+ return null;
+ }
+}
+export async function isProviderAllowed(apiKeyInfo, providerIdOrAlias) {
+ if (!apiKeyInfo) return true;
+ const allowed = apiKeyInfo.allowedProviders;
+ if (allowed === null || allowed === undefined) return true; // null = all
+ if (!Array.isArray(allowed) || allowed.length === 0) return false; // [] = none
+ if (allowed.includes(providerIdOrAlias)) return true;
+ const alias = getProviderAlias(providerIdOrAlias);
+ if (alias !== providerIdOrAlias && allowed.includes(alias)) return true;
+ const resolvedId = resolveProviderId(providerIdOrAlias);
+ if (resolvedId !== providerIdOrAlias && allowed.includes(resolvedId)) return true;
+ if (isOpenAICompatibleProvider(providerIdOrAlias) || isAnthropicCompatibleProvider(providerIdOrAlias) || isCustomEmbeddingProvider(providerIdOrAlias)) {
+ const prefix = await getNodePrefix(providerIdOrAlias);
+ if (prefix && allowed.includes(prefix)) return true;
+ }
+ return false;
+}
+
+/**
+ * Check if a combo name is allowed for a given API key.
+ * null = all allowed (default). [] = none allowed. [x] = only x.
+ */
+export function isComboAllowed(apiKeyInfo, comboName) {
+ if (!apiKeyInfo) return true;
+ const name = comboName.startsWith("combo/") ? comboName.slice(6) : comboName;
+ const allowed = apiKeyInfo.allowedCombos;
+ if (allowed === null || allowed === undefined) return true;
+ if (!Array.isArray(allowed) || allowed.length === 0) return false;
+ return allowed.includes(name);
+}
+
+/**
+ * Check if a request kind is allowed for a given API key.
+ * Kinds: "llm", "embedding", "image", "tts", "stt", "web"
+ * null = all allowed (default). [] = none allowed. [x] = only x.
+ */
+export function isKindAllowed(apiKeyInfo, kind) {
+ if (!apiKeyInfo) return true;
+ const allowed = apiKeyInfo.allowedKinds;
+ if (allowed === null || allowed === undefined) return true; // null = all
+ if (!Array.isArray(allowed) || allowed.length === 0) return false; // [] = none
+ return allowed.includes(kind);
+}
+
diff --git a/app/src/sse/utils/logger.js b/app/src/sse/utils/logger.js
index bb47ba5d..73e4070d 100644
--- a/app/src/sse/utils/logger.js
+++ b/app/src/sse/utils/logger.js
@@ -10,7 +10,9 @@ const LOG_LEVELS = {
const LEVEL = LOG_LEVELS[process.env.LOG_LEVEL?.toUpperCase?.()] ?? LOG_LEVELS.INFO;
function formatTime() {
- return new Date().toLocaleTimeString("en-US", { hour12: false });
+ const d = new Date();
+ const pad = (n) => String(n).padStart(2, "0");
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function formatData(data) {
diff --git a/app/tests/auth-proxy-metadata.test.js b/app/tests/auth-proxy-metadata.test.js
new file mode 100644
index 00000000..e54f1e1f
--- /dev/null
+++ b/app/tests/auth-proxy-metadata.test.js
@@ -0,0 +1,100 @@
+import { describe, expect, test, vi, beforeEach } from "vitest";
+import { PROXY_ROTATION_INTERVAL_OPTIONS } from "../src/app/(dashboard)/dashboard/providers/[id]/providerPageUtils.js";
+
+const localDbMock = vi.hoisted(() => ({
+ connection: {
+ id: "conn-1",
+ provider: "openai",
+ isActive: true,
+ authType: "api_key",
+ apiKey: "sk-test",
+ name: "OpenAI",
+ providerSpecificData: { proxyPoolId: "pool-1" },
+ testStatus: "active",
+ },
+ updateProviderConnection: vi.fn(async () => undefined),
+ getProxyPools: vi.fn(async () => []),
+}));
+
+vi.mock("@/models", () => ({
+ getProxyPoolById: async (id) => ({
+ id,
+ type: "http",
+ proxyUrl: "http://first:8080",
+ proxyUrls: ["http://first:8080", "http://second:8080"],
+ noProxy: "localhost",
+ isActive: true,
+ strictProxy: true,
+ }),
+}));
+
+vi.mock("@/lib/localDb", () => ({
+ getProviderConnections: async () => [{ ...localDbMock.connection }],
+ validateApiKey: async () => null,
+ updateProviderConnection: localDbMock.updateProviderConnection,
+ getSettings: async () => ({}),
+ updateSettings: async () => undefined,
+ getProviderNodeById: async () => null,
+ getProxyPools: localDbMock.getProxyPools,
+}));
+
+vi.mock("../src/sse/utils/logger.js", () => ({
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+}));
+
+describe("getProviderCredentials", () => {
+ beforeEach(() => {
+ localDbMock.connection = {
+ id: "conn-1",
+ provider: "openai",
+ isActive: true,
+ authType: "api_key",
+ apiKey: "sk-test",
+ name: "OpenAI",
+ providerSpecificData: { proxyPoolId: "pool-1" },
+ testStatus: "active",
+ };
+ localDbMock.updateProviderConnection.mockClear();
+ localDbMock.getProxyPools.mockResolvedValue([]);
+ });
+
+ test("returns resolved proxy list and strict mode", async () => {
+ const { getProviderCredentials } = await import("../src/sse/services/auth.js");
+
+ const credentials = await getProviderCredentials("openai");
+
+ expect(credentials.providerSpecificData.connectionProxyUrl).toBe("http://first:8080");
+ expect(credentials.providerSpecificData.connectionProxyUrls).toEqual(["http://first:8080", "http://second:8080"]);
+ expect(credentials.providerSpecificData.strictProxy).toBe(true);
+ });
+
+ test("rotates proxy on every exported interval", async () => {
+ const { getProviderCredentials } = await import("../src/sse/services/auth.js");
+ const intervals = PROXY_ROTATION_INTERVAL_OPTIONS.filter((minutes) => minutes !== null);
+
+ for (const minutes of intervals) {
+ localDbMock.updateProviderConnection.mockClear();
+ localDbMock.connection.providerSpecificData = {
+ proxyPoolId: "pool-1",
+ autoRotateProxyMinutes: minutes,
+ lastProxyRotateAt: new Date(Date.now() - minutes * 60 * 1000 - 1000).toISOString(),
+ };
+ localDbMock.getProxyPools.mockResolvedValue([
+ { id: "pool-1", name: "Pool 1", isActive: true },
+ { id: "pool-2", name: "Pool 2", isActive: true },
+ ]);
+
+ await getProviderCredentials("openai");
+
+ expect(localDbMock.updateProviderConnection).toHaveBeenCalledWith("conn-1", {
+ providerSpecificData: expect.objectContaining({
+ autoRotateProxyMinutes: minutes,
+ proxyPoolId: "pool-2",
+ }),
+ });
+ }
+ });
+});
diff --git a/app/tests/dashboard-features.test.js b/app/tests/dashboard-features.test.js
new file mode 100644
index 00000000..69c4ac6c
--- /dev/null
+++ b/app/tests/dashboard-features.test.js
@@ -0,0 +1,378 @@
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import net from "net";
+import { describe, expect, test } from "vitest";
+import {
+ getRefreshIntervalSeconds,
+ shouldFetchQuotaOnTick,
+} from "../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js";
+import {
+ SMART_HEALTH_CONCURRENCY,
+ SMART_HEALTH_DELETE_MODES,
+ SMART_HEALTH_INTERVAL_OPTIONS,
+ extractProxyImportLines,
+ getSmartHealthDeleteMode,
+ getSmartHealthIntervalMs,
+ parseProxyLine,
+ summarizeProxyHealthResults,
+} from "../src/app/(dashboard)/dashboard/proxy-pools/utils.js";
+import { buildRotationPatch, canTestProviderConnection, PROXY_ROTATION_INTERVAL_OPTIONS, shouldShowFetchModelsButton } from "../src/app/(dashboard)/dashboard/providers/[id]/providerPageUtils.js";
+import { resolveConnectionProxyUrls, shouldRetryProxyResponse } from "../open-sse/utils/proxyFetch.js";
+import { mergeProviderSpecificData } from "../src/app/api/providers/[id]/route.js";
+import { buildBackgroundLaunchArgs, shouldKeepBackgroundProcessInSession } from "../../hooks/backgroundLaunch.js";
+import { isPeerCliCommand } from "../../hooks/processGuard.js";
+import { applyWebSearchSaver, compressWebSearchText, WEB_SEARCH_SAVER_PROMPT } from "../open-sse/rtk/webSearchSaver.js";
+import { LOCALES, normalizeLocale } from "../src/i18n/config.js";
+import { isPortInUse } from "../src/lib/headroom/process.js";
+import { TERSE_PROMPTS } from "../open-sse/rtk/tersePrompts.js";
+import { PONYTAIL_PROMPTS } from "../open-sse/rtk/ponytailPrompts.js";
+import { CAVEMAN_PROMPTS } from "../open-sse/rtk/cavemanPrompts.js";
+import { applyStylePromptInjection } from "../open-sse/handlers/chatCore.js";
+import { injectSystemPrompt } from "../open-sse/rtk/systemInject.js";
+
+const appRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
+
+describe("locale settings", () => {
+ test("limits dashboard languages to US CN ID", () => {
+ expect(LOCALES).toEqual(["en", "zh-CN", "id"]);
+ expect(normalizeLocale("zh")).toBe("zh-CN");
+ expect(normalizeLocale("vi")).toBe("en");
+ });
+});
+
+describe("quota refresh intervals", () => {
+ test("accepts only supported minute intervals", () => {
+ expect(getRefreshIntervalSeconds(1)).toBe(60);
+ expect(getRefreshIntervalSeconds(5)).toBe(300);
+ expect(getRefreshIntervalSeconds("10")).toBe(600);
+ expect(getRefreshIntervalSeconds(2)).toBe(60);
+ });
+
+ test("keeps Claude quota refresh near three minutes", () => {
+ expect(shouldFetchQuotaOnTick({ provider: "claude" }, 1, 60)).toBe(false);
+ expect(shouldFetchQuotaOnTick({ provider: "claude" }, 3, 60)).toBe(true);
+ expect(shouldFetchQuotaOnTick({ provider: "claude" }, 1, 300)).toBe(true);
+ expect(shouldFetchQuotaOnTick({ provider: "openai" }, 1, 60)).toBe(true);
+ });
+});
+
+describe("proxy retry", () => {
+ test("retries next proxy on rate limits and provider errors", () => {
+ expect(shouldRetryProxyResponse(new Response(null, { status: 429 }))).toBe(true);
+ expect(shouldRetryProxyResponse(new Response(null, { status: 500 }))).toBe(true);
+ expect(shouldRetryProxyResponse(new Response(null, { status: 502 }))).toBe(true);
+ expect(shouldRetryProxyResponse(new Response(null, { status: 400 }))).toBe(false);
+ });
+
+ test("uses proxy list when provided and single proxy otherwise", () => {
+ expect(resolveConnectionProxyUrls("https://example.com", {
+ connectionProxyEnabled: true,
+ connectionProxyUrl: "http://first:8080",
+ connectionProxyUrls: ["http://second:8080"],
+ })).toEqual(["http://second:8080"]);
+ expect(resolveConnectionProxyUrls("https://example.com", {
+ connectionProxyEnabled: true,
+ connectionProxyUrl: "http://first:8080",
+ })).toEqual(["http://first:8080"]);
+ });
+});
+
+describe("proxy SmartHealth", () => {
+ test("summarizes alive and dead proxy pool ids", () => {
+ expect(summarizeProxyHealthResults([
+ { id: "a", ok: true },
+ { id: "b", ok: false },
+ { id: "c", ok: false },
+ ])).toEqual({ alive: 1, deadIds: ["b", "c"] });
+ });
+
+ test("supports scheduled health intervals", () => {
+ expect(SMART_HEALTH_INTERVAL_OPTIONS.map((option) => option.value)).toEqual([15, 30, 60, 360, 720, 1440]);
+ expect(getSmartHealthIntervalMs(15)).toBe(15 * 60 * 1000);
+ expect(getSmartHealthIntervalMs("60")).toBe(60 * 60 * 1000);
+ expect(getSmartHealthIntervalMs(999)).toBe(0);
+ });
+
+ test("supports user-confirmed and automatic dead proxy deletion modes", () => {
+ expect(SMART_HEALTH_DELETE_MODES.map((option) => option.value)).toEqual(["confirm", "auto", "off"]);
+ expect(getSmartHealthDeleteMode("auto")).toBe("auto");
+ expect(getSmartHealthDeleteMode("off")).toBe("off");
+ expect(getSmartHealthDeleteMode("bad")).toBe("confirm");
+ });
+
+ test("uses a larger parallel worker pool", () => {
+ expect(SMART_HEALTH_CONCURRENCY).toBe(25);
+ });
+
+ test("imports proxies from TXT and JSON content", () => {
+ expect(extractProxyImportLines("http://user:pass@127.0.0.1:7897\n127.0.0.1:7898:user:pass")).toHaveLength(2);
+ expect(extractProxyImportLines(JSON.stringify({ proxies: ["http://user:pass@127.0.0.1:7897", { host: "127.0.0.1", port: 7898, username: "user", password: "pass" }] }))).toEqual([
+ "http://user:pass@127.0.0.1:7897",
+ "127.0.0.1:7898:user:pass",
+ ]);
+ expect(parseProxyLine("127.0.0.1:7898:user:pass").proxyUrl).toBe("http://user:pass@127.0.0.1:7898/");
+ });
+});
+
+describe("provider model fetch controls", () => {
+ test("shows fetch models for every provider", () => {
+ expect(shouldShowFetchModelsButton()).toBe(true);
+ expect(shouldShowFetchModelsButton({ hasActiveConnection: false })).toBe(true);
+ });
+
+ test("shows test action for API key and cookie connections", () => {
+ expect(canTestProviderConnection({ provider: "not-whitelisted", authType: "apikey" })).toBe(true);
+ expect(canTestProviderConnection({ provider: "kiro", authType: "api_key" })).toBe(true);
+ expect(canTestProviderConnection({ provider: "chatgpt-plus", authType: "cookie" })).toBe(true);
+ expect(canTestProviderConnection({ provider: "github-copilot", authType: "oauth" })).toBe(false);
+ });
+});
+
+describe("automation dashboard source", () => {
+ test("wires Antigravity bulk automation without external credit links", () => {
+ const source = fs.readFileSync(
+ path.join(appRoot, "src/app/(dashboard)/dashboard/automation/page.js"),
+ "utf8",
+ );
+
+ expect(source).toContain("Antigravity Bulk Auto Login");
+ expect(source).toContain('provider="antigravity"');
+ expect(source).toContain('id: "antigravity"');
+ expect(source).toContain("component: AntigravityAutomationPanel");
+ expect(source).not.toContain("github.com/mrn0sleep");
+ expect(source).not.toContain('target="_blank"');
+ });
+
+ test("exposes Antigravity bulk import API route for the shared modal", () => {
+ const routeSource = fs.readFileSync(
+ path.join(appRoot, "src/app/api/oauth/antigravity/bulk-import/[[...parts]]/route.js"),
+ "utf8",
+ );
+
+ expect(routeSource).toContain("getAntigravityBulkImportManager");
+ expect(routeSource).toContain("export async function GET");
+ expect(routeSource).toContain("export async function POST");
+ expect(routeSource).toContain("parts = []");
+ expect(routeSource).not.toContain("body?.redirectUri");
+ });
+
+ test("reuses Kiro callback monitor with Antigravity state validation", () => {
+ const managerSource = fs.readFileSync(
+ path.join(appRoot, "src/lib/oauth/services/antigravityBulkImportManager.js"),
+ "utf8",
+ );
+
+ expect(managerSource).toContain('import { createKiroCallbackMonitor } from "./kiroGoogleAutomation.js"');
+ expect(managerSource).toContain("createKiroCallbackMonitor(context, page, POLL_TIMEOUT_MS, parseLocalCallbackUrl)");
+ expect(managerSource).toContain("automationResult.state !== authData.state");
+ expect(managerSource).not.toContain("function createLocalCallbackMonitor");
+ });
+
+ test("removes public landing and masuk routes", () => {
+ const guardSource = fs.readFileSync(path.join(appRoot, "src/dashboardGuard.js"), "utf8");
+ const routeSource = fs.readFileSync(
+ path.join(appRoot, "src/app/api/oauth/antigravity/bulk-import/[[...parts]]/route.js"),
+ "utf8",
+ );
+
+ expect(fs.existsSync(path.join(appRoot, "src/app/landing"))).toBe(false);
+ expect(fs.existsSync(path.join(appRoot, "src/app/masuk"))).toBe(false);
+ expect(guardSource).not.toContain("/masuk");
+ });
+});
+
+describe("CLI singleton guard", () => {
+ test("detects relative and absolute router CLI launches", () => {
+ expect(isPeerCliCommand("node cli.js --tray --skip-update --host 127.0.0.1 -p 20128")).toBe(true);
+ expect(isPeerCliCommand("/opt/homebrew/bin/node /Users/blessed/xscope0-modifed-router/cli.js --tray")).toBe(true);
+ expect(isPeerCliCommand("/opt/homebrew/bin/node /opt/homebrew/bin/xscope0-router --tray")).toBe(true);
+ expect(isPeerCliCommand("next-server (v16.2.9)")).toBe(false);
+ expect(isPeerCliCommand("bash -lc ps -axo pid,ppid,command")).toBe(false);
+ });
+
+ test("builds upstream-compatible background tray launch", () => {
+ expect(buildBackgroundLaunchArgs({ port: 20128, host: "127.0.0.1" })).toEqual([
+ "--tray",
+ "--skip-update",
+ "-p",
+ "20128",
+ "--host",
+ "127.0.0.1",
+ ]);
+ expect(shouldKeepBackgroundProcessInSession("darwin")).toBe(true);
+ expect(shouldKeepBackgroundProcessInSession("linux")).toBe(false);
+ expect(shouldKeepBackgroundProcessInSession("win32")).toBe(false);
+ });
+});
+
+describe("provider settings persistence", () => {
+ test("removes cleared provider-specific rotation settings", () => {
+ expect(mergeProviderSpecificData(
+ { autoRotateProxyMinutes: 5, autoRotateProxyOnError: true, proxyPoolId: "pool-1" },
+ { autoRotateProxyMinutes: null }
+ )).toEqual({ autoRotateProxyOnError: true, proxyPoolId: "pool-1" });
+ });
+
+ test("builds provider-wide and selected-key rotation patches", () => {
+ expect(PROXY_ROTATION_INTERVAL_OPTIONS).toEqual([null, 1, 5, 10, 15, 30]);
+ expect(buildRotationPatch({ intervalMinutes: 1, rotateOnError: true })).toEqual({
+ autoRotateProxyMinutes: 1,
+ autoRotateProxyOnError: true,
+ });
+ expect(buildRotationPatch({ intervalMinutes: null, rotateOnError: null })).toEqual({
+ autoRotateProxyMinutes: null,
+ autoRotateProxyOnError: null,
+ });
+ });
+});
+
+describe("web search token saver", () => {
+ test("compresses and reranks web search results", () => {
+ const largeText = "x".repeat(900);
+ const input = JSON.stringify({
+ results: [
+ { title: "Unrelated", url: "https://example.com/other", snippet: largeText, raw: largeText },
+ { title: "Claude web search token budgets", url: "https://example.com/claude", snippet: largeText, raw: largeText },
+ { title: "Duplicate A", url: "https://example.com/a", snippet: largeText },
+ { title: "Duplicate B", url: "https://example.com/b", snippet: largeText },
+ { title: "Duplicate C", url: "https://example.com/c", snippet: largeText },
+ { title: "Duplicate D", url: "https://example.com/d", snippet: largeText },
+ ],
+ });
+
+ const output = JSON.parse(compressWebSearchText(input, "claude web search token budget"));
+
+ expect(output.results).toHaveLength(5);
+ expect(output.results[0].title).toBe("Claude web search token budgets");
+ expect(output.results[0].raw).toBeUndefined();
+ expect(output.results[0].snippet.length).toBeLessThan(input.length);
+ });
+
+ test("injects prompt and compacts outbound web tool content", () => {
+ const body = {
+ model: "test",
+ messages: [
+ { role: "user", content: "Find web search token budget tricks" },
+ { role: "tool", name: "exa_search", content: JSON.stringify({ results: [{ title: "Token budget", url: "https://example.com", snippet: "x".repeat(900), raw: "x".repeat(900) }] }) },
+ ],
+ };
+
+ const stats = applyWebSearchSaver(body, "openai", true);
+
+ expect(stats.hits).toBe(1);
+ expect(body.messages[0].content).toContain(WEB_SEARCH_SAVER_PROMPT);
+ expect(body.messages[2].content).not.toContain("raw");
+ });
+});
+
+describe("style injection methods", () => {
+ test("old method appends to the first system message", () => {
+ const body = {
+ messages: [
+ { role: "system", content: "Base" },
+ { role: "user", content: "Say hi" },
+ ],
+ };
+
+ injectSystemPrompt(body, "openai", "Style prompt", { method: "old" });
+
+ expect(body.messages).toHaveLength(2);
+ expect(body.messages[0].content).toBe("Base\n\nStyle prompt");
+ });
+
+ test("new method inserts one system prompt before final user", () => {
+ const body = {
+ messages: [
+ { role: "system", content: "Base" },
+ { role: "user", content: "First" },
+ { role: "assistant", content: "Answer" },
+ { role: "user", content: "Last" },
+ ],
+ };
+
+ injectSystemPrompt(body, "openai", "Style prompt", { method: "new" });
+
+ expect(body.messages).toHaveLength(5);
+ expect(body.messages[3]).toEqual({ role: "system", content: "Style prompt" });
+ expect(body.messages[4].content).toBe("Last");
+ });
+
+ test("new method does not splice system role into Responses input", () => {
+ const body = {
+ input: [
+ { role: "user", content: [{ type: "input_text", text: "Last" }] },
+ ],
+ };
+
+ injectSystemPrompt(body, "openai-responses", "Style prompt", { method: "new" });
+
+ expect(body.input).toHaveLength(1);
+ expect(body.input[0].content[0]).toEqual({
+ type: "input_text",
+ text: "Style reminder: follow the active style instructions. Style prompt",
+ });
+ });
+
+ test("array message parts keep their existing text part type", () => {
+ const body = {
+ messages: [
+ { role: "user", content: [{ type: "text", text: "Last" }] },
+ ],
+ };
+
+ injectSystemPrompt(body, "openai", "Style prompt", { method: "new" });
+
+ expect(body.messages[0]).toEqual({ role: "system", content: "Style prompt" });
+ expect(body.messages[1].content[0]).toEqual({ type: "text", text: "Last" });
+ });
+
+ test("stale multiple style settings inject one new-method prompt", () => {
+ const body = { messages: [{ role: "user", content: "Last" }] };
+ const active = applyStylePromptInjection(body, "openai-chat", {
+ styleInjectionMethod: "new",
+ terseEnabled: true,
+ terseLevel: "aggressive",
+ cavemanEnabled: true,
+ cavemanLevel: "ultra",
+ ponytailEnabled: true,
+ ponytailLevel: "ultra",
+ });
+
+ expect(active).toBe("ponytail");
+ expect(body.messages).toHaveLength(2);
+ expect(body.messages[0].content).toBe(PONYTAIL_PROMPTS.ultra);
+ expect(body.messages[1].content).toBe("Last");
+ });
+});
+
+describe("Headroom process guard", () => {
+ test("detects occupied ports before spawning", async () => {
+ const server = net.createServer();
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const address = server.address();
+
+ try {
+ expect(await isPortInUse(address.port)).toBe(true);
+ } finally {
+ await new Promise((resolve) => server.close(resolve));
+ }
+ });
+});
+
+describe("style prompt sync", () => {
+ test("terse keeps upstream safety exclusions", () => {
+ expect(TERSE_PROMPTS.medium).toContain("Do not compress");
+ expect(TERSE_PROMPTS.medium).toContain("planning");
+ expect(TERSE_PROMPTS.medium).toContain("security review");
+ });
+
+ test("ponytail checks existing code and root cause first", () => {
+ expect(PONYTAIL_PROMPTS.full).toContain("Already in this codebase");
+ expect(PONYTAIL_PROMPTS.full).toContain("root cause, not symptom");
+ });
+
+ test("caveman still preserves ultra classical mode", () => {
+ expect(CAVEMAN_PROMPTS["wenyan-ultra"]).toContain("文言文 ultra");
+ });
+});
diff --git a/app/tests/proxy-config.test.js b/app/tests/proxy-config.test.js
new file mode 100644
index 00000000..001802e2
--- /dev/null
+++ b/app/tests/proxy-config.test.js
@@ -0,0 +1,28 @@
+import { describe, expect, test, vi } from "vitest";
+
+vi.mock("@/models", () => ({
+ getProxyPoolById: async (id) => {
+ if (id !== "pool-1") return null;
+ return {
+ id,
+ type: "http",
+ proxyUrl: "http://first:8080",
+ proxyUrls: ["http://first:8080", "http://second:8080"],
+ noProxy: "localhost",
+ isActive: true,
+ strictProxy: true,
+ };
+ },
+}));
+
+describe("resolveConnectionProxyConfig", () => {
+ test("returns pool proxy rotation list and strict mode", async () => {
+ const { resolveConnectionProxyConfig } = await import("../src/lib/network/connectionProxy.js");
+
+ const config = await resolveConnectionProxyConfig({ proxyPoolId: "pool-1" });
+
+ expect(config.connectionProxyUrl).toBe("http://first:8080");
+ expect(config.connectionProxyUrls).toEqual(["http://first:8080", "http://second:8080"]);
+ expect(config.strictProxy).toBe(true);
+ });
+});
diff --git a/app/tests/proxy-fetch-rotation.test.js b/app/tests/proxy-fetch-rotation.test.js
new file mode 100644
index 00000000..fcf807ad
--- /dev/null
+++ b/app/tests/proxy-fetch-rotation.test.js
@@ -0,0 +1,34 @@
+import { describe, expect, test, vi } from "vitest";
+
+vi.mock("undici", () => ({
+ ProxyAgent: class ProxyAgent {
+ constructor(options) {
+ this.options = options;
+ }
+ },
+}));
+
+describe("proxyAwareFetch", () => {
+ test("falls back to direct after all proxies return retryable responses", async () => {
+ vi.resetModules();
+ const fetchMock = vi.fn()
+ .mockResolvedValueOnce(new Response("proxy one limited", { status: 429 }))
+ .mockResolvedValueOnce(new Response("proxy two failed", { status: 502 }))
+ .mockResolvedValueOnce(new Response("direct ok", { status: 200 }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ const { proxyAwareFetch } = await import("../open-sse/utils/proxyFetch.js");
+
+ const response = await proxyAwareFetch("https://example.com/v1/chat", {}, {
+ connectionProxyEnabled: true,
+ connectionProxyUrls: ["http://proxy-one:8080", "http://proxy-two:8080"],
+ });
+
+ expect(response.status).toBe(200);
+ expect(await response.text()).toBe("direct ok");
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ expect(fetchMock.mock.calls[0][1].dispatcher).toBeTruthy();
+ expect(fetchMock.mock.calls[1][1].dispatcher).toBeTruthy();
+ expect(fetchMock.mock.calls[2][1]).toEqual({});
+ });
+});
diff --git a/app/tests/upstream-v0518.test.js b/app/tests/upstream-v0518.test.js
new file mode 100644
index 00000000..8f35ceb2
--- /dev/null
+++ b/app/tests/upstream-v0518.test.js
@@ -0,0 +1,72 @@
+import { describe, expect, test } from "vitest";
+import { calculateCostFromTokens } from "../open-sse/providers/pricing.js";
+import { getCapabilitiesForModel } from "../open-sse/providers/capabilities.js";
+import providers from "../open-sse/providers/registry/index.js";
+import { createSSEStream } from "../open-sse/utils/stream.js";
+import { extractUsage } from "../open-sse/utils/usageTracking.js";
+
+async function runStreamThrough(transform, chunks) {
+ const writer = transform.writable.getWriter();
+ const reader = transform.readable.getReader();
+ const output = [];
+ const readLoop = (async () => {
+ const decoder = new TextDecoder();
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ output.push(decoder.decode(value));
+ }
+ })();
+
+ const encoder = new TextEncoder();
+ for (const chunk of chunks) await writer.write(encoder.encode(chunk));
+ await writer.close();
+ await readLoop;
+ return output.join("");
+}
+
+describe("v0.5.18 cached token usage", () => {
+ test("prices cache creation as a subset of inclusive prompt tokens", () => {
+ const pricing = { input: 3, output: 15, cached: 0.3, cache_creation: 3.75 };
+ const cost = calculateCostFromTokens(
+ { prompt_tokens: 330, completion_tokens: 50, cached_tokens: 200, cache_creation_input_tokens: 30 },
+ pricing
+ );
+ const expected = (100 * 3 + 200 * 0.3 + 30 * 3.75 + 50 * 15) / 1_000_000;
+ expect(cost).toBeCloseTo(expected, 12);
+ });
+
+ test("extracts Claude message_start cache usage before output-only message_delta", () => {
+ const usage = extractUsage({
+ type: "message_start",
+ message: { usage: { input_tokens: 100, output_tokens: 1, cache_read_input_tokens: 200, cache_creation_input_tokens: 30 } },
+ });
+ expect(usage).toMatchObject({ prompt_tokens: 100, completion_tokens: 1, cache_read_input_tokens: 200, cache_creation_input_tokens: 30 });
+ });
+});
+
+describe("v0.5.18 streaming robustness", () => {
+ test("dedupes empty tool_calls arrays while preserving reasoning deltas", async () => {
+ const transform = createSSEStream({ mode: "passthrough", body: { messages: [] }, provider: "codebuddy-cn", model: "m" });
+ const output = await runStreamThrough(transform, [
+ 'data: {"choices":[{"delta":{"reasoning_content":"think","tool_calls":[]},"finish_reason":null}]}\n\n',
+ 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
+ ]);
+ expect(output).toContain('"reasoning_content":"think"');
+ expect(output).not.toContain('"tool_calls":[]');
+ });
+});
+
+describe("v0.5.18 provider updates", () => {
+ test("registers ClinePass provider", () => {
+ expect(providers.some((provider) => provider.id === "clinepass")).toBe(true);
+ });
+
+ test("reports Kiro Claude Sonnet 5 as a 1M adaptive-thinking model", () => {
+ expect(getCapabilitiesForModel("kiro", "claude-sonnet-5")).toMatchObject({ contextWindow: 1_000_000, maxOutput: 128_000, thinkingFormat: "claude-adaptive", reasoning: true, vision: true, search: true });
+ });
+
+ test("updates NVIDIA reasoning model capabilities", () => {
+ expect(getCapabilitiesForModel("nvidia", "minimaxai/minimax-m3")).toMatchObject({ contextWindow: 512_000, maxOutput: 131_072, thinkingFormat: "openai", reasoning: true, vision: true });
+ });
+});
diff --git a/app/tests/vitest.config.js b/app/tests/vitest.config.js
index 75e5e71f..914380dd 100644
--- a/app/tests/vitest.config.js
+++ b/app/tests/vitest.config.js
@@ -1,9 +1,19 @@
+import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
+const rootDir = fileURLToPath(new URL("..", import.meta.url));
+
export default defineConfig({
+ resolve: {
+ alias: {
+ "@": fileURLToPath(new URL("../src", import.meta.url)),
+ "open-sse": fileURLToPath(new URL("../open-sse", import.meta.url)),
+ },
+ },
test: {
environment: "node",
globals: true,
passWithNoTests: true,
+ root: rootDir,
},
});
diff --git a/cli.js b/cli.js
index ed3dd0a0..5642440f 100755
--- a/cli.js
+++ b/cli.js
@@ -43,8 +43,11 @@ function createSpinner(text) {
}
const pkg = require("./package.json");
+const { ensureAppRuntime } = require("./hooks/appRuntime");
const { ensureSqliteRuntime, buildEnvWithRuntime } = require("./hooks/sqliteRuntime");
const { ensureTrayRuntime } = require("./hooks/trayRuntime");
+const { isPeerCliCommand } = require("./hooks/processGuard");
+const { buildBackgroundLaunchArgs, shouldKeepBackgroundProcessInSession } = require("./hooks/backgroundLaunch");
const args = process.argv.slice(2);
// APP_PID_FILE uses getAppDataDir() for consistency across platforms
function getAppPidFile() {
@@ -106,11 +109,14 @@ function killPeerAppProcesses() {
const output = execSync("ps -eo pid,command 2>/dev/null", { encoding: "utf8", timeout: 5000 });
output.split("\n").forEach(line => {
const cmd = line.toLowerCase();
- const isPeer = cmd.includes("node") && (
- cmd.includes("/opt/homebrew/bin/9router") ||
- cmd.includes("/opt/homebrew/bin/xscope0-router") ||
- cmd.includes("/9router/cli.js")
- );
+ const isShellProbe =
+ cmd.includes(" zsh -lc ") ||
+ cmd.includes(" bash -lc ") ||
+ cmd.includes(" rtk ") ||
+ cmd.includes(" rg ");
+ if (isShellProbe) return;
+ const command = line.trim().replace(/^\d+\s+/, "");
+ const isPeer = isPeerCliCommand(command);
if (!isPeer) return;
const pid = line.trim().split(/\s+/)[0];
killPid(pid);
@@ -118,11 +124,11 @@ function killPeerAppProcesses() {
} catch { }
}
-function writeAppPidFile() {
+function writeAppPidFile(pid = process.pid) {
try {
const pidFile = getAppPidFile();
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
- fs.writeFileSync(pidFile, process.pid.toString());
+ fs.writeFileSync(pidFile, pid.toString());
} catch { }
}
@@ -140,6 +146,13 @@ if (!isInfoOnly) {
// better-sqlite3 is optional. Logs to stderr only on failure.
if (!isInfoOnly) try { ensureSqliteRuntime({ silent: true }); } catch {}
+// npm strips nested app/node_modules from global packages. Ensure the dashboard
+// runtime deps (not dev deps, no browser downloads) exist before spawning Next.
+if (!isInfoOnly) try { ensureAppRuntime({ silent: true }); } catch (e) {
+ console.error(`Error: ${e.message}`);
+ process.exit(1);
+}
+
// Self-heal tray runtime (systray for macOS/Linux only). Windows skipped.
if (!isInfoOnly) try { ensureTrayRuntime({ silent: true }); } catch {}
@@ -148,7 +161,8 @@ const APP_NAME = pkg.name; // Use from package.json
const INSTALL_CMD_LATEST = `npm i -g ${APP_NAME}@latest --prefer-online`;
const DEFAULT_PORT = 20128;
-const DEFAULT_HOST = "0.0.0.0";
+const DEFAULT_HOST = "127.0.0.1";
+const EXPOSED_HOST = "0.0.0.0";
// First non-internal IPv4 — the address remote peers actually reach when bound to 0.0.0.0.
function getLanIp() {
@@ -162,7 +176,7 @@ function getLanIp() {
// Local URL stays "localhost"; warn separately when bound to all interfaces (network-exposed).
function getDisplayHost() {
- return host === DEFAULT_HOST ? "localhost" : host;
+ return host === EXPOSED_HOST || host === "127.0.0.1" ? "localhost" : host;
}
const MAX_PORT_ATTEMPTS = 10;
// Identifiers for killAllAppProcesses - only kill 9router specifically
@@ -177,6 +191,7 @@ let noBrowser = false;
let skipUpdate = false;
let showLog = false;
let trayMode = false;
+let backgroundMode = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === "--port" || args[i] === "-p") {
@@ -194,16 +209,19 @@ for (let i = 0; i < args.length; i++) {
} else if (args[i] === "--tray" || args[i] === "-t") {
trayMode = true;
process.env.TRAY_MODE = "1";
+ } else if (args[i] === "--background" || args[i] === "--bg" || args[i] === "-b") {
+ backgroundMode = true;
} else if (args[i] === "--help" || args[i] === "-h") {
console.log(`
Usage: ${APP_NAME} [options]
Options:
-p, --port Port to run the server (default: ${DEFAULT_PORT})
- -H, --host Host to bind (default: ${DEFAULT_HOST})
+ -H, --host Host to bind (default: ${DEFAULT_HOST}; use 0.0.0.0 for LAN)
-n, --no-browser Don't open browser automatically
-l, --log Show server logs (default: hidden)
- -t, --tray Run in system tray mode (background)
+ -t, --tray Run with system tray supervisor
+ -b, --background Run in background/tray mode (--bg also works)
--skip-update Skip auto-update check
-h, --help Show this help message
-v, --version Show version
@@ -322,6 +340,12 @@ function killAllAppProcesses(appPort) {
// Whitelist: real node process running 9router/cli.js, or next-server.
// Avoids killing editors/grep/strace/cursor that just have "9router" in cmdline.
const cmd = line.toLowerCase();
+ const isShellProbe =
+ cmd.includes(" zsh -lc ") ||
+ cmd.includes(" bash -lc ") ||
+ cmd.includes(" rtk ") ||
+ cmd.includes(" rg ");
+ if (isShellProbe) return;
const isAppProcess =
(cmd.includes("node") && (cmd.includes("9router") || cmd.includes("xscope0-router")) && (cmd.includes("cli.js") || cmd.includes("\\9router") || cmd.includes("/9router") || cmd.includes("xscope0-router")))
|| cmd.includes("next-server");
@@ -348,6 +372,12 @@ function killAllAppProcesses(appPort) {
// Whitelist: real node process running 9router/cli.js, or next-server.
// Avoids killing grep/strace/editors/cursor that incidentally match "9router".
const cmd = line.toLowerCase();
+ const isShellProbe =
+ cmd.includes(" zsh -lc ") ||
+ cmd.includes(" bash -lc ") ||
+ cmd.includes(" rtk ") ||
+ cmd.includes(" rg ");
+ if (isShellProbe) return;
const isAppProcess =
(cmd.includes("node") && (cmd.includes("9router") || cmd.includes("xscope0-router")) && (cmd.includes("cli.js") || cmd.includes("/9router") || cmd.includes("xscope0-router")))
|| cmd.includes("next-server");
@@ -395,6 +425,21 @@ function sleepSync(ms) {
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* ignore */ }
}
+function copyMissingTree(src, dest) {
+ if (!fs.existsSync(src)) return;
+ const stat = fs.statSync(src);
+ if (stat.isDirectory()) {
+ fs.mkdirSync(dest, { recursive: true });
+ for (const name of fs.readdirSync(src)) {
+ copyMissingTree(path.join(src, name), path.join(dest, name));
+ }
+ return;
+ }
+ if (fs.existsSync(dest)) return;
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
+ fs.copyFileSync(src, dest);
+}
+
// Wait until process dies or timeout reached
function waitForExit(pid, timeoutMs) {
const deadline = Date.now() + timeoutMs;
@@ -553,6 +598,56 @@ function checkForUpdate() {
});
}
+async function showInterfaceMenu(latestVersion) {
+ const { selectMenu } = require("./src/cli/utils/input");
+ const { clearScreen } = require("./src/cli/utils/display");
+ const { getEndpoint } = require("./src/cli/utils/endpoint");
+
+ clearScreen();
+
+ const displayHost = getDisplayHost();
+ let serverUrl;
+ try {
+ const { endpoint, tunnelEnabled } = await getEndpoint(port);
+ serverUrl = tunnelEnabled ? endpoint.replace(/\/v1$/, "") : `http://${displayHost}:${port}`;
+ } catch (e) {
+ serverUrl = `http://${displayHost}:${port}`;
+ }
+
+ const subtitle = `🚀 Server: \x1b[38;2;190;80;80m${serverUrl}\x1b[0m`;
+ const startupBanner = String.raw` _______
+___ ___ ______ ____ ____ ______ ____ \ _ \
+\ \/ / / ___// ___\/ _ \\____ \_/ __ \/ /_\ \
+ > < \___ \\ \__( <_> ) |_> > ___/\ \_/ \
+/__/\_ \/____ >\___ >____/| __/ \___ >\_____ /
+ \/ \/ \/ |__| \/ \/
+$$$$$$$\ $$\ $$\
+$$ __$$\ $$ | $$ |
+$$ | $$ | $$$$$$\ $$$$$$$ | $$$$$$\ $$$$$$\ $$$$$$\ $$$$\$$$$\
+$$$$$$$ |$$ __$$\ $$ __$$ | \_$$ _| $$ __$$\ \____$$\ $$ _$$ _$$\
+$$ __$$< $$$$$$$$ |$$ / $$ | $$ | $$$$$$$$ | $$$$$$$ |$$ / $$ / $$ |
+$$ | $$ |$$ ____|$$ | $$ | $$ |$$\ $$ ____|$$ __$$ |$$ | $$ | $$ |
+$$ | $$ |\$$$$$$$\ \$$$$$$$ | \$$$$ |\$$$$$$$\ \$$$$$$$ |$$ | $$ | $$ |
+\__| \__| \_______| \_______| \____/ \_______| \_______|\__| \__| \__|`;
+
+ const menuItems = [];
+ if (latestVersion) menuItems.push({ label: `Update to v${latestVersion} (current: v${pkg.version})`, icon: "⬆" });
+ menuItems.push(
+ { label: "Web UI (Open in Browser)", icon: "🌐" },
+ { label: "Terminal UI (Interactive CLI)", icon: "💻" },
+ { label: "Run in Background (Tray)", icon: "🔔" },
+ { label: "Exit", icon: "🚪" }
+ );
+
+ const selected = await selectMenu(`Choose Interface (v${pkg.version})`, menuItems, 0, subtitle, "", [], startupBanner);
+ const offset = latestVersion ? 1 : 0;
+ if (latestVersion && selected === 0) return "update";
+ if (selected === offset) return "web";
+ if (selected === offset + 1) return "terminal";
+ if (selected === offset + 2) return "background";
+ return "exit";
+}
+
// Open browser
function openBrowser(url) {
const platform = process.platform;
@@ -576,12 +671,36 @@ function openBrowser(url) {
// Find standalone server (bundled in bin/app for published package).
// Prefer custom-server.js (injects real socket IP) when present.
const standaloneDir = path.join(__dirname, "app");
+const productionBuildDir = path.join(standaloneDir, ".next-cli-build");
+const productionBuildId = path.join(productionBuildDir, "BUILD_ID");
+const nextBuildDir = path.join(standaloneDir, ".next");
+const nextBuildId = path.join(nextBuildDir, "BUILD_ID");
const customServerPath = path.join(standaloneDir, "custom-server.js");
const serverPath = fs.existsSync(customServerPath)
? customServerPath
: path.join(standaloneDir, "server.js");
-if (!fs.existsSync(serverPath)) {
+function ensureProductionBuildDir() {
+ if (fs.existsSync(productionBuildId)) return true;
+ if (!fs.existsSync(nextBuildId)) return false;
+ const tmp = path.join(standaloneDir, `.next-cli-build.repair-${process.pid}`);
+ const backup = path.join(standaloneDir, `.next-cli-build.broken-${process.pid}`);
+ try {
+ fs.rmSync(tmp, { recursive: true, force: true });
+ fs.rmSync(backup, { recursive: true, force: true });
+ copyMissingTree(path.join(productionBuildDir, "static"), path.join(tmp, "static"));
+ fs.cpSync(nextBuildDir, tmp, { recursive: true });
+ if (fs.existsSync(productionBuildDir)) fs.renameSync(productionBuildDir, backup);
+ fs.renameSync(tmp, productionBuildDir);
+ fs.rmSync(backup, { recursive: true, force: true });
+ return fs.existsSync(productionBuildId);
+ } catch (e) {
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {}
+ return false;
+ }
+}
+
+if (!fs.existsSync(serverPath) || !ensureProductionBuildDir()) {
console.error("Error: Standalone build not found.");
console.error("Please run 'npm run build' first.");
process.exit(1);
@@ -596,50 +715,6 @@ checkForUpdate().then((latestVersion) => {
});
});
-// Show interface selection menu
-async function showInterfaceMenu(latestVersion) {
- const { selectMenu } = require("./src/cli/utils/input");
- const { clearScreen } = require("./src/cli/utils/display");
- const { getEndpoint } = require("./src/cli/utils/endpoint");
-
- clearScreen();
-
- const displayHost = getDisplayHost();
-
- // Detect tunnel/local mode for server URL display
- let serverUrl;
- try {
- const { endpoint, tunnelEnabled } = await getEndpoint(port);
- serverUrl = tunnelEnabled ? endpoint.replace(/\/v1$/, "") : `http://${displayHost}:${port}`;
- } catch (e) {
- serverUrl = `http://${displayHost}:${port}`;
- }
-
- const subtitle = `🚀 Server: \x1b[38;2;190;80;80m${serverUrl}\x1b[0m`;
-
- const menuItems = [];
-
- if (latestVersion) {
- menuItems.push({ label: `Update to v${latestVersion} (current: v${pkg.version})`, icon: "⬆" });
- }
-
- menuItems.push(
- { label: "Web UI (Open in Browser)", icon: "🌐" },
- { label: "Terminal UI (Interactive CLI)", icon: "💻" },
- { label: "Hide to Tray (Background)", icon: "🔔" },
- { label: "Exit", icon: "🚪" }
- );
-
- const selected = await selectMenu(`Choose Interface (v${pkg.version})`, menuItems, 0, subtitle);
-
- const offset = latestVersion ? 1 : 0;
-
- if (latestVersion && selected === 0) return "update";
- if (selected === offset) return "web";
- if (selected === offset + 1) return "terminal";
- if (selected === offset + 2) return "hide";
- return "exit";
-}
const MAX_RESTARTS = 2;
const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s
@@ -648,7 +723,7 @@ function startServer(latestVersion) {
const displayHost = getDisplayHost();
const url = `http://${displayHost}:${port}/dashboard`;
// Surface real network exposure when bound to all interfaces (default 0.0.0.0).
- if (host === DEFAULT_HOST) {
+ if (host === EXPOSED_HOST) {
const lanIp = getLanIp();
if (lanIp) console.log(`\x1b[33m⚠ Network-exposed: reachable at http://${lanIp}:${port} (bound 0.0.0.0). Use --host 127.0.0.1 for local-only.\x1b[0m`);
}
@@ -658,13 +733,14 @@ function startServer(latestVersion) {
const CRASH_LOG_LINES = 50;
let crashLog = [];
+ const stdio = backgroundMode ? "ignore" : (showLog ? "inherit" : ["ignore", "ignore", "pipe"]);
function spawnServer() {
serverStartTime = Date.now();
crashLog = [];
const child = spawn(RUNTIME, ["--max-old-space-size=6144", serverPath], {
cwd: standaloneDir,
- stdio: showLog ? "inherit" : ["ignore", "ignore", "pipe"],
+ stdio,
detached: true,
windowsHide: true,
env: {
@@ -673,7 +749,7 @@ function startServer(latestVersion) {
HOSTNAME: host
}
});
- if (!showLog && child.stderr) {
+ if (!backgroundMode && !showLog && child.stderr) {
child.stderr.on("data", (data) => {
const lines = data.toString().split("\n").filter(Boolean);
crashLog.push(...lines);
@@ -712,9 +788,13 @@ function startServer(latestVersion) {
// Suppress all errors during shutdown (systray lib throws JSON parse errors)
let isShuttingDown = false;
+ let lastUncaughtLogAt = 0;
process.on("uncaughtException", (err) => {
if (isShuttingDown) return;
- console.error("Error:", err.message);
+ const now = Date.now();
+ if (now - lastUncaughtLogAt < 5000) return;
+ lastUncaughtLogAt = now;
+ console.error("Error:", err?.message || String(err));
});
// Handle all exit scenarios
@@ -757,6 +837,42 @@ function startServer(latestVersion) {
}
};
+ if (backgroundMode) {
+ try {
+ const { enableAutoStart } = require("./src/cli/tray/autostart");
+ enableAutoStart(__filename, { port, host });
+ } catch (e) { }
+
+ if (shouldKeepBackgroundProcessInSession(process.platform)) {
+ process.removeAllListeners("SIGHUP");
+ process.on("SIGHUP", () => {});
+ writeAppPidFile(process.pid);
+ setTimeout(() => {
+ initTrayIcon();
+ console.log(`\n⏳ Switching to tray mode...`);
+ console.log(`🔔 ${pkg.name} is running in tray (PID: ${process.pid})`);
+ console.log(` Server: ${url}`);
+ console.log("\n💡 You can close this terminal. Right-click tray icon to quit.\n");
+ }, 2000);
+ return;
+ }
+
+ console.log("\n⏳ Starting background process... (tray icon will appear in ~3s)");
+ const bgProcess = spawn(process.execPath, [__filename, ...buildBackgroundLaunchArgs({ port, host })], {
+ detached: true,
+ stdio: "ignore",
+ windowsHide: true,
+ env: { ...process.env }
+ });
+ bgProcess.unref();
+ writeAppPidFile(bgProcess.pid);
+ console.log(`🔔 ${pkg.name} is now running in background (PID: ${bgProcess.pid})`);
+ console.log(` Server: ${url}`);
+ console.log("\n💡 You can close this terminal. Right-click tray icon to quit.\n");
+ cleanup();
+ process.exit(0);
+ }
+
// Tray-only mode: no TUI, just tray icon
if (trayMode) {
// Ignore SIGHUP so macOS terminal close doesn't kill the background tray process
@@ -775,9 +891,8 @@ function startServer(latestVersion) {
return;
}
- // Wait for server to be ready, then show interface menu loop + tray
+ // Foreground mode: restore old interactive CLI/menu, with tray alongside it.
setTimeout(async () => {
- // Start tray icon alongside TUI
initTrayIcon();
try {
@@ -789,75 +904,73 @@ function startServer(latestVersion) {
const { clearScreen } = require("./src/cli/utils/display");
clearScreen();
console.log(`\n⬆ Update v${pkg.version} → v${latestVersion}\n`);
- console.log(`Run this after exit:\n`);
+ console.log("Run this after exit:\n");
console.log(` \x1b[33m${INSTALL_CMD_LATEST}\x1b[0m\n`);
cleanup();
await killAllAppProcesses(port);
await killProcessOnPort(port);
setTimeout(() => process.exit(0), 200);
return;
- } else if (choice === "web") {
+ }
+
+ if (choice === "web") {
openBrowser(url);
- // Wait for user to come back
const { pause } = require("./src/cli/utils/input");
await pause("\nPress Enter to go back to menu...");
- } else if (choice === "terminal") {
- // Start Terminal UI - it will return when user selects Back
+ continue;
+ }
+
+ if (choice === "terminal") {
const { startTerminalUI } = require("./src/cli/terminalUI");
await startTerminalUI(port);
- // Loop continues, show menu again
- } else if (choice === "hide") {
+ continue;
+ }
+
+ if (choice === "background") {
const { clearScreen } = require("./src/cli/utils/display");
clearScreen();
-
- // Enable auto startup on OS boot
try {
const { enableAutoStart } = require("./src/cli/tray/autostart");
- enableAutoStart(__filename);
+ enableAutoStart(__filename, { port, host });
} catch (e) { }
- if (process.platform === "darwin") {
- // macOS: keep current process alive — spawning a detached child puts
- // it outside the login session so NSStatusItem silently fails.
+ if (shouldKeepBackgroundProcessInSession(process.platform)) {
process.removeAllListeners("SIGHUP");
process.on("SIGHUP", () => {});
-
- console.log(`\n⏳ Switching to tray mode... (icon already visible in menu bar)`);
- console.log(`🔔 9Router is running in tray (PID: ${process.pid})`);
- console.log(` Server: http://${displayHost}:${port}`);
- console.log(`\n💡 You can close this terminal. Right-click tray icon to quit.\n`);
-
- // Tray already init'd at startup — just keep event loop alive.
+ writeAppPidFile(process.pid);
+ console.log(`\n⏳ Switching to tray mode...`);
+ console.log(`🔔 ${pkg.name} is running in tray (PID: ${process.pid})`);
+ console.log(` Server: ${url}`);
+ console.log("\n💡 You can close this terminal. Right-click tray icon to quit.\n");
return;
}
- // Windows/Linux: spawn detached bgProcess (systray works fine in child)
- console.log(`\n⏳ Starting background process... (tray icon will appear in ~3s)`);
-
- const bgProcess = spawn(process.execPath, [__filename, "--tray", "--skip-update", "-p", port.toString()], {
+ console.log("\n⏳ Starting background process... (tray icon will appear in ~3s)");
+ const bgProcess = spawn(process.execPath, [__filename, ...buildBackgroundLaunchArgs({ port, host })], {
detached: true,
stdio: "ignore",
windowsHide: true,
env: { ...process.env }
});
bgProcess.unref();
-
- console.log(`🔔 9Router is now running in background (PID: ${bgProcess.pid})`);
- console.log(` Server: http://${displayHost}:${port}`);
- console.log(`\n💡 You can close this terminal. Right-click tray icon to quit.\n`);
-
- // cleanup() kills server so bgProcess can claim the port fresh
+ writeAppPidFile(bgProcess.pid);
+ console.log(`🔔 ${pkg.name} is now running in background (PID: ${bgProcess.pid})`);
+ console.log(` Server: ${url}`);
+ console.log("\n💡 You can close this terminal. Right-click tray icon to quit.\n");
cleanup();
process.exit(0);
- } else if (choice === "exit") {
+ }
+
+ if (choice === "exit") {
isShuttingDown = true;
console.log("\nExiting...");
cleanup();
setTimeout(() => process.exit(0), 100);
+ return;
}
}
} catch (err) {
- console.error("Error:", err.message);
+ console.error("Error:", err?.message || String(err));
cleanup();
process.exit(1);
}
@@ -885,6 +998,12 @@ function startServer(latestVersion) {
if (aliveMs >= RESTART_RESET_MS) restartCount = 0;
if (restartCount >= MAX_RESTARTS) {
+ if (trayMode) {
+ console.error(`\n⚠️ Server crashed ${MAX_RESTARTS} times. Stopping tray supervisor.`);
+ isShuttingDown = true;
+ cleanup();
+ process.exit(1);
+ }
console.error(`\n⚠️ Server crashed ${MAX_RESTARTS} times. Disabling MIT and restarting...`);
try {
const dbPath = path.join(os.homedir(), process.platform === "win32" ? path.join("AppData", "Roaming", "9router", "db.json") : path.join(".9router", "db.json"));
diff --git a/hooks/appRuntime.js b/hooks/appRuntime.js
new file mode 100644
index 00000000..01464741
--- /dev/null
+++ b/hooks/appRuntime.js
@@ -0,0 +1,83 @@
+const { spawnSync } = require("child_process");
+const fs = require("fs");
+const path = require("path");
+
+function getRootDir() {
+ return path.join(__dirname, "..");
+}
+
+function getAppDir() {
+ return path.join(getRootDir(), "app");
+}
+
+function readAppPackage() {
+ const pkgPath = path.join(getAppDir(), "package.json");
+ return JSON.parse(fs.readFileSync(pkgPath, "utf8"));
+}
+
+function hasModule(name) {
+ const parts = name.split("/");
+ for (const base of [getAppDir(), getRootDir()]) {
+ const pkgPath = path.join(base, "node_modules", ...parts, "package.json");
+ if (fs.existsSync(pkgPath)) return true;
+ }
+ return false;
+}
+
+function missingRuntimeDeps() {
+ const pkg = readAppPackage();
+ return Object.keys(pkg.dependencies || {}).filter((name) => !hasModule(name));
+}
+
+function summarizeNpmError(stderr = "") {
+ const text = String(stderr);
+ if (/ENOTFOUND|ETIMEDOUT|EAI_AGAIN|network|getaddrinfo/i.test(text)) return "No internet connection or registry unreachable";
+ if (/EACCES|EPERM|permission denied/i.test(text)) return "Permission denied";
+ if (/ENOSPC|no space/i.test(text)) return "Not enough disk space";
+ if (/ETARGET|version.*not found/i.test(text)) return "Package version not found";
+ const npmLine = text.match(/npm ERR! (.+)/);
+ if (npmLine) return npmLine[1].slice(0, 240);
+ return text.trim().split(/\r?\n/).filter(Boolean).pop()?.slice(0, 240) || "Unknown npm error";
+}
+
+function installAppRuntime({ silent = false } = {}) {
+ const appDir = getAppDir();
+ if (!silent) console.log("⏳ Installing dashboard runtime deps (first run)...");
+ const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
+ const res = spawnSync(npmCmd, ["install", "--omit=dev", "--omit=optional", "--ignore-scripts", "--no-audit", "--no-fund", "--prefer-online"], {
+ cwd: appDir,
+ stdio: ["ignore", "pipe", "pipe"],
+ shell: process.platform === "win32",
+ encoding: "utf8",
+ timeout: 600000,
+ env: {
+ ...process.env,
+ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1",
+ PUPPETEER_SKIP_DOWNLOAD: "1",
+ },
+ });
+ return { ok: res.status === 0, code: res.status, stdout: res.stdout || "", stderr: res.stderr || "" };
+}
+
+function ensureAppRuntime({ silent = false } = {}) {
+ let missing = missingRuntimeDeps();
+ if (missing.length === 0) return { installed: false, missing: [] };
+
+ const res = installAppRuntime({ silent });
+ missing = missingRuntimeDeps();
+ if (missing.length === 0) {
+ if (!silent) console.log("✅ Dashboard runtime deps ready");
+ return { installed: true, missing: [] };
+ }
+
+ const reason = summarizeNpmError(res.stderr);
+ const msg = `Dashboard runtime deps missing after install: ${missing.slice(0, 8).join(", ")}${missing.length > 8 ? ", ..." : ""}. Reason: ${reason}`;
+ if (!silent) console.warn(`⚠️ ${msg}`);
+ throw new Error(msg);
+}
+
+module.exports = {
+ ensureAppRuntime,
+ getAppDir,
+ missingRuntimeDeps,
+};
diff --git a/hooks/backgroundLaunch.js b/hooks/backgroundLaunch.js
new file mode 100644
index 00000000..81bf4816
--- /dev/null
+++ b/hooks/backgroundLaunch.js
@@ -0,0 +1,15 @@
+function shouldKeepBackgroundProcessInSession(platform = process.platform) {
+ return platform === "darwin";
+}
+
+function buildBackgroundLaunchArgs({ port, host } = {}) {
+ const args = ["--tray", "--skip-update"];
+ if (port !== undefined && port !== null) args.push("-p", String(port));
+ if (host) args.push("--host", String(host));
+ return args;
+}
+
+module.exports = {
+ buildBackgroundLaunchArgs,
+ shouldKeepBackgroundProcessInSession,
+};
diff --git a/hooks/postinstall.js b/hooks/postinstall.js
index 3a59332f..a8523ba3 100644
--- a/hooks/postinstall.js
+++ b/hooks/postinstall.js
@@ -5,6 +5,7 @@
// cli.js will retry at runtime if anything is missing.
const { ensureSqliteRuntime } = require("./sqliteRuntime");
const { ensureTrayRuntime } = require("./trayRuntime");
+const { ensureAppRuntime } = require("./appRuntime");
try {
ensureSqliteRuntime({ silent: false });
@@ -13,6 +14,12 @@ try {
console.warn(`[9router] runtime warm-up skipped: ${e.message}`);
}
+try {
+ ensureAppRuntime({ silent: false });
+} catch (e) {
+ console.warn(`[9router] dashboard runtime install skipped: ${e.message}`);
+}
+
try {
ensureTrayRuntime({ silent: false });
} catch (e) {
diff --git a/hooks/processGuard.js b/hooks/processGuard.js
new file mode 100644
index 00000000..fe9873a9
--- /dev/null
+++ b/hooks/processGuard.js
@@ -0,0 +1,24 @@
+const path = require("path");
+
+function normalizeCommandLine(command) {
+ return String(command || "").replace(/\\/g, "/").toLowerCase();
+}
+
+function isPeerCliCommand(command) {
+ const cmd = normalizeCommandLine(command);
+ const tokens = cmd.trim().split(/\s+/).map((token) => token.replace(/^['"]|['"]$/g, ""));
+ if (tokens.length < 2) return false;
+
+ const nodeBin = path.basename(tokens[0]);
+ if (!/^node(?:\.exe)?$/.test(nodeBin)) return false;
+
+ return tokens.some((token) => {
+ const normalized = token.replace(/^file:\/\//, "");
+ return normalized === "cli.js" ||
+ normalized.endsWith("/cli.js") ||
+ normalized.endsWith("/9router") ||
+ normalized.endsWith("/xscope0-router");
+ });
+}
+
+module.exports = { isPeerCliCommand };
diff --git a/package.json b/package.json
index a28069e3..3a6945d8 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
- "name": "xscope0-modifed-router",
- "version": "0.7.4",
- "description": "xscope0 Modifed Router - fork of 9router with extra provider automation",
+ "name": "xscope0",
+ "version": "0.9.32",
+ "description": "xScope0 Router - fork of 9router with extra provider automation",
"bin": {
"9router": "./cli.js",
"xscope0-router": "./cli.js"
diff --git a/scripts/build-cli.mjs b/scripts/build-cli.mjs
index 4ba4cda5..d272f1e4 100644
--- a/scripts/build-cli.mjs
+++ b/scripts/build-cli.mjs
@@ -1,7 +1,16 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
-import { cpSync, rmSync } from "node:fs";
-import { join } from "node:path";
+import {
+ copyFileSync,
+ cpSync,
+ existsSync,
+ mkdirSync,
+ readdirSync,
+ renameSync,
+ rmSync,
+ statSync,
+} from "node:fs";
+import { dirname, join } from "node:path";
const root = process.cwd();
const app = join(root, "app");
@@ -9,5 +18,31 @@ const app = join(root, "app");
const res = spawnSync("npx", ["next", "build", "--webpack"], { cwd: app, stdio: "inherit", shell: process.platform === "win32" });
if (res.status !== 0) process.exit(res.status || 1);
-rmSync(join(app, ".next-cli-build"), { recursive: true, force: true });
-cpSync(join(app, ".next"), join(app, ".next-cli-build"), { recursive: true });
+const src = join(app, ".next");
+const target = join(app, ".next-cli-build");
+const tmp = join(app, `.next-cli-build.tmp-${process.pid}`);
+const backup = join(app, `.next-cli-build.prev-${process.pid}`);
+
+function copyMissingTree(src, dest) {
+ if (!existsSync(src)) return;
+ const stat = statSync(src);
+ if (stat.isDirectory()) {
+ mkdirSync(dest, { recursive: true });
+ for (const name of readdirSync(src)) {
+ copyMissingTree(join(src, name), join(dest, name));
+ }
+ return;
+ }
+ if (existsSync(dest)) return;
+ mkdirSync(dirname(dest), { recursive: true });
+ copyFileSync(src, dest);
+}
+
+rmSync(tmp, { recursive: true, force: true });
+rmSync(backup, { recursive: true, force: true });
+copyMissingTree(join(target, "static"), join(tmp, "static"));
+cpSync(src, tmp, { recursive: true });
+
+if (existsSync(target)) renameSync(target, backup);
+renameSync(tmp, target);
+rmSync(backup, { recursive: true, force: true });
diff --git a/src/cli/api/client.js b/src/cli/api/client.js
index a8e779b4..b0cd3829 100644
--- a/src/cli/api/client.js
+++ b/src/cli/api/client.js
@@ -4,10 +4,10 @@ const crypto = require("crypto");
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
-let machineIdSync = null;
+let machineIdSync = () => "";
try {
({ machineIdSync } = require("node-machine-id"));
-} catch {}
+} catch { }
// Default configuration
const DEFAULT_CONFIG = {
@@ -42,7 +42,7 @@ function loadRawMachineId() {
const raw = fs.readFileSync(MACHINE_ID_FILE, "utf8").trim();
if (raw) return raw;
} catch {}
- try { return machineIdSync ? machineIdSync() : ""; } catch { return ""; }
+ try { return machineIdSync(); } catch { return ""; }
}
// Random secret shared with server via file → token unpredictable from machineId alone.
diff --git a/src/cli/tray/autostart.js b/src/cli/tray/autostart.js
index 4ab93cf2..0d11180a 100644
--- a/src/cli/tray/autostart.js
+++ b/src/cli/tray/autostart.js
@@ -44,16 +44,16 @@ function getCliJsPath(cliPath) {
* @param {string} cliPath - Optional path to cli.js (defaults to auto-detect)
* @returns {boolean} success
*/
-function enableAutoStart(cliPath) {
+function enableAutoStart(cliPath, options = {}) {
const platform = process.platform;
if (!["darwin", "win32", "linux"].includes(platform)) return false;
if (platform === "linux" && !process.env.DISPLAY) return false;
try {
- if (platform === "darwin") return enableMacOS(cliPath);
- if (platform === "win32") return enableWindows(cliPath);
- if (platform === "linux") return enableLinux(cliPath);
+ if (platform === "darwin") return enableMacOS(cliPath, options);
+ if (platform === "win32") return enableWindows(cliPath, options);
+ if (platform === "linux") return enableLinux(cliPath, options);
} catch (err) {
// Silent fail — autostart is optional
}
@@ -110,6 +110,12 @@ function isAutoStartEnabled() {
// ============ macOS ============
+function normalizeAutoStartOptions(options = {}) {
+ const host = options.host || "127.0.0.1";
+ const port = Number.isInteger(options.port) && options.port > 0 ? String(options.port) : null;
+ return { host, port };
+}
+
/**
* Returns true when the current Node process IS the running instance that
* launchd is managing under our agent label.
@@ -138,7 +144,7 @@ function isAgentSelfMacOS() {
}
}
-function enableMacOS(cliPath) {
+function enableMacOS(cliPath, options = {}) {
const launchAgentsDir = path.join(os.homedir(), "Library", "LaunchAgents");
const plistPath = path.join(launchAgentsDir, `${APP_LABEL}.plist`);
@@ -158,6 +164,8 @@ function enableMacOS(cliPath) {
// EnvironmentVariables.PATH explicitly includes node's bin dir so child
// processes spawned by cli.js (npm install at runtime, etc.) resolve.
const launchPath = `${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin`;
+ const autoStart = normalizeAutoStartOptions(options);
+ const portArgs = autoStart.port ? `\n -p \n ${autoStart.port} ` : "";
const plistContent = `
@@ -171,6 +179,8 @@ function enableMacOS(cliPath) {
${routerScript}
--tray
--skip-update
+ --host
+ ${autoStart.host} ${portArgs}
EnvironmentVariables
@@ -236,7 +246,7 @@ function disableMacOS() {
// ============ Windows ============
-function enableWindows(cliPath) {
+function enableWindows(cliPath, options = {}) {
const startupDir = path.join(process.env.APPDATA || "", "Microsoft", "Windows", "Start Menu", "Programs", "Startup");
const vbsPath = path.join(startupDir, `${APP_NAME}.vbs`);
@@ -245,11 +255,13 @@ function enableWindows(cliPath) {
const nodePath = process.execPath;
const routerScript = getCliJsPath(cliPath);
if (!routerScript) return false;
+ const autoStart = normalizeAutoStartOptions(options);
+ const portArgs = autoStart.port ? ` -p ${autoStart.port}` : "";
// Run node + cli.js directly, hidden window. Avoids the fragile
// `9router.cmd` lookup that depended on the npm prefix path.
const vbsContent = `Set WshShell = CreateObject("WScript.Shell")
-WshShell.Run """${nodePath}"" ""${routerScript}"" --tray --skip-update", 0, False
+WshShell.Run """${nodePath}"" ""${routerScript}"" --tray --skip-update --host ${autoStart.host}${portArgs}", 0, False
`;
fs.writeFileSync(vbsPath, vbsContent);
return true;
@@ -265,7 +277,7 @@ function disableWindows() {
// ============ Linux ============
-function enableLinux(cliPath) {
+function enableLinux(cliPath, options = {}) {
const autostartDir = path.join(os.homedir(), ".config", "autostart");
const desktopPath = path.join(autostartDir, `${APP_NAME}.desktop`);
@@ -277,12 +289,14 @@ function enableLinux(cliPath) {
const nodePath = process.execPath;
const routerScript = getCliJsPath(cliPath);
if (!routerScript) return false;
+ const autoStart = normalizeAutoStartOptions(options);
+ const portArgs = autoStart.port ? ` -p ${autoStart.port}` : "";
const desktopContent = `[Desktop Entry]
Type=Application
Name=9Router
Comment=9Router API Proxy
-Exec=${nodePath} ${routerScript} --tray --skip-update
+Exec=${nodePath} ${routerScript} --tray --skip-update --host ${autoStart.host}${portArgs}
Hidden=false
NoDisplay=false
X-GNOME-Autostart-enabled=true
diff --git a/src/cli/utils/input.js b/src/cli/utils/input.js
index 842bc27d..d7ea56b9 100644
--- a/src/cli/utils/input.js
+++ b/src/cli/utils/input.js
@@ -91,7 +91,7 @@ async function pause(message = "Press Enter to continue...") {
* (no underline). Uses readline keypress + raw 'data' fallback to prevent
* arrow-key escape sequence leaks on macOS.
*/
-async function selectMenu(title, items, defaultIndex = 0, subtitle = "", headerContent = "", breadcrumb = []) {
+async function selectMenu(title, items, defaultIndex = 0, subtitle = "", headerContent = "", breadcrumb = [], banner = "") {
return new Promise((resolve) => {
let selectedIndex = defaultIndex;
let isActive = true;
@@ -103,6 +103,12 @@ async function selectMenu(title, items, defaultIndex = 0, subtitle = "", headerC
if (!isActive) return;
process.stdout.write("\x1b[2J\x1b[H");
const width = Math.min(process.stdout.columns || 40, 40);
+ if (banner) {
+ console.log(`${COLORS.redTeam}${banner}${COLORS.reset}`);
+ console.log();
+ } else {
+ console.log("DEBUG: No banner provided");
+ }
console.log(`\n${COLORS.redTeam}${"=".repeat(width)}${COLORS.reset}`);
console.log(` ${COLORS.bright}${COLORS.redTeam}${title}${COLORS.reset}`);
if (subtitle) console.log(` ${COLORS.dim}${subtitle}${COLORS.reset}`);