Failed to load usage statistics.
;
+
+ const spinner = (
+
+
+ {notifications.map((n) => {
+ const style = getToastStyle(n.type);
+ return (
+
+
+
{style.icon}
+
+ {n.title ?
{n.title}
: null}
+
{n.message}
+
+ {n.dismissible ? (
+
+ ) : null}
+
+
+ );
+ })}
+
+ {/* Mobile sidebar overlay */}
+ {sidebarOpen && (
+
setSidebarOpen(false)}
+ />
+ )}
+
+ {/* Sidebar - Desktop */}
+
+
+
+
+ {/* Sidebar - Mobile */}
+
+ setSidebarOpen(false)} />
+
+
+ {/* Main content */}
+
+ {/* Faint grid background */}
+
+ setSidebarOpen(true)} />
+
+
+
+ );
+}
diff --git a/src/shared/components/layouts/index.js b/src/shared/components/layouts/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..4b6ac5fc86bc277c282b4673817197170186222f
--- /dev/null
+++ b/src/shared/components/layouts/index.js
@@ -0,0 +1,4 @@
+// Layout Components - Export all
+export { default as DashboardLayout } from "./DashboardLayout";
+export { default as AuthLayout } from "./AuthLayout";
+
diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js
new file mode 100644
index 0000000000000000000000000000000000000000..18ecab727def7730164cdfcbd694e8373714f2ed
--- /dev/null
+++ b/src/shared/constants/cliTools.js
@@ -0,0 +1,394 @@
+// MITM Tools — IDE tools intercepted via MITM proxy
+export const MITM_TOOLS = {
+ antigravity: {
+ id: "antigravity",
+ name: "Antigravity",
+ image: "/providers/antigravity.png",
+ color: "#4285F4",
+ description: "Google Antigravity IDE with MITM",
+ configType: "mitm",
+ mitmDomain: "daily-cloudcode-pa.googleapis.com",
+ modelAliases: ["gemini-3.5-flash-low", "gemini-3-flash-agent", "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", "gemini-pro-agent", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", "gemini-3-flash"],
+ defaultModels: [
+ { id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium) / Default", alias: "gemini-3.5-flash-low" },
+ { id: "gemini-3-flash-agent", name: "Gemini 3.5 Flash (High)", alias: "gemini-3-flash-agent" },
+ { id: "gemini-3.5-flash-extra-low", name: "Gemini 3.5 Flash (Low)", alias: "gemini-3.5-flash-extra-low" },
+ { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)", alias: "gemini-3.1-pro-low" },
+ { id: "gemini-pro-agent", name: "Gemini 3.1 Pro (High)", alias: "gemini-pro-agent" },
+ { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Thinking)", alias: "claude-sonnet-4-6" },
+ { id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 (Thinking)", alias: "claude-opus-4-6-thinking" },
+ { id: "gpt-oss-120b-medium", name: "GPT-OSS 120B (Medium)", alias: "gpt-oss-120b-medium" },
+ { id: "gemini-3-flash", name: "Gemini 3 Flash (Command)", alias: "gemini-3-flash" },
+ ],
+ },
+ copilot: {
+ id: "copilot",
+ name: "GitHub Copilot",
+ image: "/providers/copilot.png",
+ color: "#1F6FEB",
+ description: "GitHub Copilot IDE with MITM",
+ configType: "mitm",
+ mitmDomain: "api.individual.githubcopilot.com",
+ modelAliases: ["gpt-5-mini", "gpt-5.4-nano", "claude-haiku-4.5", "gpt-4o", "gpt-4.1"],
+ defaultModels: [
+ // Verified via live MITM passthrough capture of the GitHub Copilot CLI: its model
+ // picker offers "GPT-5 mini" (default → wire id "gpt-5-mini"), "Claude Haiku 4.5"
+ // ("claude-haiku-4.5") and "Auto". "Auto" is NOT a wire id — Copilot dispatches
+ // concrete models dynamically (observed "gpt-5.4-nano" for light tasks and
+ // "claude-haiku-4.5"), so it needs no slot of its own. Without a slot for
+ // gpt-5-mini / gpt-5.4-nano, getMappedModel returns null and the /chat/completions
+ // call is passed through to GitHub Copilot instead of the configured provider —
+ // and gpt-5-mini is the CLI default, so the primary turn leaks (same class as the
+ // Kiro "auto" misrouting). gpt-4o / gpt-4.1 are kept for the VS Code Copilot Chat picker.
+ { id: "gpt-5-mini", name: "GPT-5 mini", alias: "gpt-5-mini" },
+ { id: "gpt-5.4-nano", name: "GPT-5.4 nano", alias: "gpt-5.4-nano" },
+ { id: "claude-haiku-4.5", name: "Claude Haiku 4.5", alias: "claude-haiku-4.5" },
+ { id: "gpt-4o", name: "GPT-4o", alias: "gpt-4o" },
+ { id: "gpt-4.1", name: "GPT-4.1", alias: "gpt-4.1" },
+ ],
+ },
+ kiro: {
+ id: "kiro",
+ name: "Kiro",
+ image: "/providers/kiro.png",
+ color: "#FF6B00",
+ description: "Kiro IDE with MITM",
+ configType: "mitm",
+ mitmDomain: "q.us-east-1.amazonaws.com",
+ defaultModels: [
+ { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", alias: "claude-sonnet-4.5" },
+ { id: "claude-sonnet-4", name: "Claude Sonnet 4", alias: "claude-sonnet-4" },
+ { id: "claude-haiku-4.5", name: "Claude Haiku 4.5", alias: "claude-haiku-4.5" },
+ { id: "deepseek-3.2", name: "DeepSeek 3.2", alias: "deepseek-3.2" },
+ { id: "minimax-m2.1", name: "MiniMax M2.1", alias: "minimax-m2.1" },
+ { id: "simple-task", name: "Qwen3 Coder Next", alias: "simple-task" },
+ ],
+ },
+ // cursor: {
+ // id: "cursor",
+ // name: "Cursor",
+ // image: "/providers/cursor.png",
+ // color: "#000000",
+ // description: "Cursor IDE with MITM",
+ // configType: "mitm",
+ // mitmDomain: "api2.cursor.sh",
+ // defaultModels: [
+ // { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", alias: "claude-sonnet-4-5" },
+ // { id: "claude-opus-4", name: "Claude Opus 4", alias: "claude-opus-4" },
+ // { id: "gpt-4o", name: "GPT-4o", alias: "gpt-4o" },
+ // ],
+ // },
+};
+
+// CLI Tools configuration
+export const CLI_TOOLS = {
+ claude: {
+ id: "claude",
+ name: "Claude Code",
+ image: "/providers/claude.png",
+ color: "#D97757",
+ description: "Anthropic Claude Code CLI",
+ configType: "env",
+ envVars: {
+ baseUrl: "ANTHROPIC_BASE_URL",
+ model: "ANTHROPIC_MODEL",
+ opusModel: "ANTHROPIC_DEFAULT_OPUS_MODEL",
+ sonnetModel: "ANTHROPIC_DEFAULT_SONNET_MODEL",
+ haikuModel: "ANTHROPIC_DEFAULT_HAIKU_MODEL",
+ },
+ modelAliases: ["default", "sonnet", "opus", "haiku", "opusplan"],
+ settingsFile: "~/.claude/settings.json",
+ defaultModels: [
+ { id: "opus", name: "Claude Opus", alias: "opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-4-6" },
+ { id: "sonnet", name: "Claude Sonnet", alias: "sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL", defaultValue: "cc/claude-sonnet-4-6" },
+ { id: "haiku", name: "Claude Haiku", alias: "haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL", defaultValue: "cc/claude-haiku-4-5-20251001" },
+ ],
+ },
+ openclaw: {
+ id: "openclaw",
+ name: "Open Claw",
+ image: "/providers/openclaw.png",
+ color: "#FF6B35",
+ description: "Open Claw AI Assistant",
+ configType: "custom",
+ },
+ codex: {
+ id: "codex",
+ name: "OpenAI Codex CLI / App",
+ image: "/providers/codex.png",
+ color: "#10A37F",
+ description: "OpenAI Codex CLI",
+ configType: "custom",
+ },
+ opencode: {
+ id: "opencode",
+ name: "OpenCode",
+ image: "/providers/opencode.png",
+ color: "#E87040",
+ description: "OpenCode AI Terminal Assistant",
+ configType: "custom",
+ },
+ cowork: {
+ id: "cowork",
+ name: "Claude Cowork",
+ image: "/providers/claude.png",
+ color: "#D97757",
+ description: "Claude Desktop Cowork (third-party inference)",
+ configType: "custom",
+ },
+ hermes: {
+ id: "hermes",
+ name: "Hermes Agent",
+ image: "/providers/hermes.png",
+ color: "#8B5CF6",
+ description: "Nous Research self-improving AI agent",
+ configType: "custom",
+ },
+ droid: {
+ id: "droid",
+ name: "Factory Droid",
+ image: "/providers/droid.png",
+ color: "#00D4FF",
+ description: "Factory Droid AI Assistant",
+ configType: "custom",
+ },
+ cursor: {
+ id: "cursor",
+ name: "Cursor",
+ image: "/providers/cursor.png",
+ color: "#000000",
+ description: "Cursor AI Code Editor",
+ configType: "guide",
+ requiresExternalUrl: true,
+ notes: [
+ { type: "warning", text: "Requires Cursor Pro account to use this feature." },
+ { type: "cloudCheck", text: "Cursor routes requests through its own server, so local endpoint is not supported. Please enable Tunnel or Cloud Endpoint in Settings." },
+ ],
+ guideSteps: [
+ { step: 1, title: "Open Settings", desc: "Go to Settings → Models" },
+ { step: 2, title: "Enable OpenAI API", desc: "Enable \"OpenAI API key\" option" },
+ { step: 3, title: "Base URL", value: "{{baseUrl}}", copyable: true },
+ { step: 4, title: "API Key", type: "apiKeySelector" },
+ { step: 5, title: "Add Custom Model", desc: "Click \"View All Model\" → \"Add Custom Model\"" },
+ { step: 6, title: "Select Model", type: "modelSelector" },
+ ],
+ },
+ cline: {
+ id: "cline",
+ name: "Cline",
+ image: "/providers/cline.png",
+ color: "#00D1B2",
+ description: "Cline AI Coding Assistant",
+ configType: "custom",
+ },
+ kilo: {
+ id: "kilo",
+ name: "Kilo Code",
+ image: "/providers/kilocode.png",
+ color: "#FF6B6B",
+ description: "Kilo Code AI Assistant",
+ configType: "custom",
+ },
+ roo: {
+ id: "roo",
+ name: "Roo",
+ image: "/providers/roo.png",
+ color: "#FF6B6B",
+ description: "Roo AI Assistant",
+ configType: "guide",
+ guideSteps: [
+ { step: 1, title: "Open Settings", desc: "Go to Roo Settings panel" },
+ { step: 2, title: "Select Provider", desc: "Choose API Provider → Ollama" },
+ { step: 3, title: "Base URL", value: "{{baseUrl}}", copyable: true },
+ { step: 4, title: "API Key", type: "apiKeySelector" },
+ { step: 5, title: "Select Model", type: "modelSelector" },
+ ],
+ },
+ continue: {
+ id: "continue",
+ name: "Continue",
+ image: "/providers/continue.png",
+ color: "#7C3AED",
+ description: "Continue AI Assistant",
+ configType: "guide",
+ guideSteps: [
+ { step: 1, title: "Open Config", desc: "Open Continue configuration file" },
+ { step: 2, title: "API Key", type: "apiKeySelector" },
+ { step: 3, title: "Select Model", type: "modelSelector" },
+ { step: 4, title: "Add Model Config", desc: "Add the following configuration to your models array:" },
+ ],
+ codeBlock: {
+ language: "json",
+ code: `{
+ "apiBase": "{{baseUrl}}",
+ "title": "{{model}}",
+ "model": "{{model}}",
+ "provider": "openai",
+ "apiKey": "{{apiKey}}"
+}`,
+ },
+ },
+ amp: {
+ id: "amp",
+ name: "Amp CLI",
+ image: "/providers/amp.png",
+ color: "#F97316",
+ description: "Sourcegraph Amp coding assistant CLI",
+ docsUrl: "/docs?section=cli-tools&tool=amp",
+ configType: "guide",
+ defaultCommand: "amp",
+ modelAliases: ["g25p", "g25f", "cs45", "g54"],
+ notes: [
+ { type: "info", text: "Use 9Router model aliases to keep Amp shorthand mappings stable across provider updates." },
+ { type: "warning", text: "Suggested shorthand examples: g25p → gemini/gemini-2.5-pro, g25f → gemini/gemini-2.5-flash, cs45 → cc/claude-sonnet-4-5-20250929." },
+ ],
+ guideSteps: [
+ { step: 1, title: "Install Amp", desc: "Install the Amp CLI using the package manager supported by your environment." },
+ { step: 2, title: "API Key", type: "apiKeySelector" },
+ { step: 3, title: "Base URL", value: "{{baseUrl}}", copyable: true },
+ { step: 4, title: "Select Model", type: "modelSelector" },
+ { step: 5, title: "Add Shorthands", desc: "Map Amp shorthand names such as g25p or cs45 to 9Router aliases in your local config." },
+ ],
+ codeBlock: {
+ language: "bash",
+ code: `export OPENAI_API_KEY="{{apiKey}}"
+export OPENAI_BASE_URL="{{baseUrl}}"
+amp --model "{{model}}"
+# Example shorthand aliases you can map locally:
+# g25p -> gemini/gemini-2.5-pro
+# cs45 -> cc/claude-sonnet-4-5-20250929`,
+ },
+ },
+ qwen: {
+ id: "qwen",
+ name: "Qwen Code",
+ image: "/providers/qwen.png",
+ color: "#10B981",
+ description: "Alibaba Qwen Code CLI — supports OpenAI, Anthropic & Gemini providers via 9Router",
+ docsUrl: "https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/",
+ configType: "guide",
+ defaultCommand: "qwen",
+ notes: [
+ { type: "info", text: "Qwen Code supports multiple provider types (openai, anthropic, gemini) via modelProviders in settings.json. 9Router works as an OpenAI-compatible endpoint." },
+ { type: "info", text: "Any model available in 9Router can be used — not just Qwen models. Select from Qwen, Claude, Gemini, GPT, and more." },
+ { type: "warning", text: "Config path: Linux/macOS ~/.qwen/settings.json • Windows %USERPROFILE%\\.qwen\\settings.json" },
+ { type: "error", text: "Qwen OAuth free tier was discontinued on 2026-04-15. Use 9Router with alicode/openrouter/anthropic/gemini providers instead." },
+ ],
+ modelAliases: ["coder-model", "qwen3-coder-plus", "qwen3-coder-flash", "vision-model", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gemini-3-flash", "gemini-3.1-pro-high"],
+ defaultModels: [
+ { id: "coder-model", name: "Coder Model (Qwen 3.6 Plus)", alias: "coder-model", envKey: "OPENAI_MODEL", defaultValue: "coder-model", isTopLevel: true },
+ { id: "qwen3-coder-plus", name: "Qwen 3 Coder Plus", alias: "qwen3-coder-plus", envKey: "OPENAI_MODEL", defaultValue: "qwen3-coder-plus" },
+ { id: "qwen3-coder-flash", name: "Qwen 3 Coder Flash", alias: "qwen3-coder-flash", envKey: "OPENAI_MODEL", defaultValue: "qwen3-coder-flash" },
+ { id: "vision-model", name: "Vision Model (Multimodal)", alias: "vision-model", envKey: "OPENAI_MODEL", defaultValue: "vision-model" },
+ { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", alias: "claude-sonnet-4-6", envKey: "OPENAI_MODEL", defaultValue: "claude-sonnet-4-6" },
+ { id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking", alias: "claude-opus-4-6-thinking", envKey: "OPENAI_MODEL", defaultValue: "claude-opus-4-6-thinking" },
+ { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High", alias: "gemini-3.1-pro-high", envKey: "OPENAI_MODEL", defaultValue: "gemini-3.1-pro-high" },
+ { id: "gemini-3-flash", name: "Gemini 3 Flash", alias: "gemini-3-flash", envKey: "OPENAI_MODEL", defaultValue: "gemini-3-flash" },
+ ],
+ guideSteps: [
+ { step: 1, title: "Install Qwen Code", desc: "npm install -g @qwen-code/qwen-code" },
+ { step: 2, title: "API Key", type: "apiKeySelector" },
+ { step: 3, title: "Base URL", value: "{{baseUrl}}", copyable: true },
+ { step: 4, title: "Select Model", type: "modelSelector" },
+ { step: 5, title: "Save Config", desc: "Copy the JSON below to your ~/.qwen/settings.json file." },
+ ],
+ codeBlock: {
+ language: "json",
+ code: `{
+ "security": {
+ "auth": {
+ "selectedType": "openai",
+ "apiKey": "{{apiKey}}",
+ "baseUrl": "{{baseUrl}}"
+ }
+ },
+ "model": {
+ "name": "{{model}}"
+ }
+}`,
+ },
+ },
+ "deepseek-tui": {
+ id: "deepseek-tui",
+ name: "DeepSeek TUI",
+ image: "/providers/deepseek-tui.png",
+ color: "#4D6BFE",
+ description: "DeepSeek Terminal Coding Agent (Rust TUI)",
+ docsUrl: "https://github.com/DeepSeek-TUI/DeepSeek-TUI",
+ configType: "custom",
+ defaultCommand: "deepseek",
+ modelAliases: ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-chat", "deepseek-reasoner"],
+ defaultModels: [
+ { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", alias: "deepseek-v4-pro" },
+ { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", alias: "deepseek-v4-flash" },
+ { id: "deepseek-chat", name: "DeepSeek V3 Chat", alias: "deepseek-chat" },
+ ],
+ notes: [
+ { type: "info", text: "DeepSeek TUI uses ~/.deepseek/config.toml for configuration. 9Router will update the provider to 'openai' mode with your base_url, api_key, and model." },
+ { type: "warning", text: "Config path: Linux/macOS ~/.deepseek/config.toml • Windows %USERPROFILE%\\.deepseek\\config.toml" },
+ ],
+ },
+ jcode: {
+ id: "jcode",
+ name: "jcode",
+ image: "/providers/jcode.png",
+ color: "#FF6B35",
+ description: "High-performance Rust-based coding agent harness",
+ configType: "custom",
+ docsUrl: "https://github.com/1jehuang/jcode",
+ notes: [
+ {
+ type: "info",
+ text: "jcode is a Rust-based coding agent with semantic memory, multi-agent swarms, and extreme performance (27.8 MB RAM, 14ms boot)."
+ },
+ {
+ type: "info",
+ text: "Configure 9router as an OpenAI-compatible provider to route all jcode requests through 9router's optimization layer."
+ },
+ {
+ type: "warning",
+ text: "Requires jcode installed. Install via: curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash"
+ },
+ ],
+ defaultModels: [
+ { id: "claude-opus-4-7", name: "Claude Opus 4.7", alias: "opus", defaultValue: "cc/claude-opus-4-7" },
+ { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", alias: "sonnet", defaultValue: "cc/claude-sonnet-4-6" },
+ { id: "gpt-5.5", name: "GPT 5.5", alias: "gpt5", defaultValue: "cx/gpt-5.5" },
+ { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", alias: "gemini", defaultValue: "gemini/gemini-3.1-pro" },
+ ],
+ },
+ // HIDDEN: gemini-cli
+ // "gemini-cli": {
+ // id: "gemini-cli",
+ // name: "Gemini CLI",
+ // icon: "terminal",
+ // color: "#4285F4",
+ // description: "Google Gemini CLI",
+ // configType: "env",
+ // envVars: {
+ // baseUrl: "GEMINI_API_BASE_URL",
+ // model: "GEMINI_MODEL",
+ // },
+ // defaultModels: [
+ // { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", alias: "pro" },
+ // { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", alias: "flash" },
+ // ],
+ // },
+};
+
+// Get all provider models for mapping dropdown
+export const getProviderModelsForMapping = (providers) => {
+ const result = [];
+ providers.forEach(conn => {
+ if (conn.isActive && (conn.testStatus === "active" || conn.testStatus === "success")) {
+ result.push({
+ connectionId: conn.id,
+ provider: conn.provider,
+ name: conn.name,
+ models: conn.models || [],
+ });
+ }
+ });
+ return result;
+};
+
diff --git a/src/shared/constants/colors.js b/src/shared/constants/colors.js
new file mode 100644
index 0000000000000000000000000000000000000000..873f3e519e02e97eb2d30fe54fc5b8ac3f79d180
--- /dev/null
+++ b/src/shared/constants/colors.js
@@ -0,0 +1,77 @@
+// Claude-inspired color palette for Endpoint Proxy
+// Light theme: Warm beige/cream tones
+// Dark theme: Deep charcoal/brown tones
+
+export const COLORS = {
+ // Primary - Warm Coral/Terracotta (Claude-like)
+ primary: {
+ DEFAULT: "#D97757",
+ hover: "#C56243",
+ light: "#E8A58C",
+ dark: "#B0664D",
+ },
+
+ // Light theme backgrounds
+ light: {
+ bg: "#FBF9F6",
+ bgAlt: "#F5F1ED",
+ surface: "#FFFFFF",
+ sidebar: "rgba(246, 246, 246, 0.8)",
+ border: "rgba(0, 0, 0, 0.1)",
+ textMain: "#383733",
+ textMuted: "#75736E",
+ },
+
+ // Dark theme backgrounds
+ dark: {
+ bg: "#191918",
+ bgAlt: "#1F1F1E",
+ surface: "#242423",
+ sidebar: "rgba(30, 30, 30, 0.8)",
+ border: "rgba(255, 255, 255, 0.1)",
+ textMain: "#ECEBE8",
+ textMuted: "#9E9D99",
+ },
+
+ // Status colors
+ status: {
+ success: "#22C55E",
+ successLight: "#DCFCE7",
+ successDark: "#166534",
+ warning: "#F59E0B",
+ warningLight: "#FEF3C7",
+ warningDark: "#92400E",
+ error: "#EF4444",
+ errorLight: "#FEE2E2",
+ errorDark: "#991B1B",
+ info: "#3B82F6",
+ infoLight: "#DBEAFE",
+ infoDark: "#1E40AF",
+ },
+};
+
+// CSS Variables mapping for Tailwind
+export const CSS_VARIABLES = {
+ light: {
+ "--color-primary": COLORS.primary.DEFAULT,
+ "--color-primary-hover": COLORS.primary.hover,
+ "--color-bg": COLORS.light.bg,
+ "--color-bg-alt": COLORS.light.bgAlt,
+ "--color-surface": COLORS.light.surface,
+ "--color-sidebar": COLORS.light.sidebar,
+ "--color-border": COLORS.light.border,
+ "--color-text-main": COLORS.light.textMain,
+ "--color-text-muted": COLORS.light.textMuted,
+ },
+ dark: {
+ "--color-primary": COLORS.primary.DEFAULT,
+ "--color-primary-hover": COLORS.primary.hover,
+ "--color-bg": COLORS.dark.bg,
+ "--color-bg-alt": COLORS.dark.bgAlt,
+ "--color-surface": COLORS.dark.surface,
+ "--color-sidebar": COLORS.dark.sidebar,
+ "--color-border": COLORS.dark.border,
+ "--color-text-main": COLORS.dark.textMain,
+ "--color-text-muted": COLORS.dark.textMuted,
+ },
+};
diff --git a/src/shared/constants/config.js b/src/shared/constants/config.js
new file mode 100644
index 0000000000000000000000000000000000000000..ebf5d1205cb6a52c2d5a02b6fa955a9401dad3d3
--- /dev/null
+++ b/src/shared/constants/config.js
@@ -0,0 +1,91 @@
+import pkg from "../../../package.json" with { type: "json" };
+
+// App configuration
+export const APP_CONFIG = {
+ name: "9Router Proxy",
+ description: "AI Infrastructure Management",
+ version: pkg.version,
+};
+
+// GitHub configuration
+export const GITHUB_CONFIG = {
+ changelogUrl: "https://raw.githubusercontent.com/decolua/9router/refs/heads/master/CHANGELOG.md",
+ donateUrl: "https://9router.com/api/donate",
+};
+
+// Updater configuration
+export const UPDATER_CONFIG = {
+ npmPackageName: "9router",
+ installCmd: "npm i -g 9router",
+ installCmdLatest: "npm i -g 9router@latest --prefer-online",
+ shutdownCountdownSec: 3,
+ exitDelayMs: 500,
+ statusPort: 20129,
+ statusPollIntervalMs: 1000,
+ statusLogTailLines: 8,
+ installRetries: 3,
+ installRetryDelayMs: 5000,
+ lingerAfterDoneMs: 30000,
+ waitForExitMinMs: 5000,
+ waitForExitMaxMs: 20000,
+ waitForExitCheckMs: 500,
+ appPort: 20128,
+};
+
+// Theme configuration
+export const THEME_CONFIG = {
+ storageKey: "theme",
+ defaultTheme: "system", // "light" | "dark" | "system"
+};
+
+// Subscription
+export const SUBSCRIPTION_CONFIG = {
+ price: 1.0,
+ currency: "USD",
+ interval: "month",
+ planName: "Pro Plan",
+};
+
+// API endpoints
+export const API_ENDPOINTS = {
+ users: "/api/users",
+ providers: "/api/providers",
+ payments: "/api/payments",
+ auth: "/api/auth",
+};
+
+export const CONSOLE_LOG_CONFIG = {
+ maxLines: 200,
+ pollIntervalMs: 1000,
+};
+
+// Client-side store TTL: how long fetched data stays fresh before re-fetching
+export const CLIENT_STORE_TTL_MS = 60000;
+
+// Claude auto-ping: keep 5h window warm by sending a tiny request right after reset
+export const CLAUDE_AUTOPING_CONFIG = {
+ settingsKey: "claudeAutoPing", // settings table field
+ tickIntervalMs: 60000, // scheduler tick
+ pingLeadMs: 5000, // fire once reset passes (within tolerance)
+ pingModel: "claude-haiku-4-5-20251001", // cheapest model
+ pingText: "hi",
+ pingMaxTokens: 1,
+ refreshAheadMs: 300000, // refetch usage when within 5min of reset
+ fiveHourKey: "session (5h)", // quota key returned by usage handler
+};
+
+// Re-export from providers.js for backward compatibility
+export {
+ FREE_PROVIDERS,
+ OAUTH_PROVIDERS,
+ APIKEY_PROVIDERS,
+ WEB_COOKIE_PROVIDERS,
+ AI_PROVIDERS,
+ AUTH_METHODS,
+} from "./providers.js";
+
+// Re-export from models.js for backward compatibility
+export {
+ PROVIDER_MODELS,
+ AI_MODELS,
+} from "./models.js";
diff --git a/src/shared/constants/coworkPlugins.js b/src/shared/constants/coworkPlugins.js
new file mode 100644
index 0000000000000000000000000000000000000000..b32f12080d72c79a3aaf4067d6604a49f66396b2
--- /dev/null
+++ b/src/shared/constants/coworkPlugins.js
@@ -0,0 +1,72 @@
+// Default remote plugins for Claude Cowork (3p managedMcpServers, HTTPS only).
+const DEFAULT_PLUGINS = [
+ {
+ name: "exa",
+ title: "Exa",
+ description: "Real-time web search and code documentation",
+ url: "https://mcp.exa.ai/mcp",
+ transport: "http",
+ oauth: false,
+ toolNames: ["web_search_exa", "web_fetch_exa"],
+ },
+ {
+ name: "tavily",
+ title: "Tavily",
+ description: "Real-time web search optimized for LLM agents",
+ url: "https://mcp.tavily.com/mcp",
+ transport: "http",
+ oauth: true,
+ toolNames: ["tavily_search", "tavily_extract", "tavily_crawl", "tavily_map"],
+ },
+];
+
+// Local stdio plugins bridged via inline SSE endpoint on the app's port.
+const LOCAL_STDIO_PLUGINS = [
+ {
+ name: "browsermcp",
+ title: "Browser MCP",
+ description: "Control your running Chrome (requires Chrome extension)",
+ extensionUrl: "https://chromewebstore.google.com/detail/browser-mcp-automate-your/bjfgambnhccakkhmkepdoekmckoijdlc",
+ command: "npx",
+ args: ["-y", "@browsermcp/mcp@latest"],
+ toolNames: ["browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_screenshot", "browser_get_console_logs", "browser_wait", "browser_press_key", "browser_go_back", "browser_go_forward"],
+ },
+];
+
+function buildManagedMcpServers(plugins) {
+ const list = Array.isArray(plugins) ? plugins : [];
+ const out = [];
+ const seen = new Set();
+ for (const p of list) {
+ if (!p?.name || !p?.url || seen.has(p.name)) continue;
+ seen.add(p.name);
+ const entry = {
+ name: p.name,
+ url: p.url,
+ transport: p.transport || (/\/sse(\b|\/)/i.test(p.url) ? "sse" : "http"),
+ };
+ if (p.oauth) entry.oauth = true;
+ if (Array.isArray(p.toolNames) && p.toolNames.length > 0) {
+ // Strip any pre-existing "{name}-" prefixes (idempotent across re-applies),
+ // then emit both bare + single-prefixed variants to match runtime tool naming.
+ const prefix = `${p.name}-`;
+ const bare = new Set();
+ for (const raw of p.toolNames) {
+ if (typeof raw !== "string" || !raw) continue;
+ let t = raw;
+ while (t.startsWith(prefix)) t = t.slice(prefix.length);
+ bare.add(t);
+ }
+ const policy = {};
+ for (const t of bare) {
+ policy[t] = "allow";
+ policy[`${prefix}${t}`] = "allow";
+ }
+ entry.toolPolicy = policy;
+ }
+ out.push(entry);
+ }
+ return out;
+}
+
+module.exports = { DEFAULT_PLUGINS, LOCAL_STDIO_PLUGINS, buildManagedMcpServers };
diff --git a/src/shared/constants/index.js b/src/shared/constants/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..820029c5f3999940eaf9ae8f02bf98660531a278
--- /dev/null
+++ b/src/shared/constants/index.js
@@ -0,0 +1,4 @@
+// Shared Constants - Export all
+export * from "./colors";
+export * from "./config";
+
diff --git a/src/shared/constants/locales.js b/src/shared/constants/locales.js
new file mode 100644
index 0000000000000000000000000000000000000000..63f7fa9780a2805f1869f70afbcf43e3292c2ce0
--- /dev/null
+++ b/src/shared/constants/locales.js
@@ -0,0 +1,36 @@
+// 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": "🇳🇴",
+};
diff --git a/src/shared/constants/mitmToolHosts.js b/src/shared/constants/mitmToolHosts.js
new file mode 100644
index 0000000000000000000000000000000000000000..b1decb4ae9b7448e7217afa6a000ab6d5e3540ed
--- /dev/null
+++ b/src/shared/constants/mitmToolHosts.js
@@ -0,0 +1,12 @@
+/**
+ * Per-tool DNS hosts — written to hosts file as 127.0.0.1 when MITM DNS is enabled.
+ * Kept in sync with MITM routing; shared by Node (dnsConfig) and dashboard UI.
+ */
+const TOOL_HOSTS = {
+ antigravity: ["daily-cloudcode-pa.googleapis.com", "cloudcode-pa.googleapis.com"],
+ copilot: ["api.individual.githubcopilot.com"],
+ kiro: ["runtime.us-east-1.kiro.dev", "q.us-east-1.amazonaws.com", "codewhisperer.us-east-1.amazonaws.com"],
+ cursor: ["api2.cursor.sh"],
+};
+
+module.exports = { TOOL_HOSTS };
diff --git a/src/shared/constants/models.js b/src/shared/constants/models.js
new file mode 100644
index 0000000000000000000000000000000000000000..297daae79229e0f309bf6a483890139beb62284f
--- /dev/null
+++ b/src/shared/constants/models.js
@@ -0,0 +1,47 @@
+// Import directly from file to avoid pulling in server-side dependencies via index.js
+export {
+ PROVIDER_MODELS,
+ getProviderModels,
+ getDefaultModel,
+ isValidModel as isValidModelCore,
+ findModelName,
+ getModelTargetFormat,
+ getModelStrip,
+ PROVIDER_ID_TO_ALIAS,
+ getModelsByProviderId,
+ getModelUpstreamId,
+ getModelQuotaFamily
+} from "open-sse/config/providerModels.js";
+
+import { AI_PROVIDERS, isOpenAICompatibleProvider } from "./providers.js";
+import { PROVIDER_MODELS as MODELS } from "open-sse/config/providerModels.js";
+
+// Providers that accept any model (passthrough)
+const PASSTHROUGH_PROVIDERS = new Set(
+ Object.entries(AI_PROVIDERS)
+ .filter(([, p]) => p.passthroughModels)
+ .map(([key]) => key)
+);
+
+// Wrap isValidModel with passthrough providers
+export function isValidModel(aliasOrId, modelId) {
+ if (isOpenAICompatibleProvider(aliasOrId)) return true;
+ if (PASSTHROUGH_PROVIDERS.has(aliasOrId)) return true;
+ const models = MODELS[aliasOrId];
+ if (!models) return false;
+ return models.some(m => m.id === modelId);
+}
+
+// Legacy AI_MODELS for backward compatibility
+export const AI_MODELS = Object.entries(MODELS).flatMap(([alias, models]) =>
+ models.map(m => ({ provider: alias, model: m.id, name: m.name }))
+);
+
+export const getModelKind = (m, fallback = null) => m?.kind || m?.type || fallback;
+
+// Capacity metadata for UI badges — icon + label + color per capability.
+export const CAPACITY_META = {
+ vision: { icon: "visibility", label: "Vision", desc: "Supports image input", color: "text-blue-500" },
+ // search: temporarily hidden (feature not wired yet)
+ reasoning: { icon: "neurology", label: "Reasoning", desc: "Supports reasoning / thinking", color: "text-amber-500" },
+};
diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js
new file mode 100644
index 0000000000000000000000000000000000000000..34356cb78ba978d84f8e17b0feaa4e789275eeed
--- /dev/null
+++ b/src/shared/constants/providers.js
@@ -0,0 +1,165 @@
+// Provider definitions
+import REGISTRY from "open-sse/providers/registry/index.js";
+import { RISK_NOTICE } from "@/shared/constants/providersDisplay";
+
+const MEDIA_ENTRY_KEYS = [
+ "serviceKinds", "ttsConfig", "sttConfig", "embeddingConfig",
+ "imageConfig", "imageToTextConfig", "videoConfig", "musicConfig",
+ "searchViaChat", "searchConfig", "fetchConfig",
+ "modelsFetcher", "mediaPriority", "hiddenKinds",
+];
+
+// Build provider UI object from registry entry
+function buildProviderEntry(r) {
+ const mediaFields = {};
+ if (r.media) Object.assign(mediaFields, r.media);
+ for (const k of MEDIA_ENTRY_KEYS) {
+ if (r[k] !== undefined) mediaFields[k] = r[k];
+ }
+ const display = { ...(r.display || {}) };
+ if (display.deprecationNotice === "RISK_NOTICE") display.deprecationNotice = RISK_NOTICE;
+ return {
+ ...display,
+ id: r.id,
+ alias: r.uiAlias || r.alias,
+ ...(r.hidden ? { hidden: true } : {}),
+ ...mediaFields,
+ ...(r.priority !== undefined ? { priority: r.priority } : {}),
+ ...(r.hasFree ? { hasFree: true } : {}),
+ ...(r.thinkingConfig ? { thinkingConfig: r.thinkingConfig } : {}),
+ ...(r.regions ? { regions: r.regions, defaultRegion: r.defaultRegion } : {}),
+ ...(r.hasProviderSpecificData ? { hasProviderSpecificData: true } : {}),
+ ...(r.noAuth ? { noAuth: true } : {}),
+ ...(r.passthroughModels ? { passthroughModels: true } : {}),
+ ...(r.hasOAuth ? { hasOAuth: true } : {}),
+ ...(r.authModes ? { authModes: r.authModes } : {}),
+ ...(r.authType ? { authType: r.authType } : {}),
+ ...(r.authHint ? { authHint: r.authHint } : {}),
+ };
+}
+
+const byCategory = (cat) => Object.fromEntries(
+ REGISTRY.filter(r => r.category === cat).map(r => [r.id, buildProviderEntry(r)])
+);
+
+export const FREE_PROVIDERS = byCategory("free");
+export const FREE_TIER_PROVIDERS = byCategory("freeTier");
+
+// Thinking config definitions
+// options: list of selectable modes ("auto" = no override from server)
+// defaultMode: fallback when user hasn't configured
+// extended: claude-style thinking (thinking.type + budget_tokens) — used by most providers
+// effort: openai-style reasoning_effort — only openai + codex
+export const THINKING_CONFIG = {
+ extended: {
+ options: ["auto", "on", "off"],
+ defaultMode: "auto",
+ defaultBudgetTokens: 10000
+ },
+ effort: {
+ options: ["auto", "none", "low", "medium", "high"],
+ defaultMode: "auto"
+ }
+};
+
+export const OAUTH_PROVIDERS = byCategory("oauth");
+export const APIKEY_PROVIDERS = byCategory("apikey");
+
+// Web Cookie Providers (use browser session cookie instead of API key)
+export const WEB_COOKIE_PROVIDERS = byCategory("webCookie");
+
+// Media provider kinds — each kind maps to a route and endpoint config
+export const MEDIA_PROVIDER_KINDS = [
+ { id: "embedding", label: "Embedding", icon: "data_array", endpoint: { method: "POST", path: "/v1/embeddings" } },
+ { id: "image", label: "Text to Image", icon: "brush", endpoint: { method: "POST", path: "/v1/images/generations" } },
+ { id: "imageToText", label: "Image to Text", icon: "image_search", endpoint: { method: "POST", path: "/v1/images/understanding" } },
+ { id: "tts", label: "Text To Speech", icon: "record_voice_over", endpoint: { method: "POST", path: "/v1/audio/speech" } },
+ { id: "stt", label: "Speech To Text", icon: "mic", endpoint: { method: "POST", path: "/v1/audio/transcriptions" } },
+ { id: "webSearch", label: "Web Search", icon: "travel_explore", endpoint: { method: "POST", path: "/v1/search" } },
+ { id: "webFetch", label: "Web Fetch", icon: "language", endpoint: { method: "POST", path: "/v1/web/fetch" } },
+ { id: "video", label: "Video", icon: "movie", endpoint: { method: "POST", path: "/v1/video/generations" } },
+ { id: "music", label: "Music", icon: "music_note", endpoint: { method: "POST", path: "/v1/audio/music" } },
+];
+
+export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
+export const ANTHROPIC_COMPATIBLE_PREFIX = "anthropic-compatible-";
+export const CUSTOM_EMBEDDING_PREFIX = "custom-embedding-";
+
+export function isOpenAICompatibleProvider(providerId) {
+ return typeof providerId === "string" && providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
+}
+
+export function isAnthropicCompatibleProvider(providerId) {
+ return typeof providerId === "string" && providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
+}
+
+export function isCustomEmbeddingProvider(providerId) {
+ return typeof providerId === "string" && providerId.startsWith(CUSTOM_EMBEDDING_PREFIX);
+}
+
+// All providers (combined)
+export const AI_PROVIDERS = { ...FREE_PROVIDERS, ...FREE_TIER_PROVIDERS, ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS, ...WEB_COOKIE_PROVIDERS };
+
+// Auth methods
+export const AUTH_METHODS = {
+ oauth: { id: "oauth" },
+ apikey: { id: "apikey" },
+ cookie: { id: "cookie" },
+};
+
+// Helper: Get provider by alias
+export function getProviderByAlias(alias) {
+ for (const provider of Object.values(AI_PROVIDERS)) {
+ if (provider.alias === alias || provider.id === alias) {
+ return provider;
+ }
+ }
+ return null;
+}
+
+// Helper: Get provider ID from alias
+export function resolveProviderId(aliasOrId) {
+ const provider = getProviderByAlias(aliasOrId);
+ return provider?.id || aliasOrId;
+}
+
+// Helper: Get alias from provider ID
+export function getProviderAlias(providerId) {
+ const provider = AI_PROVIDERS[providerId];
+ return provider?.alias || providerId;
+}
+
+// Alias to ID mapping (for quick lookup)
+export const ALIAS_TO_ID = Object.values(AI_PROVIDERS).reduce((acc, p) => {
+ acc[p.alias] = p.id;
+ return acc;
+}, {});
+
+// ID to Alias mapping
+export const ID_TO_ALIAS = Object.values(AI_PROVIDERS).reduce((acc, p) => {
+ acc[p.id] = p.alias;
+ return acc;
+}, {});
+
+// Helper: Get providers by service kind (e.g. "tts", "embedding", "image")
+// Providers without serviceKinds default to ["llm"]
+export function getProvidersByKind(kind) {
+ return Object.values(AI_PROVIDERS)
+ .filter((p) => {
+ const kinds = p.serviceKinds ?? ["llm"];
+ if (!kinds.includes(kind)) return false;
+ if (p.hidden) return false;
+ if (p.hiddenKinds?.includes(kind)) return false;
+ return true;
+ })
+ .sort((a, b) => (a.priority ?? a.mediaPriority ?? 999) - (b.priority ?? b.mediaPriority ?? 999));
+}
+
+// Derive từ registry features flags
+export const USAGE_SUPPORTED_PROVIDERS = REGISTRY
+ .filter(r => r.features?.usage)
+ .map(r => r.id);
+
+export const USAGE_APIKEY_PROVIDERS = REGISTRY
+ .filter(r => r.features?.usageApikey)
+ .map(r => r.id);
diff --git a/src/shared/constants/providersDisplay.js b/src/shared/constants/providersDisplay.js
new file mode 100644
index 0000000000000000000000000000000000000000..cd1d54a3be0fdccc9914318bb78ef39acd1b3aa8
--- /dev/null
+++ b/src/shared/constants/providersDisplay.js
@@ -0,0 +1,12 @@
+// UI display config — all providers derive from registry.display.
+import REGISTRY from "open-sse/providers/registry/index.js";
+
+export const RISK_NOTICE = "⚠️ 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.";
+
+// Resolve "RISK_NOTICE" token → real notice text (registry stores token to avoid import cycle)
+const resolveDisplay = (d) =>
+ d.deprecationNotice === "RISK_NOTICE" ? { ...d, deprecationNotice: RISK_NOTICE } : d;
+
+export const PROVIDER_DISPLAY = Object.fromEntries(
+ REGISTRY.filter((r) => r.display).map((r) => [r.id, resolveDisplay(r.display)]),
+);
diff --git a/src/shared/constants/skills.js b/src/shared/constants/skills.js
new file mode 100644
index 0000000000000000000000000000000000000000..1f758e32394feb6b779847e9c8b5def5488e310d
--- /dev/null
+++ b/src/shared/constants/skills.js
@@ -0,0 +1,78 @@
+// Agent Skills metadata — single source of truth for /dashboard/skills page.
+// Each skill = 1 raw GitHub URL the user copies and pastes to any AI agent.
+
+const REPO = "decolua/9router";
+const BRANCH = "master";
+const SKILL_PATH = "skills";
+
+export const SKILLS_REPO_URL = `https://github.com/${REPO}`;
+export const SKILLS_RAW_BASE = `https://raw.githubusercontent.com/${REPO}/refs/heads/${BRANCH}/${SKILL_PATH}`;
+export const SKILLS_BLOB_BASE = `https://github.com/${REPO}/blob/${BRANCH}/${SKILL_PATH}`;
+
+export const SKILLS = [
+ {
+ id: "9router",
+ name: "9Router (Entry)",
+ description: "Setup + index of all capabilities. Start here — covers base URL, auth, model discovery, and links to every capability skill.",
+ endpoint: null,
+ icon: "hub",
+ isEntry: true,
+ },
+ {
+ id: "9router-chat",
+ name: "Chat",
+ description: "Chat / code-gen via OpenAI or Anthropic format with streaming.",
+ endpoint: "/v1/chat/completions",
+ icon: "chat",
+ },
+ {
+ id: "9router-image",
+ name: "Image Generation",
+ description: "Text-to-image via DALL-E, Imagen, FLUX, MiniMax, SDWebUI…",
+ endpoint: "/v1/images/generations",
+ icon: "image",
+ },
+ {
+ id: "9router-tts",
+ name: "Text-to-Speech",
+ description: "OpenAI / ElevenLabs / Edge / Google / Deepgram voices.",
+ endpoint: "/v1/audio/speech",
+ icon: "record_voice_over",
+ },
+ {
+ id: "9router-stt",
+ name: "Speech-to-Text",
+ description: "Transcribe audio via OpenAI Whisper, Groq, Gemini, Deepgram, AssemblyAI…",
+ endpoint: "/v1/audio/transcriptions",
+ icon: "mic",
+ },
+ {
+ id: "9router-embeddings",
+ name: "Embeddings",
+ description: "Vectors for RAG / semantic search via OpenAI, Gemini, Mistral…",
+ endpoint: "/v1/embeddings",
+ icon: "scatter_plot",
+ },
+ {
+ id: "9router-web-search",
+ name: "Web Search",
+ description: "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.",
+ endpoint: "/v1/search",
+ icon: "search",
+ },
+ {
+ id: "9router-web-fetch",
+ name: "Web Fetch",
+ description: "URL → markdown / text / HTML via Firecrawl, Jina, Tavily, Exa.",
+ endpoint: "/v1/web/fetch",
+ icon: "language",
+ },
+];
+
+export function getSkillRawUrl(id) {
+ return `${SKILLS_RAW_BASE}/${id}/SKILL.md`;
+}
+
+export function getSkillBlobUrl(id) {
+ return `${SKILLS_BLOB_BASE}/${id}/SKILL.md`;
+}
diff --git a/src/shared/constants/ttsProviders.js b/src/shared/constants/ttsProviders.js
new file mode 100644
index 0000000000000000000000000000000000000000..f1b990d35a2b8b1fb0be47a620b7f4f57ad496c5
--- /dev/null
+++ b/src/shared/constants/ttsProviders.js
@@ -0,0 +1,138 @@
+/**
+ * TTS Provider Configuration
+ * Centralized config for TTS provider UI behavior
+ */
+export const TTS_PROVIDER_CONFIG = {
+ "google-tts": {
+ hasLanguageDropdown: false,
+ hasModelSelector: false,
+ hasBrowseButton: true,
+ voiceSource: "hardcoded", // languages built from providerModels at runtime
+ },
+ "openai": {
+ hasLanguageDropdown: false,
+ hasModelSelector: true,
+ hasBrowseButton: false,
+ voiceSource: "hardcoded",
+ modelKey: "openai-tts-models",
+ voiceKey: "openai-tts-voices",
+ voicesPerModel: true,
+ },
+ "openrouter": {
+ hasLanguageDropdown: false,
+ hasModelSelector: true,
+ hasBrowseButton: false,
+ voiceSource: "hardcoded",
+ modelKey: "openrouter-tts-models",
+ voiceKey: "openrouter-tts-voices",
+ voicesPerModel: true,
+ },
+ "elevenlabs": {
+ hasLanguageDropdown: false,
+ hasModelSelector: true,
+ hasBrowseButton: true,
+ hasVoiceIdInput: true, // allow manual voice id entry
+ voiceSource: "api-language", // grouped by language from backend
+ modelKey: "elevenlabs-tts-models",
+ apiEndpoint: "/api/media-providers/tts/elevenlabs/voices",
+ },
+ "edge-tts": {
+ hasLanguageDropdown: false,
+ hasModelSelector: false,
+ hasBrowseButton: true,
+ voiceSource: "api-language", // from API with language picker
+ },
+ "local-device": {
+ hasLanguageDropdown: false,
+ hasModelSelector: false,
+ hasBrowseButton: true,
+ voiceSource: "api-language", // from API with language picker
+ },
+ // ── Config-driven providers (load models from providers.js → ttsConfig.models) ──
+ "nvidia": {
+ hasModelSelector: true,
+ hasBrowseButton: false,
+ hasVoiceIdInput: true,
+ voiceSource: "config",
+ },
+ "hyperbolic": {
+ hasModelSelector: true,
+ hasBrowseButton: false,
+ voiceSource: "config",
+ },
+ "deepgram": {
+ hasModelSelector: false,
+ hasBrowseButton: true,
+ voiceSource: "api-language",
+ apiEndpoint: "/api/media-providers/tts/deepgram/voices",
+ },
+ "huggingface": {
+ hasModelSelector: true,
+ hasBrowseButton: false,
+ voiceSource: "config",
+ },
+ "cartesia": {
+ hasModelSelector: true,
+ hasBrowseButton: false,
+ hasVoiceIdInput: true,
+ voiceSource: "config",
+ },
+ "playht": {
+ hasModelSelector: true,
+ hasBrowseButton: false,
+ hasVoiceIdInput: true,
+ voiceSource: "config",
+ },
+ "coqui": {
+ hasModelSelector: true,
+ hasBrowseButton: false,
+ hasVoiceIdInput: true,
+ voiceSource: "config",
+ },
+ "tortoise": {
+ hasModelSelector: true,
+ hasBrowseButton: false,
+ hasVoiceIdInput: true,
+ voiceSource: "config",
+ },
+ "inworld": {
+ hasModelSelector: true,
+ hasBrowseButton: true,
+ hasVoiceIdInput: true,
+ voiceSource: "api-language",
+ modelKey: "inworld-tts-models",
+ apiEndpoint: "/api/media-providers/tts/inworld/voices",
+ },
+ "qwen": {
+ hasModelSelector: true,
+ hasBrowseButton: false,
+ hasVoiceIdInput: true,
+ voiceSource: "config",
+ },
+ "minimax": {
+ hasModelSelector: true,
+ hasBrowseButton: true,
+ hasVoiceIdInput: true,
+ voiceSource: "api-language",
+ apiEndpoint: "/api/media-providers/tts/minimax/voices",
+ defaultVoiceId: "English_expressive_narrator",
+ },
+ "minimax-cn": {
+ hasModelSelector: true,
+ hasBrowseButton: true,
+ hasVoiceIdInput: true,
+ voiceSource: "api-language",
+ apiEndpoint: "/api/media-providers/tts/minimax/voices?provider=minimax-cn",
+ defaultVoiceId: "English_expressive_narrator",
+ },
+ "gemini": {
+ hasLanguageDropdown: false,
+ hasLanguageHint: true, // sends body.language to guide TTS pronunciation
+ hasModelSelector: true,
+ hasBrowseButton: false,
+ voiceSource: "hardcoded",
+ modelKey: "gemini-tts-models",
+ voiceKey: "gemini-tts-voices",
+ voicesPerModel: true,
+ },
+};
diff --git a/src/shared/hooks/index.js b/src/shared/hooks/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..0e5f64bb24d212f759d1888c5df7cacbe1065230
--- /dev/null
+++ b/src/shared/hooks/index.js
@@ -0,0 +1,3 @@
+// Shared Hooks - Export all
+export { useTheme } from "./useTheme";
+export { useModelCaps } from "./useModelCaps";
diff --git a/src/shared/hooks/useCopyToClipboard.js b/src/shared/hooks/useCopyToClipboard.js
new file mode 100644
index 0000000000000000000000000000000000000000..fa5afed96905d46f6eb5f2a2bccd15288f5834b0
--- /dev/null
+++ b/src/shared/hooks/useCopyToClipboard.js
@@ -0,0 +1,43 @@
+"use client";
+
+import { useState, useCallback, useRef } from "react";
+
+/**
+ * Hook for copy to clipboard with feedback
+ * @param {number} resetDelay - Time in ms before resetting copied state (default: 2000)
+ * @returns {{ copied: string|null, copy: (text: string, id?: string) => void }}
+ */
+export function useCopyToClipboard(resetDelay = 2000) {
+ const [copied, setCopied] = useState(null);
+ const timeoutRef = useRef(null);
+
+ const copy = useCallback((text, id = "default") => {
+ const write = async () => {
+ if (navigator?.clipboard?.writeText) {
+ await navigator.clipboard.writeText(text);
+ } else {
+ const textarea = document.createElement("textarea");
+ textarea.value = text;
+ textarea.style.position = "fixed";
+ textarea.style.opacity = "0";
+ document.body.appendChild(textarea);
+ textarea.select();
+ document.execCommand("copy");
+ document.body.removeChild(textarea);
+ }
+ };
+ write();
+ setCopied(id);
+
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+
+ timeoutRef.current = setTimeout(() => {
+ setCopied(null);
+ }, resetDelay);
+ }, [resetDelay]);
+
+ return { copied, copy };
+}
+
diff --git a/src/shared/hooks/useModelCaps.js b/src/shared/hooks/useModelCaps.js
new file mode 100644
index 0000000000000000000000000000000000000000..3dbdcdd41a0231d0b35ec6d1956f64b7fbbdf5d8
--- /dev/null
+++ b/src/shared/hooks/useModelCaps.js
@@ -0,0 +1,44 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
+
+// Fetch model capabilities once and expose a lookup by fullModel ("provider/model") or bare model id.
+export function useModelCaps() {
+ const [byFull, setByFull] = useState({});
+ const [byId, setById] = useState({});
+
+ useEffect(() => {
+ let alive = true;
+ (async () => {
+ try {
+ const res = await fetch("/api/models");
+ if (!res.ok) return;
+ const data = await res.json();
+ const full = {};
+ const id = {};
+ for (const m of data.models || []) {
+ if (!m.caps) continue;
+ if (m.fullModel) full[m.fullModel] = m.caps;
+ if (m.model) id[m.model] = m.caps;
+ }
+ if (alive) { setByFull(full); setById(id); }
+ } catch { /* ignore */ }
+ })();
+ return () => { alive = false; };
+ }, []);
+
+ // Resolve caps from a "provider/model" string or a bare model id.
+ const getCaps = (key) => {
+ if (!key) return null;
+ if (byFull[key]) return byFull[key];
+ const bare = key.includes("/") ? key.slice(key.indexOf("/") + 1) : key;
+ if (byId[bare]) return byId[bare];
+ // Fallback: compute caps for dynamic models (passthrough/custom/suggested) not in static list
+ const provider = key.includes("/") ? key.slice(0, key.indexOf("/")) : null;
+ const c = getCapabilitiesForModel(provider, bare);
+ return { vision: c.vision, search: c.search, reasoning: c.reasoning };
+ };
+
+ return { getCaps };
+}
diff --git a/src/shared/hooks/useTheme.js b/src/shared/hooks/useTheme.js
new file mode 100644
index 0000000000000000000000000000000000000000..45dae99234a096283c1ab31bd622e9427a25c208
--- /dev/null
+++ b/src/shared/hooks/useTheme.js
@@ -0,0 +1,60 @@
+"use client";
+
+import { useEffect, useState, useSyncExternalStore } from "react";
+import useThemeStore from "@/store/themeStore";
+
+// Subscribe to system theme changes
+function subscribeToSystemTheme(callback) {
+ if (typeof window === "undefined") return () => {};
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
+ mediaQuery.addEventListener("change", callback);
+ return () => mediaQuery.removeEventListener("change", callback);
+}
+
+// Get current system theme preference
+function getSystemThemeSnapshot() {
+ if (typeof window === "undefined") return false;
+ return window.matchMedia("(prefers-color-scheme: dark)").matches;
+}
+
+// Server snapshot always returns false
+function getServerSnapshot() {
+ return false;
+}
+
+export function useTheme() {
+ const { theme, setTheme, toggleTheme, initTheme } = useThemeStore();
+
+ // Use useSyncExternalStore to safely subscribe to system theme
+ const systemPrefersDark = useSyncExternalStore(
+ subscribeToSystemTheme,
+ getSystemThemeSnapshot,
+ getServerSnapshot
+ );
+
+ useEffect(() => {
+ initTheme();
+ }, [initTheme]);
+
+ // Listen for system theme changes when theme is "system"
+ useEffect(() => {
+ if (theme !== "system") return;
+
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
+ const handleChange = () => initTheme();
+
+ mediaQuery.addEventListener("change", handleChange);
+ return () => mediaQuery.removeEventListener("change", handleChange);
+ }, [theme, initTheme]);
+
+ // Compute isDark from current state (no effect needed)
+ const isDark = theme === "dark" || (theme === "system" && systemPrefersDark);
+
+ return {
+ theme,
+ setTheme,
+ toggleTheme,
+ isDark,
+ };
+}
+
diff --git a/src/shared/services/bootstrap.js b/src/shared/services/bootstrap.js
new file mode 100644
index 0000000000000000000000000000000000000000..a4e03da9001bce9fe61ae5a4aae88714c32bac88
--- /dev/null
+++ b/src/shared/services/bootstrap.js
@@ -0,0 +1,12 @@
+import initializeApp from "./initializeApp.js";
+
+// Skip during Next.js build/prerender — bootstrap would download cloudflared, init DNS, etc.
+const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build"
+ || process.env.NEXT_PHASE === "phase-export"
+ || process.env.NEXT_PHASE === "phase-static";
+
+// Server-only singleton: guard via global so HMR / re-imports don't double-init
+if (typeof window === "undefined" && !isBuildPhase && !global.__appBootstrapped) {
+ global.__appBootstrapped = true;
+ initializeApp().catch((e) => console.error("[Bootstrap] init failed:", e.message));
+}
diff --git a/src/shared/services/claudeAutoPing.js b/src/shared/services/claudeAutoPing.js
new file mode 100644
index 0000000000000000000000000000000000000000..14abc12795e5a1bd396a263b0bd6d305cd54c824
--- /dev/null
+++ b/src/shared/services/claudeAutoPing.js
@@ -0,0 +1,117 @@
+// Claude auto-ping scheduler: warms the 5h window by sending a tiny request right after reset.
+import "open-sse/index.js";
+
+import { getSettings, getProviderConnections, updateProviderConnection } from "@/lib/localDb";
+import { getClaudeUsage } from "open-sse/services/usage/claude.js";
+import { CLAUDE_CLI_SPOOF_HEADERS } from "open-sse/providers/shared.js";
+import { proxyAwareFetch } from "open-sse/utils/proxyFetch.js";
+import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
+import { refreshAndUpdateCredentials } from "@/app/api/usage/[connectionId]/route.js";
+import { CLAUDE_AUTOPING_CONFIG } from "@/shared/constants/config";
+
+const C = CLAUDE_AUTOPING_CONFIG;
+const PING_URL = "https://api.anthropic.com/v1/messages?beta=true";
+
+const g = (global.__claudeAutoPing ??= { interval: null, running: false, resetCache: {} });
+
+function buildProxyOptions(cfg) {
+ return {
+ connectionProxyEnabled: cfg.connectionProxyEnabled === true,
+ connectionProxyUrl: cfg.connectionProxyUrl || "",
+ connectionNoProxy: cfg.connectionNoProxy || "",
+ vercelRelayUrl: cfg.vercelRelayUrl || "",
+ strictProxy: false,
+ };
+}
+
+// Send minimal "hi" to start a fresh 5h window
+async function sendPing(accessToken, proxyOptions) {
+ const res = await proxyAwareFetch(PING_URL, {
+ method: "POST",
+ headers: {
+ ...CLAUDE_CLI_SPOOF_HEADERS,
+ "Authorization": `Bearer ${accessToken}`,
+ "content-type": "application/json",
+ },
+ body: JSON.stringify({
+ model: C.pingModel,
+ max_tokens: C.pingMaxTokens,
+ messages: [{ role: "user", content: C.pingText }],
+ }),
+ }, proxyOptions);
+ return res.ok;
+}
+
+async function pingConnection(conn) {
+ // Cached resetAt is stable for the whole 5h window; skip usage poll until near reset
+ const cachedReset = g.resetCache[conn.id];
+ if (cachedReset && Date.now() < new Date(cachedReset).getTime() - C.refreshAheadMs) return;
+
+ const proxyCfg = await resolveConnectionProxyConfig(conn.providerSpecificData);
+ const proxyOptions = buildProxyOptions(proxyCfg);
+
+ // Refresh token if needed, then read 5h reset time
+ let connection = conn;
+ try {
+ const r = await refreshAndUpdateCredentials(connection, false, proxyOptions);
+ connection = r.connection;
+ } catch (e) {
+ console.warn(`[AutoPing] ${conn.id}: refresh failed: ${e.message}`);
+ return;
+ }
+
+ const usage = await getClaudeUsage(connection.accessToken, proxyOptions);
+ const resetAt = usage?.quotas?.[C.fiveHourKey]?.resetAt;
+ if (!resetAt) return;
+
+ // Cache resetAt to gate future ticks
+ g.resetCache[conn.id] = resetAt;
+
+ const resetMs = new Date(resetAt).getTime();
+ const now = Date.now();
+
+ // Only ping once per reset cycle, right after window flips
+ if (now < resetMs - C.pingLeadMs) return;
+ if (connection.lastPingedResetAt === resetAt) return;
+
+ const ok = await sendPing(connection.accessToken, proxyOptions);
+ await updateProviderConnection(connection.id, {
+ lastPingedResetAt: resetAt,
+ lastPingAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ });
+ console.log(`[AutoPing] ${connection.id}: ping ${ok ? "sent" : "failed"} (reset ${resetAt})`);
+}
+
+async function tick() {
+ if (g.running) return;
+ g.running = true;
+ try {
+ const settings = await getSettings();
+ const enabledMap = settings[C.settingsKey]?.connections || {};
+ if (Object.keys(enabledMap).length === 0) return;
+
+ const conns = await getProviderConnections({ provider: "claude", isActive: true });
+ // Only ping connections the user explicitly enabled
+ const targets = conns.filter((c) => c.authType === "oauth" && enabledMap[c.id] === true);
+ if (targets.length === 0) return;
+
+ for (const conn of targets) {
+ try {
+ await pingConnection(conn);
+ } catch (e) {
+ console.warn(`[AutoPing] ${conn.id}: ${e.message}`);
+ }
+ }
+ } catch (e) {
+ console.warn("[AutoPing] tick error:", e.message);
+ } finally {
+ g.running = false;
+ }
+}
+
+export function startClaudeAutoPing() {
+ if (g.interval) return;
+ g.interval = setInterval(() => { tick().catch(() => {}); }, C.tickIntervalMs);
+ if (g.interval.unref) g.interval.unref();
+}
diff --git a/src/shared/services/initializeApp.js b/src/shared/services/initializeApp.js
new file mode 100644
index 0000000000000000000000000000000000000000..234c90d681ee32f7f4f0c24380582fcbdb286b60
--- /dev/null
+++ b/src/shared/services/initializeApp.js
@@ -0,0 +1,282 @@
+import os from "os";
+import { fileURLToPath } from "url";
+import { dirname, join } from "path";
+import { existsSync } from "fs";
+import { cleanupProviderConnections, getSettings, updateSettings, getApiKeys } from "@/lib/localDb";
+import {
+ enableTunnel, enableTailscale,
+ isTunnelManuallyDisabled, isTunnelReconnecting, isTailscaleReconnecting,
+ getTunnelService, getTailscaleService, setTunnelUnexpectedExitCallback,
+ killCloudflared, isCloudflaredRunning, ensureCloudflared,
+ isTailscaleRunning, isTailscaleRunningStrict, isDaemonAlive, startFunnel,
+ checkInternet,
+ RESTART_COOLDOWN_MS, NETWORK_SETTLE_MS,
+ WATCHDOG_INTERVAL_MS, NETWORK_CHECK_INTERVAL_MS, VIRTUAL_IFACE_REGEX,
+} from "@/lib/tunnel";
+import { getMitmStatus, startMitm, loadEncryptedPassword, initDbHooks, restoreToolDNS, removeAllDNSEntriesSync } from "@/mitm/manager";
+import { startClaudeAutoPing } from "@/shared/services/claudeAutoPing";
+import { syncToJson as syncMitmAliasCache } from "@/lib/mitmAliasCache";
+
+// Inject correct paths and DB hooks into manager.js (CJS) from ESM context
+(function bootstrapMitm() {
+ if (!process.env.MITM_SERVER_PATH) {
+ try {
+ const thisFile = fileURLToPath(import.meta.url);
+ const appSrc = dirname(dirname(thisFile));
+ const candidate = join(appSrc, "mitm", "server.js");
+ if (existsSync(candidate)) process.env.MITM_SERVER_PATH = candidate;
+ } catch { /* ignore */ }
+ }
+ try { initDbHooks(getSettings, updateSettings); } catch { /* ignore */ }
+})();
+
+process.setMaxListeners(20);
+
+// Survive Next.js hot reload
+const g = global.__appSingleton ??= {
+ signalHandlersRegistered: false,
+ watchdogInterval: null,
+ networkMonitorInterval: null,
+ lastNetworkFingerprint: null,
+ lastWatchdogTick: Date.now(),
+ lastOnline: null,
+ mitmStartInProgress: false,
+ tunnelAutoResumed: false,
+ tailscaleAutoResumed: false,
+};
+
+export async function initializeApp() {
+ try {
+ await cleanupProviderConnections();
+ const settings = await getSettings();
+
+ // Auto-resume tunnel (once per process)
+ if (settings.tunnelEnabled && !g.tunnelAutoResumed) {
+ g.tunnelAutoResumed = true;
+ console.log("[InitApp] Tunnel was enabled, auto-resuming...");
+ safeRestartTunnel("startup").catch((e) => console.log("[InitApp] Tunnel resume failed:", e.message));
+ }
+
+ // Auto-resume tailscale (once per process)
+ if (settings.tailscaleEnabled && !g.tailscaleAutoResumed) {
+ g.tailscaleAutoResumed = true;
+ console.log("[InitApp] Tailscale was enabled, auto-resuming...");
+ safeRestartTailscale("startup").catch((e) => console.log("[InitApp] Tailscale resume failed:", e.message));
+ }
+
+ if (!g.signalHandlersRegistered) {
+ const cleanup = () => {
+ try { removeAllDNSEntriesSync(); } catch { /* best effort */ }
+ killCloudflared();
+ process.exit();
+ };
+ process.on("SIGINT", cleanup);
+ process.on("SIGTERM", cleanup);
+ process.on("exit", () => { try { removeAllDNSEntriesSync(); } catch { /* ignore */ } });
+ g.signalHandlersRegistered = true;
+ }
+
+ ensureCloudflared().catch(() => {});
+
+ // Sync mitmAlias DB → JSON cache so standalone MITM server can read it
+ syncMitmAliasCache().catch(() => {});
+
+ // Auto-respawn tunnel when cloudflared exits unexpectedly (e.g. network change drop)
+ setTunnelUnexpectedExitCallback(() => {
+ safeRestartTunnel("unexpected-exit").catch(() => {});
+ });
+
+ startWatchdog();
+ startNetworkMonitor();
+ autoStartMitm();
+ startClaudeAutoPing();
+ } catch (error) {
+ console.error("[InitApp] Error:", error);
+ }
+}
+
+async function autoStartMitm() {
+ if (g.mitmStartInProgress) return;
+ g.mitmStartInProgress = true;
+ try {
+ const settings = await getSettings();
+ if (!settings.mitmEnabled) return;
+ const mitmStatus = await getMitmStatus();
+ if (mitmStatus.running) return;
+
+ const password = await loadEncryptedPassword();
+ if (!password && process.platform !== "win32") {
+ console.log("[InitApp] MITM was enabled but no saved password found, skipping auto-start");
+ return;
+ }
+
+ const keys = await getApiKeys();
+ const activeKey = keys.find(k => k.isActive !== false);
+
+ console.log("[InitApp] MITM was enabled, auto-starting...");
+ await startMitm(activeKey?.key || "sk_9router", password);
+ console.log("[InitApp] MITM auto-started");
+ try {
+ await restoreToolDNS(password);
+ console.log("[InitApp] DNS restored from saved state");
+ } catch (e) {
+ console.log("[InitApp] DNS restore failed:", e.message);
+ }
+ } catch (err) {
+ console.log("[InitApp] MITM auto-start failed:", err.message);
+ } finally {
+ g.mitmStartInProgress = false;
+ }
+}
+
+// Cooldown only applies to repeating watchdog ticks (anti hammer-loop).
+// Network/exit events are one-shot transitions → bypass to recover fast.
+const FORCE_RESTART_REASONS = /^(startup|netchange|sleep|sleep\+netchange|online|unexpected-exit)$/;
+
+// ─── Safe restart (4 guards: spawn / cooldown / alive / internet) ────────────
+
+async function safeRestartTunnel(reason) {
+ const svc = getTunnelService();
+ const settings = await getSettings();
+ if (!settings.tunnelEnabled) return;
+ if (svc.cancelToken.cancelled) return;
+ if (svc.spawnInProgress) return;
+
+ const force = FORCE_RESTART_REASONS.test(reason);
+
+ // Process alive = trust cloudflared (self-reconnects via --retries 99, keeps same URL).
+ // Killing a live process on network change drops the tunnel and rotates the quick-tunnel URL.
+ if (isCloudflaredRunning()) return;
+
+ if (!force && Date.now() - svc.lastRestartAt < RESTART_COOLDOWN_MS) {
+ console.log(`[Tunnel] degraded but cooldown active, skip (${reason})`);
+ return;
+ }
+ if (!await checkInternet()) return;
+
+ console.log(`[Tunnel] safeRestart (${reason}) — tunnel unreachable${force ? " [force]" : ""}`);
+ try {
+ await enableTunnel();
+ svc.lastRestartAt = Date.now();
+ console.log("[Tunnel] restart success");
+ } catch (err) {
+ if (!/cloudflared killed|tunnel cancelled/.test(err.message)) {
+ console.log("[Tunnel] restart failed:", err.message);
+ }
+ }
+}
+
+async function safeRestartTailscale(reason) {
+ const svc = getTailscaleService();
+ const settings = await getSettings();
+ if (!settings.tailscaleEnabled) return;
+ if (svc.cancelToken.cancelled) return;
+ if (svc.spawnInProgress) return;
+
+ // Tailscale daemon is OS-level with built-in reconnect; trust it when running (even on netchange).
+ // Startup uses strict probe — cached state is cold after process/dev reload.
+ const running = reason === "startup" ? await isTailscaleRunningStrict() : isTailscaleRunning();
+ if (running) return;
+
+ // Daemon alive but funnel dropped → recover funnel only; never full-restart (preserves login/daemon).
+ if (isDaemonAlive() && svc.activeLocalPort) {
+ try {
+ await startFunnel(svc.activeLocalPort);
+ svc.lastRestartAt = Date.now();
+ console.log("[Tailscale] funnel re-established (daemon alive)");
+ } catch (err) {
+ console.log("[Tailscale] funnel recovery failed:", err.message);
+ }
+ return;
+ }
+
+ const force = FORCE_RESTART_REASONS.test(reason);
+ if (!force && Date.now() - svc.lastRestartAt < RESTART_COOLDOWN_MS) {
+ console.log(`[Tailscale] degraded but cooldown active, skip (${reason})`);
+ return;
+ }
+ if (!await checkInternet()) return;
+
+ console.log(`[Tailscale] safeRestart (${reason}) — daemon not running${force ? " [force]" : ""}`);
+ try {
+ await enableTailscale();
+ svc.lastRestartAt = Date.now();
+ console.log("[Tailscale] restart success");
+ } catch (err) {
+ console.log("[Tailscale] restart failed:", err.message);
+ }
+}
+
+// ─── Watchdog: 60s tick check both services ──────────────────────────────────
+
+function startWatchdog() {
+ if (g.watchdogInterval) return;
+ g.watchdogInterval = setInterval(() => {
+ safeRestartTunnel("watchdog").catch(() => {});
+ safeRestartTailscale("watchdog").catch(() => {});
+ }, WATCHDOG_INTERVAL_MS);
+ if (g.watchdogInterval.unref) g.watchdogInterval.unref();
+}
+
+// ─── Network monitor: detect IPv4 fingerprint change + sleep/wake ────────────
+
+function getNetworkFingerprint() {
+ const interfaces = os.networkInterfaces();
+ const active = [];
+ for (const [name, addrs] of Object.entries(interfaces)) {
+ if (!addrs) continue;
+ if (VIRTUAL_IFACE_REGEX.test(name)) continue;
+ for (const addr of addrs) {
+ if (!addr.internal && addr.family === "IPv4") {
+ active.push(`${name}:${addr.address}`);
+ }
+ }
+ }
+ return active.sort().join("|");
+}
+
+function startNetworkMonitor() {
+ if (g.networkMonitorInterval) return;
+
+ g.lastNetworkFingerprint = getNetworkFingerprint();
+ g.lastWatchdogTick = Date.now();
+ g.lastOnline = null;
+
+ g.networkMonitorInterval = setInterval(async () => {
+ try {
+ const now = Date.now();
+ const elapsed = now - g.lastWatchdogTick;
+ g.lastWatchdogTick = now;
+
+ const currentFingerprint = getNetworkFingerprint();
+ const networkChanged = currentFingerprint !== g.lastNetworkFingerprint;
+ const wasSleep = elapsed > NETWORK_CHECK_INTERVAL_MS * 6;
+ if (networkChanged) g.lastNetworkFingerprint = currentFingerprint;
+
+ // Real reachability check (TCP 1.1.1.1:443) — not just interface presence
+ const online = await checkInternet();
+ const wasOffline = g.lastOnline === false;
+ g.lastOnline = online;
+
+ if (!online) return; // no internet → idle, don't restart
+
+ const onlineEdge = wasOffline; // offline → online transition
+ if (!networkChanged && !wasSleep && !onlineEdge) return;
+
+ // Wait for DHCP/DNS to settle before probing
+ await new Promise((r) => setTimeout(r, NETWORK_SETTLE_MS));
+
+ const reason = onlineEdge ? "online"
+ : wasSleep && networkChanged ? "sleep+netchange"
+ : wasSleep ? "sleep" : "netchange";
+ safeRestartTunnel(reason).catch(() => {});
+ safeRestartTailscale(reason).catch(() => {});
+ } catch (err) {
+ console.log("[NetworkMonitor] error:", err.message);
+ }
+ }, NETWORK_CHECK_INTERVAL_MS);
+
+ if (g.networkMonitorInterval.unref) g.networkMonitorInterval.unref();
+}
+
+export default initializeApp;
diff --git a/src/shared/utils/api.js b/src/shared/utils/api.js
new file mode 100644
index 0000000000000000000000000000000000000000..5712ef9eeb88f44a964cee0aae64b70de70650c3
--- /dev/null
+++ b/src/shared/utils/api.js
@@ -0,0 +1,93 @@
+/**
+ * API utility functions for making HTTP requests
+ */
+
+const DEFAULT_HEADERS = {
+ "Content-Type": "application/json",
+};
+
+/**
+ * Make a GET request
+ * @param {string} url - API endpoint
+ * @param {object} options - Fetch options
+ * @returns {Promise