From 05fcd08057c828bf6a912c6a87d594531e048ff1 Mon Sep 17 00:00:00 2001 From: wehub-resource-sync Date: Mon, 13 Jul 2026 12:21:01 +0800 Subject: [PATCH] chore: import upstream snapshot with attribution --- .dockerignore | 32 + .env.example | 41 + .github/dependabot.yml | 2 + .github/workflows/docker-publish.yml | 60 + .github/workflows/gitbook-pages.yml | 50 + .gitignore | 80 + .npmignore | 31 + .vscode/settings.json | 20 + CHANGELOG.md | 380 ++++ CLAUDE.md | 91 + DOCKER.md | 132 ++ Dockerfile | 53 + LICENSE | 21 + README.md | 1444 ++++++++++++++ README.wehub.md | 7 + README.zh-CN.md | 1311 ++++++++++++ captain-definition | 4 + cli/.gitignore | 2 + cli/.npmignore | 9 + cli/LICENSE | 42 + cli/README.md | 125 ++ cli/cli.js | 852 ++++++++ cli/hooks/postinstall.js | 22 + cli/hooks/sqliteRuntime.js | 156 ++ cli/hooks/trayRuntime.js | 107 + cli/package.json | 48 + cli/scripts/build-cli.js | 281 +++ cli/scripts/buildMitm.js | 71 + cli/src/cli/api/client.js | 556 ++++++ cli/src/cli/menus/apiKeys.js | 233 +++ cli/src/cli/menus/cliTools.js | 618 ++++++ cli/src/cli/menus/combos.js | 477 +++++ cli/src/cli/menus/providers.js | 847 ++++++++ cli/src/cli/menus/settings.js | 204 ++ cli/src/cli/terminalUI.js | 121 ++ cli/src/cli/tray/autostart.js | 306 +++ cli/src/cli/tray/icon.ico | Bin 0 -> 969 bytes cli/src/cli/tray/icon.png | Bin 0 -> 947 bytes cli/src/cli/tray/tray.js | 322 +++ cli/src/cli/tray/tray.ps1 | 130 ++ cli/src/cli/tray/trayWin.js | 89 + cli/src/cli/utils/clipboard.js | 30 + cli/src/cli/utils/display.js | 156 ++ cli/src/cli/utils/endpoint.js | 32 + cli/src/cli/utils/format.js | 125 ++ cli/src/cli/utils/input.js | 156 ++ cli/src/cli/utils/menuHelper.js | 156 ++ cli/src/cli/utils/modelSelector.js | 136 ++ custom-server.js | 32 + docker-compose.yml | 30 + docs/ARCHITECTURE.md | 557 ++++++ eslint.config.mjs | 16 + gitbook/.gitignore | 44 + gitbook/app/[lang]/[...slug]/page.js | 36 + gitbook/app/[lang]/page.js | 26 + gitbook/app/globals.css | 190 ++ gitbook/app/layout.js | 21 + gitbook/app/page.js | 26 + gitbook/components/DocsContent.js | 13 + gitbook/components/DocsHeader.js | 78 + gitbook/components/DocsLayout.js | 25 + gitbook/components/DocsSidebar.js | 125 ++ gitbook/components/DocsToc.js | 61 + gitbook/components/LanguageSwitcher.js | 96 + gitbook/constants/docsConfig.js | 242 +++ gitbook/constants/languages.js | 19 + gitbook/content/en/deployment/cloud.md | 473 +++++ gitbook/content/en/deployment/localhost.md | 164 ++ gitbook/content/en/faq.md | 387 ++++ gitbook/content/en/features/combos.md | 537 +++++ gitbook/content/en/features/quota-tracking.md | 687 +++++++ gitbook/content/en/features/smart-routing.md | 407 ++++ .../en/getting-started/installation.md | 478 +++++ .../content/en/getting-started/quick-start.md | 247 +++ gitbook/content/en/index.md | 164 ++ gitbook/content/en/integration/claude-code.md | 109 + gitbook/content/en/integration/cline.md | 201 ++ gitbook/content/en/integration/codex.md | 136 ++ gitbook/content/en/integration/continue.md | 249 +++ gitbook/content/en/integration/cursor.md | 149 ++ gitbook/content/en/integration/other-tools.md | 416 ++++ gitbook/content/en/integration/roo.md | 127 ++ gitbook/content/en/providers/cheap.md | 462 +++++ gitbook/content/en/providers/free.md | 442 +++++ gitbook/content/en/providers/subscription.md | 404 ++++ gitbook/content/en/troubleshooting.md | 351 ++++ gitbook/content/es/deployment/cloud.md | 473 +++++ gitbook/content/es/deployment/localhost.md | 164 ++ gitbook/content/es/faq.md | 387 ++++ gitbook/content/es/features/combos.md | 537 +++++ gitbook/content/es/features/quota-tracking.md | 687 +++++++ gitbook/content/es/features/smart-routing.md | 407 ++++ .../es/getting-started/installation.md | 478 +++++ .../content/es/getting-started/quick-start.md | 247 +++ gitbook/content/es/index.md | 164 ++ gitbook/content/es/integration/claude-code.md | 109 + gitbook/content/es/integration/cline.md | 201 ++ gitbook/content/es/integration/codex.md | 136 ++ gitbook/content/es/integration/continue.md | 249 +++ gitbook/content/es/integration/cursor.md | 149 ++ gitbook/content/es/integration/other-tools.md | 416 ++++ gitbook/content/es/integration/roo.md | 127 ++ gitbook/content/es/providers/cheap.md | 462 +++++ gitbook/content/es/providers/free.md | 442 +++++ gitbook/content/es/providers/subscription.md | 404 ++++ gitbook/content/es/troubleshooting.md | 351 ++++ gitbook/content/ja/deployment/cloud.md | 473 +++++ gitbook/content/ja/deployment/localhost.md | 164 ++ gitbook/content/ja/faq.md | 387 ++++ gitbook/content/ja/features/combos.md | 537 +++++ gitbook/content/ja/features/quota-tracking.md | 687 +++++++ gitbook/content/ja/features/smart-routing.md | 407 ++++ .../ja/getting-started/installation.md | 478 +++++ .../content/ja/getting-started/quick-start.md | 247 +++ gitbook/content/ja/index.md | 164 ++ gitbook/content/ja/integration/claude-code.md | 109 + gitbook/content/ja/integration/cline.md | 201 ++ gitbook/content/ja/integration/codex.md | 136 ++ gitbook/content/ja/integration/continue.md | 249 +++ gitbook/content/ja/integration/cursor.md | 149 ++ gitbook/content/ja/integration/other-tools.md | 416 ++++ gitbook/content/ja/integration/roo.md | 127 ++ gitbook/content/ja/providers/cheap.md | 462 +++++ gitbook/content/ja/providers/free.md | 442 +++++ gitbook/content/ja/providers/subscription.md | 404 ++++ gitbook/content/ja/troubleshooting.md | 351 ++++ gitbook/content/vi/deployment/cloud.md | 473 +++++ gitbook/content/vi/deployment/localhost.md | 164 ++ gitbook/content/vi/faq.md | 387 ++++ gitbook/content/vi/features/combos.md | 537 +++++ gitbook/content/vi/features/quota-tracking.md | 687 +++++++ gitbook/content/vi/features/smart-routing.md | 407 ++++ .../vi/getting-started/installation.md | 478 +++++ .../content/vi/getting-started/quick-start.md | 247 +++ gitbook/content/vi/index.md | 164 ++ gitbook/content/vi/integration/claude-code.md | 109 + gitbook/content/vi/integration/cline.md | 201 ++ gitbook/content/vi/integration/codex.md | 136 ++ gitbook/content/vi/integration/continue.md | 249 +++ gitbook/content/vi/integration/cursor.md | 149 ++ gitbook/content/vi/integration/other-tools.md | 416 ++++ gitbook/content/vi/integration/roo.md | 127 ++ gitbook/content/vi/providers/cheap.md | 462 +++++ gitbook/content/vi/providers/free.md | 442 +++++ gitbook/content/vi/providers/subscription.md | 404 ++++ gitbook/content/vi/troubleshooting.md | 351 ++++ gitbook/content/zh-CN/deployment/cloud.md | 473 +++++ gitbook/content/zh-CN/deployment/localhost.md | 164 ++ gitbook/content/zh-CN/faq.md | 387 ++++ gitbook/content/zh-CN/features/combos.md | 537 +++++ .../content/zh-CN/features/quota-tracking.md | 687 +++++++ .../content/zh-CN/features/smart-routing.md | 407 ++++ .../zh-CN/getting-started/installation.md | 478 +++++ .../zh-CN/getting-started/quick-start.md | 247 +++ gitbook/content/zh-CN/index.md | 164 ++ .../content/zh-CN/integration/claude-code.md | 109 + gitbook/content/zh-CN/integration/cline.md | 201 ++ gitbook/content/zh-CN/integration/codex.md | 136 ++ gitbook/content/zh-CN/integration/continue.md | 249 +++ gitbook/content/zh-CN/integration/cursor.md | 149 ++ .../content/zh-CN/integration/other-tools.md | 416 ++++ gitbook/content/zh-CN/integration/roo.md | 127 ++ gitbook/content/zh-CN/providers/cheap.md | 462 +++++ gitbook/content/zh-CN/providers/free.md | 442 +++++ .../content/zh-CN/providers/subscription.md | 404 ++++ gitbook/content/zh-CN/troubleshooting.md | 351 ++++ gitbook/jsconfig.json | 8 + gitbook/lib/content.js | 42 + gitbook/next.config.mjs | 24 + gitbook/package.json | 25 + gitbook/postcss.config.mjs | 5 + gitbook/utils/markdown.js | 218 ++ i18n/README.ja-JP.md | 1209 +++++++++++ i18n/README.ru.md | 1311 ++++++++++++ i18n/README.vi.md | 1310 ++++++++++++ i18n/README.zh-CN.md | 1306 ++++++++++++ images/9router.png | Bin 0 -> 758319 bytes images/fusion-combo-ui.png | Bin 0 -> 38539 bytes jsconfig.json | 12 + next.config.mjs | 91 + open-sse/.npmignore | 8 + open-sse/AGENTS.md | 39 + open-sse/config/appConstants.js | 182 ++ open-sse/config/codexInstructions.js | 119 ++ open-sse/config/constants.js | 4 + open-sse/config/defaultThinkingSignature.js | 12 + open-sse/config/errorConfig.js | 85 + open-sse/config/googleTtsLanguages.js | 62 + open-sse/config/kiroConstants.js | 277 +++ open-sse/config/mediaConfig.js | 27 + open-sse/config/models.js | 13 + open-sse/config/ollamaModels.js | 19 + open-sse/config/providerModels.js | 106 + open-sse/config/providers.js | 19 + open-sse/config/runtimeConfig.js | 96 + open-sse/config/ttsModels.js | 131 ++ open-sse/executors/antigravity.js | 620 ++++++ open-sse/executors/azure.js | 57 + open-sse/executors/base.js | 186 ++ open-sse/executors/codebuddy-cn.js | 40 + open-sse/executors/codex.js | 485 +++++ open-sse/executors/commandcode.js | 94 + open-sse/executors/cursor.js | 688 +++++++ open-sse/executors/default.js | 332 ++++ open-sse/executors/gemini-cli.js | 89 + open-sse/executors/github.js | 345 ++++ open-sse/executors/grok-cli.js | 397 ++++ open-sse/executors/grok-web.js | 343 ++++ open-sse/executors/iflow.js | 108 + open-sse/executors/index.js | 90 + open-sse/executors/kimchi.js | 123 ++ open-sse/executors/kiro.js | 592 ++++++ open-sse/executors/mimo-free.js | 167 ++ open-sse/executors/ollama-local.js | 14 + open-sse/executors/opencode-go.js | 49 + open-sse/executors/opencode.js | 32 + open-sse/executors/perplexity-web.js | 505 +++++ open-sse/executors/qoder.js | 458 +++++ open-sse/executors/qwen.js | 129 ++ open-sse/executors/vertex.js | 177 ++ open-sse/executors/xiaomi-tokenplan.js | 20 + open-sse/handlers/chatCore.js | 399 ++++ .../handlers/chatCore/nonStreamingHandler.js | 312 +++ open-sse/handlers/chatCore/requestDetail.js | 126 ++ .../handlers/chatCore/sseToJsonHandler.js | 237 +++ .../handlers/chatCore/streamingHandler.js | 144 ++ open-sse/handlers/embeddingProviders/_base.js | 4 + .../handlers/embeddingProviders/gemini.js | 54 + open-sse/handlers/embeddingProviders/index.js | 24 + .../handlers/embeddingProviders/openai.js | 31 + .../embeddingProviders/openaiCompatNode.js | 13 + open-sse/handlers/embeddingsCore.js | 126 ++ open-sse/handlers/fetch/index.js | 237 +++ open-sse/handlers/imageGenerationCore.js | 230 +++ open-sse/handlers/imageProviders/_base.js | 31 + .../handlers/imageProviders/antigravity.js | 73 + .../imageProviders/blackForestLabs.js | 44 + .../handlers/imageProviders/cloudflareAi.js | 179 ++ open-sse/handlers/imageProviders/codex.js | 199 ++ open-sse/handlers/imageProviders/comfyui.js | 12 + open-sse/handlers/imageProviders/falAi.js | 42 + open-sse/handlers/imageProviders/gemini.js | 26 + .../handlers/imageProviders/huggingface.js | 23 + open-sse/handlers/imageProviders/index.js | 43 + .../handlers/imageProviders/nanobanana.js | 60 + open-sse/handlers/imageProviders/openai.js | 33 + open-sse/handlers/imageProviders/runwayml.js | 48 + open-sse/handlers/imageProviders/sdwebui.js | 20 + .../handlers/imageProviders/stabilityAi.js | 35 + open-sse/handlers/responsesHandler.js | 99 + open-sse/handlers/search/callers.js | 371 ++++ open-sse/handlers/search/chatSearch.js | 455 +++++ open-sse/handlers/search/index.js | 201 ++ open-sse/handlers/search/normalizers.js | 223 +++ open-sse/handlers/sttCore.js | 193 ++ open-sse/handlers/ttsCore.js | 74 + open-sse/handlers/ttsProviders/_base.js | 39 + open-sse/handlers/ttsProviders/edgeTts.js | 89 + open-sse/handlers/ttsProviders/elevenlabs.js | 48 + open-sse/handlers/ttsProviders/gemini.js | 128 ++ .../handlers/ttsProviders/genericFormats.js | 169 ++ open-sse/handlers/ttsProviders/googleTts.js | 54 + open-sse/handlers/ttsProviders/index.js | 52 + open-sse/handlers/ttsProviders/localDevice.js | 87 + open-sse/handlers/ttsProviders/minimax.js | 59 + open-sse/handlers/ttsProviders/openai.js | 33 + open-sse/handlers/ttsProviders/openrouter.js | 73 + open-sse/index.js | 79 + open-sse/providers/REGISTRY_TEMPLATE.js | 98 + open-sse/providers/capabilities.js | 293 +++ open-sse/providers/index.js | 51 + open-sse/providers/models/helpers.js | 20 + open-sse/providers/models/namePatterns.js | 33 + open-sse/providers/models/schema.js | 40 + open-sse/providers/pricing.js | 308 +++ open-sse/providers/registry/alicode-intl.js | 30 + open-sse/providers/registry/alicode.js | 31 + open-sse/providers/registry/anthropic.js | 32 + open-sse/providers/registry/antigravity.js | 81 + open-sse/providers/registry/assemblyai.js | 38 + open-sse/providers/registry/aws-polly.js | 45 + open-sse/providers/registry/azure.js | 21 + .../providers/registry/black-forest-labs.js | 32 + open-sse/providers/registry/blackbox.js | 41 + open-sse/providers/registry/brave-search.js | 35 + open-sse/providers/registry/byteplus.js | 35 + open-sse/providers/registry/cartesia.js | 36 + open-sse/providers/registry/cerebras.js | 31 + open-sse/providers/registry/chutes.js | 24 + open-sse/providers/registry/claude.js | 87 + open-sse/providers/registry/cline.js | 51 + open-sse/providers/registry/clinepass.js | 57 + open-sse/providers/registry/cloudflare-ai.js | 55 + open-sse/providers/registry/codebuddy-cn.js | 77 + open-sse/providers/registry/codex.js | 91 + open-sse/providers/registry/cohere.js | 25 + open-sse/providers/registry/comfyui.js | 20 + open-sse/providers/registry/commandcode.js | 43 + open-sse/providers/registry/coqui.js | 30 + open-sse/providers/registry/cursor.js | 58 + open-sse/providers/registry/deepgram.js | 33 + open-sse/providers/registry/deepseek.js | 51 + open-sse/providers/registry/edge-tts.js | 24 + open-sse/providers/registry/elevenlabs.js | 35 + open-sse/providers/registry/exa.js | 50 + open-sse/providers/registry/fal-ai.js | 34 + open-sse/providers/registry/featherless.js | 34 + open-sse/providers/registry/firecrawl.js | 34 + open-sse/providers/registry/fireworks.js | 29 + open-sse/providers/registry/gemini-cli.js | 58 + open-sse/providers/registry/gemini.js | 81 + open-sse/providers/registry/github.js | 82 + open-sse/providers/registry/gitlab.js | 32 + open-sse/providers/registry/glm-cn.js | 35 + open-sse/providers/registry/glm.js | 58 + open-sse/providers/registry/google-pse.js | 35 + open-sse/providers/registry/google-tts.js | 24 + open-sse/providers/registry/grok-cli.js | 86 + open-sse/providers/registry/grok-web.js | 39 + open-sse/providers/registry/groq.js | 37 + open-sse/providers/registry/huggingface.js | 34 + open-sse/providers/registry/hyperbolic.js | 35 + open-sse/providers/registry/iflow.js | 52 + open-sse/providers/registry/index.js | 204 ++ open-sse/providers/registry/inworld.js | 36 + open-sse/providers/registry/jina-ai.js | 42 + open-sse/providers/registry/jina-reader.js | 34 + open-sse/providers/registry/kilocode.js | 51 + open-sse/providers/registry/kimchi.js | 49 + open-sse/providers/registry/kimi-coding.js | 65 + open-sse/providers/registry/kimi.js | 56 + open-sse/providers/registry/kiro.js | 115 ++ open-sse/providers/registry/linkup.js | 34 + open-sse/providers/registry/local-device.js | 24 + open-sse/providers/registry/mimo-free.js | 24 + open-sse/providers/registry/minimax-cn.js | 76 + open-sse/providers/registry/minimax.js | 83 + open-sse/providers/registry/mistral.js | 31 + open-sse/providers/registry/mmf.js | 19 + open-sse/providers/registry/nanobanana.js | 35 + open-sse/providers/registry/nebius.js | 27 + open-sse/providers/registry/nvidia.js | 43 + open-sse/providers/registry/ollama-local.js | 19 + open-sse/providers/registry/ollama.js | 36 + open-sse/providers/registry/openai.js | 81 + open-sse/providers/registry/opencode-go.js | 41 + open-sse/providers/registry/opencode.js | 25 + open-sse/providers/registry/openrouter.js | 60 + .../providers/registry/perplexity-agent.js | 49 + open-sse/providers/registry/perplexity-web.js | 33 + open-sse/providers/registry/perplexity.js | 35 + open-sse/providers/registry/playht.js | 36 + open-sse/providers/registry/qoder.js | 54 + open-sse/providers/registry/qwen.js | 33 + open-sse/providers/registry/recraft.js | 24 + open-sse/providers/registry/runwayml.js | 30 + open-sse/providers/registry/sdwebui.js | 20 + open-sse/providers/registry/searchapi.js | 35 + open-sse/providers/registry/searxng.js | 35 + open-sse/providers/registry/serper.js | 35 + open-sse/providers/registry/siliconflow.js | 39 + open-sse/providers/registry/stability-ai.js | 31 + open-sse/providers/registry/tavily.js | 50 + open-sse/providers/registry/together.js | 31 + open-sse/providers/registry/topaz.js | 19 + open-sse/providers/registry/tortoise.js | 30 + open-sse/providers/registry/venice.js | 56 + .../providers/registry/vercel-ai-gateway.js | 41 + open-sse/providers/registry/vertex-partner.js | 29 + open-sse/providers/registry/vertex.js | 32 + open-sse/providers/registry/volcengine-ark.js | 35 + open-sse/providers/registry/voyage-ai.js | 30 + open-sse/providers/registry/xai.js | 43 + open-sse/providers/registry/xiaomi-mimo.js | 46 + .../providers/registry/xiaomi-tokenplan.js | 63 + open-sse/providers/registry/youcom.js | 35 + open-sse/providers/schema.js | 76 + open-sse/providers/shared.js | 74 + open-sse/providers/thinkingLevels.js | 48 + open-sse/rtk/applyFilter.js | 15 + open-sse/rtk/autodetect.js | 119 ++ open-sse/rtk/caveman.js | 9 + open-sse/rtk/cavemanPrompts.js | 110 ++ open-sse/rtk/constants.js | 60 + open-sse/rtk/filters/buildOutput.js | 127 ++ open-sse/rtk/filters/dedupLog.js | 44 + open-sse/rtk/filters/find.js | 50 + open-sse/rtk/filters/gitDiff.js | 92 + open-sse/rtk/filters/gitLog.js | 99 + open-sse/rtk/filters/gitStatus.js | 117 ++ open-sse/rtk/filters/grep.js | 48 + open-sse/rtk/filters/ls.js | 79 + open-sse/rtk/filters/readNumbered.js | 27 + open-sse/rtk/filters/searchList.js | 52 + open-sse/rtk/filters/smartTruncate.js | 15 + open-sse/rtk/filters/tree.js | 32 + open-sse/rtk/headroom.js | 348 ++++ open-sse/rtk/index.js | 155 ++ open-sse/rtk/ponytail.js | 9 + open-sse/rtk/ponytailPrompt.js | 52 + open-sse/rtk/pxpipe.js | 104 + open-sse/rtk/registry.js | 40 + open-sse/rtk/systemInject.js | 98 + open-sse/services/accountFallback.js | 215 ++ open-sse/services/clinepassModels.js | 63 + open-sse/services/combo.js | 571 ++++++ open-sse/services/compact.js | 71 + open-sse/services/copilotModels.js | 155 ++ open-sse/services/kimchiModels.js | 176 ++ open-sse/services/kiroModels.js | 332 ++++ open-sse/services/model.js | 136 ++ open-sse/services/oauthCredentialManager.js | 156 ++ open-sse/services/projectId.js | 306 +++ open-sse/services/provider.js | 169 ++ open-sse/services/qoderModels.js | 214 ++ open-sse/services/tokenRefresh.js | 254 +++ open-sse/services/tokenRefresh/dedup.js | 31 + open-sse/services/tokenRefresh/providers.js | 624 ++++++ open-sse/services/usage.js | 60 + open-sse/services/usage/claude.js | 147 ++ open-sse/services/usage/codebuddy-cn.js | 138 ++ open-sse/services/usage/codex.js | 201 ++ open-sse/services/usage/github.js | 100 + open-sse/services/usage/google.js | 241 +++ open-sse/services/usage/grok-cli.js | 274 +++ open-sse/services/usage/kiro.js | 188 ++ open-sse/services/usage/minimax.js | 234 +++ open-sse/services/usage/misc.js | 269 +++ open-sse/services/usage/shared.js | 70 + open-sse/shared/clineAuth.js | 37 + open-sse/shared/machineId.js | 19 + open-sse/shared/qoder/constants.js | 64 + open-sse/shared/qoder/cosy.js | 175 ++ open-sse/shared/qoder/encoding.js | 55 + open-sse/transformer/responsesTransformer.js | 439 ++++ open-sse/transformer/streamToJsonConverter.js | 103 + open-sse/translator/concerns/chunk.js | 11 + open-sse/translator/concerns/finishReason.js | 63 + open-sse/translator/concerns/image.js | 124 ++ open-sse/translator/concerns/json.js | 5 + open-sse/translator/concerns/message.js | 7 + open-sse/translator/concerns/modality.js | 155 ++ open-sse/translator/concerns/paramSupport.js | 75 + open-sse/translator/concerns/prefetch.js | 96 + open-sse/translator/concerns/reasoning.js | 24 + open-sse/translator/concerns/thinking.js | 53 + .../translator/concerns/thinkingUnified.js | 329 +++ open-sse/translator/concerns/toolCall.js | 153 ++ open-sse/translator/concerns/usage.js | 69 + open-sse/translator/formats.js | 36 + open-sse/translator/formats/claude.js | 368 ++++ open-sse/translator/formats/gemini.js | 380 ++++ open-sse/translator/formats/maxTokens.js | 34 + open-sse/translator/formats/openai.js | 134 ++ open-sse/translator/formats/responsesApi.js | 141 ++ open-sse/translator/index.js | 284 +++ .../request/antigravity-to-openai.js | 242 +++ open-sse/translator/request/claude-to-kiro.js | 478 +++++ .../translator/request/claude-to-openai.js | 263 +++ .../translator/request/gemini-to-openai.js | 152 ++ .../translator/request/openai-responses.js | 385 ++++ .../translator/request/openai-to-claude.js | 383 ++++ .../request/openai-to-commandcode.js | 172 ++ .../translator/request/openai-to-cursor.js | 185 ++ .../translator/request/openai-to-gemini.js | 442 +++++ open-sse/translator/request/openai-to-kiro.js | 607 ++++++ .../translator/request/openai-to-ollama.js | 195 ++ .../translator/request/openai-to-vertex.js | 42 + .../translator/response/claude-to-openai.js | 195 ++ .../response/commandcode-to-openai.js | 184 ++ .../translator/response/cursor-to-openai.js | 30 + .../translator/response/gemini-to-openai.js | 147 ++ .../translator/response/kiro-to-claude.js | 261 +++ .../translator/response/kiro-to-openai.js | 160 ++ .../translator/response/ollama-to-openai.js | 134 ++ .../translator/response/openai-responses.js | 535 +++++ .../response/openai-to-antigravity.js | 123 ++ .../translator/response/openai-to-claude.js | 265 +++ open-sse/translator/schema/blocks.js | 43 + open-sse/translator/schema/defaults.js | 7 + open-sse/translator/schema/finishReasons.js | 27 + open-sse/translator/schema/index.js | 8 + open-sse/translator/schema/roles.js | 16 + open-sse/utils/bypassHandler.js | 313 +++ open-sse/utils/claudeCloaking.js | 165 ++ open-sse/utils/claudeHeaderCache.js | 70 + open-sse/utils/claudeSignature.js | 41 + open-sse/utils/clientDetector.js | 63 + open-sse/utils/cursorChecksum.js | 149 ++ open-sse/utils/cursorProtobuf.js | 904 +++++++++ open-sse/utils/debugLog.js | 14 + open-sse/utils/error.js | 147 ++ open-sse/utils/ollamaTransform.js | 85 + open-sse/utils/proxyFetch.js | 368 ++++ open-sse/utils/reasoningContentInjector.js | 76 + open-sse/utils/requestLogger.js | 260 +++ open-sse/utils/responsesStreamHelpers.js | 50 + open-sse/utils/sessionManager.js | 231 +++ open-sse/utils/sse.js | 14 + open-sse/utils/sseConstants.js | 23 + open-sse/utils/stream.js | 494 +++++ open-sse/utils/streamHandler.js | 254 +++ open-sse/utils/streamHelpers.js | 122 ++ open-sse/utils/toolDeduper.js | 49 + open-sse/utils/usageTracking.js | 438 ++++ package.json | 60 + postcss.config.mjs | 12 + public/favicon.svg | 11 + public/file.svg | 1 + public/globe.svg | 1 + public/i18n/literals/ar.json | 195 ++ public/i18n/literals/bn.json | 195 ++ public/i18n/literals/cs.json | 195 ++ public/i18n/literals/da.json | 195 ++ public/i18n/literals/de.json | 195 ++ public/i18n/literals/el.json | 195 ++ public/i18n/literals/es.json | 195 ++ public/i18n/literals/fa.json | 195 ++ public/i18n/literals/fi.json | 195 ++ public/i18n/literals/fr.json | 195 ++ public/i18n/literals/he.json | 195 ++ public/i18n/literals/hi.json | 195 ++ public/i18n/literals/hu.json | 195 ++ public/i18n/literals/id.json | 195 ++ public/i18n/literals/it.json | 195 ++ public/i18n/literals/ja.json | 195 ++ public/i18n/literals/ko.json | 195 ++ public/i18n/literals/nl.json | 195 ++ public/i18n/literals/no.json | 195 ++ public/i18n/literals/pl.json | 195 ++ public/i18n/literals/pt-BR.json | 195 ++ public/i18n/literals/pt-PT.json | 195 ++ public/i18n/literals/ro.json | 195 ++ public/i18n/literals/ru.json | 195 ++ public/i18n/literals/sv.json | 195 ++ public/i18n/literals/th.json | 195 ++ public/i18n/literals/tl.json | 195 ++ public/i18n/literals/tr.json | 195 ++ public/i18n/literals/uk.json | 195 ++ public/i18n/literals/ur.json | 195 ++ public/i18n/literals/vi.json | 195 ++ public/i18n/literals/zh-CN.json | 1391 +++++++++++++ public/i18n/literals/zh-TW.json | 195 ++ public/icons/icon-192.svg | 4 + public/icons/icon-512.svg | 4 + public/next.svg | 1 + public/providers/alicode-intl.png | Bin 0 -> 3546 bytes public/providers/alicode.png | Bin 0 -> 3546 bytes public/providers/amp.png | Bin 0 -> 6387 bytes public/providers/anthropic-m.png | Bin 0 -> 5133 bytes public/providers/anthropic.png | Bin 0 -> 2736 bytes public/providers/antigravity.png | Bin 0 -> 11588 bytes public/providers/assemblyai.png | Bin 0 -> 1117 bytes public/providers/aws-polly.png | Bin 0 -> 30178 bytes public/providers/azure.png | Bin 0 -> 7167 bytes public/providers/black-forest-labs.png | Bin 0 -> 4654 bytes public/providers/blackbox.png | Bin 0 -> 3162 bytes public/providers/brave-search.png | Bin 0 -> 9853 bytes public/providers/byteplus.png | Bin 0 -> 2709 bytes public/providers/cartesia.png | Bin 0 -> 1711 bytes public/providers/cerebras.png | Bin 0 -> 1597 bytes public/providers/chutes.png | Bin 0 -> 4730 bytes public/providers/claude.png | Bin 0 -> 11797 bytes public/providers/cline.png | Bin 0 -> 15547 bytes public/providers/clinepass.png | Bin 0 -> 15547 bytes public/providers/cloudflare-ai.png | Bin 0 -> 3947 bytes public/providers/codebuddy-cn.png | Bin 0 -> 23433 bytes public/providers/codex.png | Bin 0 -> 5819 bytes public/providers/cohere.png | Bin 0 -> 4366 bytes public/providers/comfyui.png | Bin 0 -> 29834 bytes public/providers/commandcode.png | Bin 0 -> 6585 bytes public/providers/continue.png | Bin 0 -> 33371 bytes public/providers/copilot.png | Bin 0 -> 27346 bytes public/providers/coqui.png | Bin 0 -> 16367 bytes public/providers/cursor.png | Bin 0 -> 7736 bytes public/providers/deepgram.png | Bin 0 -> 2736 bytes public/providers/deepseek-tui.png | Bin 0 -> 8406 bytes public/providers/deepseek.png | Bin 0 -> 7727 bytes public/providers/droid.png | Bin 0 -> 6875 bytes public/providers/edge-tts.png | Bin 0 -> 1348 bytes public/providers/elevenlabs.png | Bin 0 -> 933 bytes public/providers/exa.png | Bin 0 -> 10724 bytes public/providers/fal-ai.png | Bin 0 -> 6389 bytes public/providers/firecrawl.png | Bin 0 -> 3211 bytes public/providers/fireworks.png | Bin 0 -> 1729 bytes public/providers/gemini-cli.png | Bin 0 -> 11646 bytes public/providers/gemini.png | Bin 0 -> 9927 bytes public/providers/github.png | Bin 0 -> 27346 bytes public/providers/glm-cn.png | Bin 0 -> 2548 bytes public/providers/glm.png | Bin 0 -> 2548 bytes public/providers/google-pse.png | Bin 0 -> 1981 bytes public/providers/google-tts.png | Bin 0 -> 6038 bytes public/providers/grok-cli.png | Bin 0 -> 3168 bytes public/providers/grok-web.png | Bin 0 -> 3168 bytes public/providers/groq.png | Bin 0 -> 4915 bytes public/providers/hermes.png | Bin 0 -> 23273 bytes public/providers/huggingface.png | Bin 0 -> 15383 bytes public/providers/hyperbolic.png | Bin 0 -> 8824 bytes public/providers/iflow.png | Bin 0 -> 10327 bytes public/providers/inworld.png | Bin 0 -> 2606 bytes public/providers/jcode.png | Bin 0 -> 1260 bytes public/providers/jina-ai.png | Bin 0 -> 3874 bytes public/providers/jina-reader.png | Bin 0 -> 1620 bytes public/providers/kilocode.png | Bin 0 -> 3334 bytes public/providers/kimchi.png | Bin 0 -> 3188 bytes public/providers/kimchi.svg | 4 + public/providers/kimi-coding.png | Bin 0 -> 18477 bytes public/providers/kimi.png | Bin 0 -> 18477 bytes public/providers/kiro.png | Bin 0 -> 8905 bytes public/providers/linkup.png | Bin 0 -> 2216 bytes public/providers/local-device.png | Bin 0 -> 1261 bytes public/providers/mimo-free.png | Bin 0 -> 2630 bytes public/providers/minimax-cn.png | Bin 0 -> 15349 bytes public/providers/minimax.png | Bin 0 -> 15349 bytes public/providers/mistral.png | Bin 0 -> 2055 bytes public/providers/nanobanana.png | Bin 0 -> 9927 bytes public/providers/nebius.png | Bin 0 -> 3837 bytes public/providers/nvidia.png | Bin 0 -> 10349 bytes public/providers/oai-cc.png | Bin 0 -> 10137 bytes public/providers/oai-r.png | Bin 0 -> 9041 bytes public/providers/ollama-local.png | Bin 0 -> 5637 bytes public/providers/ollama.png | Bin 0 -> 5637 bytes public/providers/openai.png | Bin 0 -> 1117 bytes public/providers/openclaw.png | Bin 0 -> 16740 bytes public/providers/opencode-go.png | Bin 0 -> 16378 bytes public/providers/opencode.png | Bin 0 -> 16378 bytes public/providers/openrouter.png | Bin 0 -> 8824 bytes public/providers/perplexity-web.png | Bin 0 -> 10678 bytes public/providers/perplexity.png | Bin 0 -> 10678 bytes public/providers/playht.png | Bin 0 -> 23715 bytes public/providers/qoder.png | Bin 0 -> 7197 bytes public/providers/qwen.png | Bin 0 -> 14534 bytes public/providers/recraft.png | Bin 0 -> 4083 bytes public/providers/roo.png | Bin 0 -> 3084 bytes public/providers/runwayml.png | Bin 0 -> 5346 bytes public/providers/sdwebui.png | Bin 0 -> 2059 bytes public/providers/searchapi.png | Bin 0 -> 7311 bytes public/providers/searxng.png | Bin 0 -> 7142 bytes public/providers/serper.png | Bin 0 -> 7148 bytes public/providers/siliconflow.png | Bin 0 -> 7250 bytes public/providers/stability-ai.png | Bin 0 -> 24455 bytes public/providers/tavily.png | Bin 0 -> 3987 bytes public/providers/together.png | Bin 0 -> 13884 bytes public/providers/topaz.png | Bin 0 -> 1134 bytes public/providers/tortoise.png | Bin 0 -> 3442 bytes public/providers/vertex-partner.png | Bin 0 -> 8908 bytes public/providers/vertex.png | Bin 0 -> 8908 bytes public/providers/volcengine-ark.png | Bin 0 -> 6005 bytes public/providers/voyage-ai.png | Bin 0 -> 1223 bytes public/providers/xai.png | Bin 0 -> 3168 bytes public/providers/xiaomi-mimo.png | Bin 0 -> 6408 bytes public/providers/xiaomi-tokenplan.png | Bin 0 -> 6408 bytes public/providers/youcom.png | Bin 0 -> 7580 bytes public/sw.js | 22 + public/vercel.svg | 1 + public/window.svg | 1 + scripts/injectDisplayToRegistry.mjs | 223 +++ scripts/migrate-registry.mjs | 271 +++ scripts/test-combo-autoswitch.mjs | 84 + scripts/translate-readme.js | 201 ++ skills/9router-chat/SKILL.md | 73 + skills/9router-embeddings/SKILL.md | 69 + skills/9router-image/SKILL.md | 86 + skills/9router-stt/SKILL.md | 79 + skills/9router-tts/SKILL.md | 80 + skills/9router-web-fetch/SKILL.md | 99 + skills/9router-web-search/SKILL.md | 91 + skills/9router/SKILL.md | 61 + skills/README.md | 42 + .../basic-chat/BasicChatPageClient.js | 967 +++++++++ .../(dashboard)/dashboard/basic-chat/page.js | 5 + .../dashboard/cli-tools/CLIToolsPageClient.js | 66 + .../cli-tools/[toolId]/ToolDetailClient.js | 173 ++ .../dashboard/cli-tools/[toolId]/page.js | 11 + .../components/AntigravityToolCard.js | 481 +++++ .../cli-tools/components/ApiKeySelect.js | 66 + .../cli-tools/components/BaseUrlSelect.js | 174 ++ .../cli-tools/components/ClaudeToolCard.js | 390 ++++ .../cli-tools/components/ClineToolCard.js | 301 +++ .../cli-tools/components/CodexToolCard.js | 402 ++++ .../cli-tools/components/CopilotToolCard.js | 323 +++ .../cli-tools/components/CoworkToolCard.js | 597 ++++++ .../components/DeepSeekTuiToolCard.js | 338 ++++ .../cli-tools/components/DefaultToolCard.js | 271 +++ .../cli-tools/components/DroidToolCard.js | 410 ++++ .../components/EndpointPresetControl.js | 128 ++ .../cli-tools/components/HermesToolCard.js | 317 +++ .../cli-tools/components/JcodeToolCard.js | 380 ++++ .../cli-tools/components/KiloToolCard.js | 275 +++ .../cli-tools/components/MitmLinkCard.js | 40 + .../cli-tools/components/MitmServerCard.js | 329 +++ .../cli-tools/components/MitmToolCard.js | 318 +++ .../cli-tools/components/OpenClawToolCard.js | 388 ++++ .../cli-tools/components/OpenCodeToolCard.js | 500 +++++ .../cli-tools/components/ToolSummaryCard.js | 39 + .../cli-tools/components/cliEndpointMatch.js | 13 + .../dashboard/cli-tools/components/index.js | 19 + .../(dashboard)/dashboard/cli-tools/page.js | 7 + src/app/(dashboard)/dashboard/combos/page.js | 654 ++++++ .../dashboard/console-log/ConsoleLogClient.js | 96 + .../(dashboard)/dashboard/console-log/page.js | 8 + .../dashboard/endpoint/EndpointPageClient.js | 1295 ++++++++++++ .../endpoint/components/EndpointRow.js | 22 + .../endpoint/components/SecurityWarning.js | 23 + .../endpoint/components/StatusAlert.js | 23 + .../dashboard/endpoint/components/Tooltip.js | 13 + .../dashboard/endpoint/endpointConstants.js | 32 + .../dashboard/endpoint/endpointPing.js | 29 + .../(dashboard)/dashboard/endpoint/page.js | 7 + .../[id]/components/EmbeddingExampleCard.js | 254 +++ .../[id]/components/GenericExampleCard.js | 539 +++++ .../[kind]/[id]/components/SttExampleCard.js | 290 +++ .../[kind]/[id]/components/TtsExampleCard.js | 566 ++++++ .../[kind]/[id]/components/exampleShared.js | 76 + .../media-providers/[kind]/[id]/page.js | 210 ++ .../dashboard/media-providers/[kind]/page.js | 289 +++ .../media-providers/combo/[id]/page.js | 410 ++++ .../dashboard/media-providers/web/page.js | 209 ++ .../dashboard/mitm/MitmPageClient.js | 117 ++ src/app/(dashboard)/dashboard/mitm/page.js | 5 + src/app/(dashboard)/dashboard/page.js | 7 + src/app/(dashboard)/dashboard/profile/page.js | 1182 +++++++++++ .../providers/[id]/AddApiKeyModal.js | 415 ++++ .../providers/[id]/AddCustomModelModal.js | 125 ++ .../providers/[id]/BulkImportCodexModal.js | 150 ++ .../providers/[id]/CompatibleModelsSection.js | 232 +++ .../dashboard/providers/[id]/ConnectionRow.js | 318 +++ .../dashboard/providers/[id]/CooldownTimer.js | 42 + .../providers/[id]/EditCompatibleNodeModal.js | 161 ++ .../dashboard/providers/[id]/ModelRow.js | 102 + .../[id]/PassthroughModelsSection.js | 174 ++ .../dashboard/providers/[id]/page.js | 1760 +++++++++++++++++ .../components/AddCompatibleModal.js | 221 +++ .../providers/components/ConnectionsCard.js | 493 +++++ .../components/ModelAvailabilityBadge.js | 185 ++ .../providers/components/ModelsCard.js | 294 +++ .../dashboard/providers/new/page.js | 220 +++ .../(dashboard)/dashboard/providers/page.js | 966 +++++++++ .../(dashboard)/dashboard/proxy-pools/page.js | 1063 ++++++++++ .../dashboard/pxpipe/PxpipeClient.js | 283 +++ src/app/(dashboard)/dashboard/pxpipe/page.js | 5 + src/app/(dashboard)/dashboard/quota/page.js | 11 + src/app/(dashboard)/dashboard/skills/page.js | 112 ++ .../dashboard/token-saver/TokenSaverClient.js | 1015 ++++++++++ .../(dashboard)/dashboard/token-saver/page.js | 5 + .../(dashboard)/dashboard/translator/page.js | 303 +++ .../usage/components/OverviewCards.js | 39 + .../ProviderLimits/ProviderLimitCard.js | 186 ++ .../ProviderLimits/QuotaProgressBar.js | 132 ++ .../components/ProviderLimits/QuotaTable.js | 264 +++ .../usage/components/ProviderLimits/index.js | 1438 ++++++++++++++ .../usage/components/ProviderLimits/utils.js | 505 +++++ .../usage/components/ProviderTopology.js | 327 +++ .../usage/components/RequestDetailsTab.js | 510 +++++ .../dashboard/usage/components/UsageChart.js | 141 ++ .../dashboard/usage/components/UsageTable.js | 255 +++ src/app/(dashboard)/dashboard/usage/page.js | 75 + src/app/(dashboard)/layout.js | 6 + src/app/api/auth/login/route.js | 83 + src/app/api/auth/logout/route.js | 12 + src/app/api/auth/oidc/callback/route.js | 87 + src/app/api/auth/oidc/start/route.js | 52 + src/app/api/auth/oidc/test/route.js | 84 + src/app/api/auth/reset-password/route.js | 13 + src/app/api/auth/status/route.js | 45 + src/app/api/cli-tools/all-statuses/route.js | 46 + .../cli-tools/antigravity-mitm/alias/route.js | 53 + .../api/cli-tools/antigravity-mitm/route.js | 202 ++ .../api/cli-tools/claude-settings/route.js | 203 ++ src/app/api/cli-tools/cline-settings/route.js | 135 ++ src/app/api/cli-tools/codex-settings/route.js | 238 +++ .../api/cli-tools/copilot-settings/route.js | 150 ++ .../cli-tools/cowork-mcp-registry/route.js | 77 + .../api/cli-tools/cowork-mcp-tools/route.js | 95 + .../api/cli-tools/cowork-settings/route.js | 387 ++++ .../cli-tools/deepseek-tui-settings/route.js | 164 ++ src/app/api/cli-tools/droid-settings/route.js | 215 ++ .../api/cli-tools/hermes-settings/route.js | 175 ++ src/app/api/cli-tools/jcode-settings/route.js | 216 ++ src/app/api/cli-tools/kilo-settings/route.js | 133 ++ .../api/cli-tools/openclaw-settings/route.js | 294 +++ .../api/cli-tools/opencode-settings/route.js | 265 +++ src/app/api/combos/[id]/route.js | 81 + src/app/api/combos/route.js | 48 + src/app/api/headroom/extras/route.js | 46 + src/app/api/headroom/proxy/[...path]/route.js | 104 + src/app/api/headroom/restart/route.js | 35 + src/app/api/headroom/start/route.js | 35 + src/app/api/headroom/status/route.js | 18 + src/app/api/headroom/stop/route.js | 14 + src/app/api/health/route.js | 15 + src/app/api/init/route.js | 4 + src/app/api/keys/[id]/route.js | 58 + src/app/api/keys/route.js | 42 + src/app/api/locale/route.js | 30 + src/app/api/mcp/[plugin]/message/route.js | 19 + src/app/api/mcp/[plugin]/sse/route.js | 35 + .../tts/deepgram/voices/route.js | 65 + .../tts/elevenlabs/voices/route.js | 71 + .../tts/inworld/voices/route.js | 61 + .../tts/minimax/voices/route.js | 113 ++ .../api/media-providers/tts/voices/route.js | 99 + src/app/api/models/alias/route.js | 53 + src/app/api/models/availability/route.js | 103 + src/app/api/models/custom/route.js | 48 + src/app/api/models/disabled/route.js | 50 + src/app/api/models/route.js | 67 + src/app/api/models/test/ping.js | 193 ++ src/app/api/models/test/route.js | 14 + .../api/oauth/[provider]/[action]/route.js | 351 ++++ src/app/api/oauth/codex/bulk-import/route.js | 121 ++ src/app/api/oauth/codex/import-token/route.js | 96 + src/app/api/oauth/cursor/auto-import/route.js | 258 +++ src/app/api/oauth/cursor/import/route.js | 100 + src/app/api/oauth/gitlab/pat/route.js | 62 + src/app/api/oauth/iflow/cookie/route.js | 137 ++ src/app/api/oauth/kiro/api-key/route.js | 67 + src/app/api/oauth/kiro/auto-import/route.js | 132 ++ .../api/oauth/kiro/import-cli-proxy/route.js | 40 + src/app/api/oauth/kiro/import/route.js | 66 + .../api/oauth/kiro/social-authorize/route.js | 43 + .../api/oauth/kiro/social-exchange/route.js | 67 + src/app/api/pricing/route.js | 134 ++ src/app/api/provider-nodes/[id]/route.js | 101 + src/app/api/provider-nodes/route.js | 104 + src/app/api/provider-nodes/validate/route.js | 212 ++ src/app/api/providers/[id]/models/route.js | 552 ++++++ src/app/api/providers/[id]/route.js | 189 ++ .../api/providers/[id]/test-models/route.js | 66 + src/app/api/providers/[id]/test/route.js | 23 + src/app/api/providers/[id]/test/testUtils.js | 861 ++++++++ src/app/api/providers/client/route.js | 127 ++ .../api/providers/kilo/free-models/route.js | 55 + src/app/api/providers/route.js | 197 ++ .../api/providers/suggested-models/filters.js | 26 + .../api/providers/suggested-models/route.js | 32 + src/app/api/providers/test-batch/route.js | 131 ++ src/app/api/providers/validate/route.js | 634 ++++++ src/app/api/proxy-pools/[id]/route.js | 123 ++ src/app/api/proxy-pools/[id]/test/route.js | 70 + .../proxy-pools/cloudflare-deploy/route.js | 145 ++ src/app/api/proxy-pools/deno-deploy/route.js | 175 ++ src/app/api/proxy-pools/route.js | 93 + .../api/proxy-pools/vercel-deploy/route.js | 142 ++ src/app/api/pxpipe/health/route.js | 16 + src/app/api/pxpipe/install/route.js | 20 + src/app/api/pxpipe/logs/route.js | 18 + src/app/api/pxpipe/restart/route.js | 16 + src/app/api/pxpipe/start/route.js | 26 + src/app/api/pxpipe/stats/route.js | 14 + src/app/api/pxpipe/status/route.js | 21 + src/app/api/pxpipe/stop/route.js | 16 + src/app/api/settings/database/route.js | 51 + src/app/api/settings/proxy-test/route.js | 23 + src/app/api/settings/require-login/route.js | 15 + src/app/api/settings/route.js | 117 ++ src/app/api/shutdown/route.js | 24 + src/app/api/tags/route.js | 18 + src/app/api/translator/console-logs/route.js | 24 + .../translator/console-logs/stream/route.js | 90 + src/app/api/translator/load/route.js | 45 + src/app/api/translator/save/route.js | 44 + src/app/api/translator/send/route.js | 94 + src/app/api/translator/translate/route.js | 90 + src/app/api/tunnel/disable/route.js | 12 + src/app/api/tunnel/enable/route.js | 16 + src/app/api/tunnel/status/route.js | 25 + src/app/api/tunnel/tailscale-check/route.js | 54 + src/app/api/tunnel/tailscale-disable/route.js | 12 + src/app/api/tunnel/tailscale-enable/route.js | 12 + src/app/api/tunnel/tailscale-install/route.js | 72 + .../codex-reset-credits/route.js | 156 ++ src/app/api/usage/[connectionId]/route.js | 191 ++ src/app/api/usage/chart/route.js | 21 + src/app/api/usage/history/route.js | 12 + src/app/api/usage/logs/route.js | 12 + src/app/api/usage/providers/route.js | 41 + src/app/api/usage/request-details/route.js | 59 + src/app/api/usage/request-logs/route.js | 13 + src/app/api/usage/stats/route.js | 23 + src/app/api/usage/stream/route.js | 79 + src/app/api/v1/api/chat/route.js | 37 + src/app/api/v1/audio/speech/route.js | 16 + src/app/api/v1/audio/transcriptions/route.js | 19 + src/app/api/v1/audio/voices/route.js | 68 + src/app/api/v1/chat/completions/route.js | 35 + src/app/api/v1/embeddings/route.js | 21 + src/app/api/v1/images/generations/route.js | 16 + src/app/api/v1/messages/count_tokens/route.js | 94 + src/app/api/v1/messages/route.js | 36 + src/app/api/v1/models/[kind]/route.js | 55 + src/app/api/v1/models/info/route.js | 104 + src/app/api/v1/models/route.js | 491 +++++ src/app/api/v1/responses/compact/route.js | 37 + src/app/api/v1/responses/route.js | 30 + src/app/api/v1/route.js | 1 + src/app/api/v1/search/route.js | 21 + src/app/api/v1/web/fetch/route.js | 21 + src/app/api/v1beta/models/[...path]/route.js | 585 ++++++ src/app/api/v1beta/models/route.js | 62 + src/app/api/version/route.js | 61 + src/app/api/version/shutdown/route.js | 15 + src/app/api/version/update/route.js | 21 + src/app/callback/page.js | 150 ++ src/app/dashboard/settings/pricing/page.js | 173 ++ src/app/favicon.ico | Bin 0 -> 25931 bytes src/app/globals.css | 511 +++++ .../landing/components/AnimatedBackground.js | 57 + src/app/landing/components/Features.js | 133 ++ src/app/landing/components/FlowAnimation.js | 175 ++ src/app/landing/components/Footer.js | 61 + src/app/landing/components/GetStarted.js | 97 + src/app/landing/components/HeroSection.js | 47 + src/app/landing/components/HowItWorks.js | 66 + src/app/landing/components/Navigation.js | 72 + src/app/landing/page.js | 106 + src/app/layout.js | 51 + src/app/login/page.js | 247 +++ src/app/manifest.js | 30 + src/app/page.js | 5 + src/dashboardGuard.js | 260 +++ src/i18n/RuntimeI18nProvider.js | 27 + src/i18n/config.js | 185 ++ src/i18n/runtime.js | 162 ++ src/lib/appUpdater.js | 200 ++ src/lib/auth/dashboardSession.js | 82 + src/lib/auth/loginLimiter.js | 60 + src/lib/auth/oidc.js | 234 +++ src/lib/consoleLogBuffer.js | 108 + src/lib/dataDir.js | 37 + src/lib/db/adapters/betterSqliteAdapter.js | 55 + src/lib/db/adapters/bunSqliteAdapter.js | 63 + src/lib/db/adapters/nodeSqliteAdapter.js | 84 + src/lib/db/adapters/sqljsAdapter.js | 115 ++ src/lib/db/backup.js | 75 + src/lib/db/driver.js | 85 + src/lib/db/helpers/jsonCol.js | 9 + src/lib/db/helpers/kvStore.js | 39 + src/lib/db/helpers/metaStore.js | 22 + src/lib/db/index.js | 171 ++ src/lib/db/migrate.js | 297 +++ src/lib/db/migrations/001-initial.js | 14 + src/lib/db/migrations/index.js | 10 + src/lib/db/paths.js | 18 + src/lib/db/repos/aliasRepo.js | 62 + src/lib/db/repos/apiKeysRepo.js | 75 + src/lib/db/repos/combosRepo.js | 73 + src/lib/db/repos/connectionsRepo.js | 261 +++ src/lib/db/repos/disabledModelsRepo.js | 56 + src/lib/db/repos/nodesRepo.js | 95 + src/lib/db/repos/pricingRepo.js | 108 + src/lib/db/repos/proxyPoolsRepo.js | 103 + src/lib/db/repos/requestDetailsRepo.js | 207 ++ src/lib/db/repos/settingsRepo.js | 114 ++ src/lib/db/repos/usageRepo.js | 772 ++++++++ src/lib/db/schema.js | 161 ++ src/lib/db/version.js | 21 + src/lib/disabledModelsDb.js | 4 + src/lib/headroom/detect.js | 192 ++ src/lib/headroom/process.js | 260 +++ src/lib/localDb.js | 21 + src/lib/mcp/stdioSseBridge.js | 183 ++ src/lib/mitmAliasCache.js | 46 + src/lib/network/connectionProxy.js | 187 ++ src/lib/network/initOutboundProxy.js | 25 + src/lib/network/outboundProxy.js | 85 + src/lib/network/proxyTest.js | 91 + src/lib/oauth/constants/oauth.js | 145 ++ src/lib/oauth/constants/xai.js | 62 + src/lib/oauth/kiroExternalIdp.js | 155 ++ src/lib/oauth/providerHelpers.js | 90 + src/lib/oauth/providers.js | 1681 ++++++++++++++++ src/lib/oauth/services/antigravity.js | 321 +++ src/lib/oauth/services/claude.js | 136 ++ src/lib/oauth/services/codex.js | 144 ++ src/lib/oauth/services/cursor.js | 179 ++ src/lib/oauth/services/gemini.js | 240 +++ src/lib/oauth/services/github.js | 225 +++ src/lib/oauth/services/iflow.js | 202 ++ src/lib/oauth/services/index.js | 17 + src/lib/oauth/services/kimchi.js | 133 ++ src/lib/oauth/services/kiro.js | 381 ++++ src/lib/oauth/services/oauth.js | 157 ++ src/lib/oauth/services/openai.js | 123 ++ src/lib/oauth/services/qoder.js | 216 ++ src/lib/oauth/services/qwen.js | 170 ++ src/lib/oauth/services/xai.js | 238 +++ src/lib/oauth/utils/banner.js | 63 + src/lib/oauth/utils/pkce.js | 39 + src/lib/oauth/utils/server.js | 426 ++++ src/lib/oauth/utils/ui.js | 48 + src/lib/providerNormalization.js | 45 + src/lib/pxpipe/events.js | 125 ++ src/lib/pxpipe/install.js | 123 ++ src/lib/pxpipe/loader.js | 70 + src/lib/pxpipe/service.js | 49 + src/lib/qoder/constants.js | 2 + src/lib/qoder/cosy.js | 2 + src/lib/qoder/encoding.js | 2 + src/lib/requestDetailsDb.js | 4 + src/lib/tunnel/cloudflare/cloudflared.js | 449 +++++ src/lib/tunnel/cloudflare/config.js | 9 + src/lib/tunnel/cloudflare/healthCheck.js | 29 + src/lib/tunnel/cloudflare/manager.js | 151 ++ src/lib/tunnel/cloudflare/pid.js | 23 + src/lib/tunnel/index.js | 53 + src/lib/tunnel/shared/dnsResolver.js | 17 + src/lib/tunnel/shared/internetCheck.js | 26 + src/lib/tunnel/shared/state.js | 41 + src/lib/tunnel/shared/watchdogConfig.js | 8 + src/lib/tunnel/tailscale/config.js | 7 + src/lib/tunnel/tailscale/healthCheck.js | 29 + src/lib/tunnel/tailscale/manager.js | 129 ++ src/lib/tunnel/tailscale/tailscale.js | 859 ++++++++ src/lib/updater/updater.js | 235 +++ src/lib/usageDb.js | 7 + src/mitm/antigravityIdeVersion.js | 50 + src/mitm/cert/generate.js | 32 + src/mitm/cert/install.js | 269 +++ src/mitm/cert/rootCA.js | 173 ++ src/mitm/config.js | 89 + src/mitm/dbReader.js | 22 + src/mitm/dns/dnsConfig.js | 266 +++ src/mitm/handlers/antigravity.js | 33 + src/mitm/handlers/base.js | 226 +++ src/mitm/handlers/copilot.js | 35 + src/mitm/handlers/cursor.js | 15 + src/mitm/handlers/kiro.js | 526 +++++ src/mitm/logger.js | 106 + src/mitm/manager.js | 883 +++++++++ src/mitm/paths.js | 32 + src/mitm/server.js | 440 +++++ src/mitm/winElevated.js | 81 + src/models/index.js | 38 + src/proxy.js | 9 + .../components/AddCustomEmbeddingModal.js | 183 ++ src/shared/components/Avatar.js | 88 + src/shared/components/Badge.js | 54 + src/shared/components/Button.js | 56 + src/shared/components/CapacityBadges.js | 28 + src/shared/components/Card.js | 116 ++ src/shared/components/ChangelogModal.js | 97 + src/shared/components/ComboFormModal.js | 176 ++ src/shared/components/CursorAuthModal.js | 212 ++ src/shared/components/DonateModal.js | 136 ++ src/shared/components/Drawer.js | 82 + src/shared/components/EditConnectionModal.js | 316 +++ src/shared/components/Footer.js | 132 ++ src/shared/components/GitLabAuthModal.js | 194 ++ src/shared/components/Header.js | 369 ++++ src/shared/components/HeaderLanguage.js | 46 + src/shared/components/HeaderMenu.js | 126 ++ src/shared/components/IFlowCookieModal.js | 132 ++ src/shared/components/Input.js | 65 + src/shared/components/KiroAuthModal.js | 595 ++++++ src/shared/components/KiroOAuthWrapper.js | 103 + src/shared/components/KiroSocialOAuthModal.js | 214 ++ src/shared/components/LanguageSwitcher.js | 191 ++ src/shared/components/Loading.js | 75 + src/shared/components/ManualConfigModal.js | 44 + src/shared/components/McpMarketplaceModal.js | 255 +++ src/shared/components/Modal.js | 146 ++ src/shared/components/ModelSelectModal.js | 581 ++++++ src/shared/components/NineRemoteButton.js | 23 + src/shared/components/NineRemotePromoModal.js | 99 + src/shared/components/NoAuthProxyCard.js | 134 ++ src/shared/components/OAuthModal.js | 724 +++++++ src/shared/components/Pagination.js | 150 ++ src/shared/components/PricingModal.js | 208 ++ src/shared/components/ProviderIcon.js | 51 + src/shared/components/ProviderInfoCard.js | 82 + src/shared/components/RequestLogger.js | 121 ++ src/shared/components/SegmentedControl.js | 48 + src/shared/components/Select.js | 67 + src/shared/components/Sidebar.js | 441 +++++ src/shared/components/ThemeProvider.js | 15 + src/shared/components/ThemeToggle.js | 42 + src/shared/components/Toggle.js | 69 + src/shared/components/Tooltip.js | 25 + src/shared/components/UsageStats.js | 533 +++++ src/shared/components/index.js | 44 + src/shared/components/layouts/AuthLayout.js | 29 + .../components/layouts/DashboardLayout.js | 104 + src/shared/components/layouts/index.js | 4 + src/shared/constants/cliTools.js | 396 ++++ src/shared/constants/colors.js | 77 + src/shared/constants/config.js | 109 + src/shared/constants/coworkPlugins.js | 72 + src/shared/constants/index.js | 4 + src/shared/constants/locales.js | 37 + src/shared/constants/mitmToolHosts.js | 12 + src/shared/constants/models.js | 47 + src/shared/constants/providers.js | 165 ++ src/shared/constants/providersDisplay.js | 12 + src/shared/constants/skills.js | 78 + src/shared/constants/ttsProviders.js | 138 ++ src/shared/hooks/index.js | 3 + src/shared/hooks/useCopyToClipboard.js | 43 + src/shared/hooks/useModelCaps.js | 44 + src/shared/hooks/useTheme.js | 60 + src/shared/services/bootstrap.js | 12 + src/shared/services/initializeApp.js | 296 +++ src/shared/services/quotaAutoPing.js | 298 +++ src/shared/utils/api.js | 93 + src/shared/utils/apiKey.js | 98 + src/shared/utils/clineAuth.js | 1 + src/shared/utils/cn.js | 11 + src/shared/utils/connectionStatus.js | 6 + src/shared/utils/index.js | 40 + src/shared/utils/machine.js | 6 + src/shared/utils/machineId.js | 66 + src/shared/utils/providerCustomModels.js | 54 + src/shared/utils/providerModelsFetcher.js | 30 + src/shared/utils/ssrfGuard.js | 56 + src/sse/handlers/chat.js | 287 +++ src/sse/handlers/embeddings.js | 141 ++ src/sse/handlers/fetch.js | 222 +++ src/sse/handlers/imageGeneration.js | 142 ++ src/sse/handlers/search.js | 206 ++ src/sse/handlers/stt.js | 88 + src/sse/handlers/tts.js | 114 ++ src/sse/services/auth.js | 319 +++ src/sse/services/model.js | 94 + src/sse/services/tokenRefresh.js | 325 +++ src/sse/utils/logger.js | 117 ++ src/store/headerSearchStore.js | 19 + src/store/index.js | 6 + src/store/notificationStore.js | 45 + src/store/providerStore.js | 55 + src/store/settingsStore.js | 51 + src/store/themeStore.js | 54 + src/store/userStore.js | 20 + start.sh | 4 + tests/.gitignore | 4 + tests/README.md | 51 + tests/__baseline__/alias-baseline.json | 247 +++ tests/__baseline__/baseline-results.json | 1 + tests/__baseline__/current.json | 1 + tests/__baseline__/known-fails.txt | 25 + tests/__baseline__/oauth-urls-baseline.json | 57 + tests/__baseline__/providers-baseline.json | 904 +++++++++ tests/__baseline__/snapshot-providers.mjs | 11 + tests/__baseline__/verify-alias.mjs | 55 + tests/__baseline__/verify-no-regression.mjs | 28 + tests/__baseline__/verify-oauth-urls.mjs | 70 + tests/__baseline__/verify-providers.mjs | 46 + tests/package.json | 17 + tests/translator/AGENTS.md | 122 ++ .../__snapshots__/golden-request.test.js.snap | 317 +++ .../golden-response-stream.test.js.snap | 570 ++++++ .../golden-translator-concerns.test.js.snap | 226 +++ .../golden-url-header.test.js.snap | 1237 ++++++++++++ tests/translator/bugs-antigravity.test.js | 139 ++ .../bugs-claudeCode-context.test.js | 73 + .../bugs-codexCli-responses.test.js | 77 + .../bugs-gemini-cursor-commandcode.test.js | 76 + tests/translator/bugs-kiro.test.js | 44 + tests/translator/bugs-openai-bridge.test.js | 120 ++ .../translator/bugs-toClaude-context.test.js | 182 ++ tests/translator/claude-kiro-direct.test.js | 179 ++ tests/translator/coverage-all-models.test.js | 53 + tests/translator/format-roundtrip.test.js | 76 + tests/translator/golden-request.test.js | 103 + .../translator/golden-response-stream.test.js | 118 ++ .../golden-translator-concerns.test.js | 106 + tests/translator/golden-url-header.test.js | 72 + tests/translator/matrix.js | 67 + .../translator/real/all-formats.real.test.js | 293 +++ .../real/antigravity-models.real.test.js | 127 ++ .../real/file-base64-survey.real.test.js | 168 ++ .../real/nvidia-thinking.e2e.test.js | 58 + .../real/provider-cases.real.test.js | 168 ++ .../real/smoke-providers.real.test.js | 123 ++ tests/translator/real/thinking.real.test.js | 122 ++ .../vision-capability-survey.real.test.js | 183 ++ tests/translator/registerAll.js | 24 + tests/translator/thinking-unified.test.js | 171 ++ tests/unit/alicode-cache-control-2069.test.js | 66 + tests/unit/antigravity-cache.test.js | 216 ++ tests/unit/antigravity-mitm.test.js | 40 + tests/unit/antigravity-oauth-client.test.js | 50 + tests/unit/antigravity-retry-hook.test.js | 101 + tests/unit/antigravity-usage-headers.test.js | 30 + tests/unit/base-executor-retry.test.js | 107 + tests/unit/buildOutputFilter.test.js | 232 +++ .../unit/buildOutputFilterAdversarial.test.js | 393 ++++ tests/unit/cached-token-e2e.test.js | 84 + tests/unit/cached-token-usage.test.js | 188 ++ tests/unit/capabilities-opus-context.test.js | 34 + tests/unit/capabilities-service-kind.test.js | 15 + tests/unit/capabilities.test.js | 29 + tests/unit/caveman-prompts.test.js | 79 + tests/unit/claude-cloaking.test.js | 76 + tests/unit/claude-header-forwarding.test.js | 417 ++++ .../unit/codebuddy-cn-bonus-recurring.test.js | 30 + tests/unit/codebuddy-reasoning-optin.test.js | 37 + tests/unit/codex-fast-capacity.test.js | 71 + tests/unit/codex-image-fetch.test.js | 161 ++ tests/unit/codex-refresh-token.test.js | 172 ++ tests/unit/codex-reset-credits.test.js | 198 ++ tests/unit/codex-tool-normalization.test.js | 140 ++ tests/unit/combo-autoswitch.test.js | 78 + tests/unit/combo-fusion.test.js | 216 ++ tests/unit/combo-routing.test.js | 58 + tests/unit/commandcode-to-openai.test.js | 127 ++ .../compatible-provider-connections.test.js | 169 ++ tests/unit/count-tokens.test.js | 84 + tests/unit/cursor-composer-thinking.test.js | 86 + tests/unit/dashboard-guard.test.js | 279 +++ tests/unit/db-benchmark.test.js | 174 ++ tests/unit/db-concurrent.test.js | 171 ++ tests/unit/db-driver-chain.test.js | 59 + tests/unit/db-migration-chain.test.js | 100 + tests/unit/db-sqlite-vs-lowdb.test.js | 287 +++ tests/unit/embeddings.cloud.test.js | 524 +++++ tests/unit/embeddingsCore.test.js | 646 ++++++ tests/unit/executor-const-guard.test.js | 48 + tests/unit/file-block-routing.test.js | 57 + tests/unit/finish-reason-concern.test.js | 83 + tests/unit/force-stream-config.test.js | 153 ++ tests/unit/gemini-native-endpoint.test.js | 260 +++ tests/unit/gemini-tts.test.js | 95 + tests/unit/gemini-usage-projectid.test.js | 87 + tests/unit/github-responses-routing.test.js | 56 + tests/unit/grok-cli-executor.test.js | 288 +++ tests/unit/grok-cli-oauth-probe.test.js | 50 + tests/unit/grok-cli-usage.test.js | 202 ++ tests/unit/headroom-chat-core.test.js | 253 +++ tests/unit/headroom-detect.test.js | 107 + tests/unit/headroom-responses-format.test.js | 107 + tests/unit/headroom.test.js | 212 ++ tests/unit/hf-model-routing.test.js | 12 + tests/unit/image-fetch-hardening.test.js | 77 + tests/unit/image-generation.test.js | 546 +++++ tests/unit/kimchi-strip-reasoning.test.js | 128 ++ tests/unit/kimchi.test.js | 234 +++ tests/unit/kiro-external-idp.test.js | 207 ++ tests/unit/kiro-model-slots.test.js | 40 + tests/unit/kiro-profile-arn.test.js | 63 + tests/unit/kiro-thinking-strip.test.js | 124 ++ tests/unit/mimo-free.live.test.js | 60 + tests/unit/mimo-free.test.js | 250 +++ tests/unit/minimax-tts.test.js | 120 ++ tests/unit/minimax-usage.test.js | 239 +++ tests/unit/minimax-voices.test.js | 83 + tests/unit/mitm-root-ca.test.js | 35 + tests/unit/modality-strip.test.js | 108 + tests/unit/model-name-regex.test.js | 50 + tests/unit/model-routing.test.js | 80 + tests/unit/model-test-routing.test.js | 194 ++ tests/unit/multimodal-drop-lock.test.js | 58 + tests/unit/oauth-cursor-auto-import.test.js | 184 ++ tests/unit/openai-responses-multiturn.test.js | 182 ++ .../openai-responses-terminal-event.test.js | 96 + .../openai-to-claude-response-tools.test.js | 61 + .../openai-to-claude-tools-no-type.test.js | 91 + tests/unit/openai-to-claude.test.js | 206 ++ tests/unit/openai-to-commandcode.test.js | 181 ++ tests/unit/openai-to-kiro.test.js | 365 ++++ tests/unit/openai-to-ollama-malformed.test.js | 30 + tests/unit/opencode-go-models.test.js | 71 + tests/unit/param-support.test.js | 55 + tests/unit/perplexity-web.test.js | 306 +++ tests/unit/prefetch-images.test.js | 58 + tests/unit/provider-custom-models.test.js | 84 + tests/unit/provider-display-split.test.js | 34 + tests/unit/provider-models-minimax-m3.test.js | 52 + .../unit/provider-pricing-minimax-m3.test.js | 29 + .../unit/provider-test-models-routing.test.js | 83 + tests/unit/provider-thinking-config.test.js | 17 + tests/unit/provider-validation.test.js | 271 +++ tests/unit/pxpipe.test.js | 95 + tests/unit/qoder.test.js | 469 +++++ tests/unit/quota-auto-ping.test.js | 351 ++++ tests/unit/reasoningContentInjector.test.js | 160 ++ tests/unit/request-details-tab.test.js | 252 +++ tests/unit/responses-abort-terminal.test.js | 72 + tests/unit/rtk.e2e.test.js | 136 ++ tests/unit/rtk.multi-provider.e2e.test.js | 137 ++ tests/unit/rtk.test.js | 525 +++++ tests/unit/rtkFindWindows.test.js | 62 + tests/unit/rtkKiro.test.js | 291 +++ tests/unit/searxng-provider-config.test.js | 30 + tests/unit/security-audit.test.js | 289 +++ tests/unit/session-manager.test.js | 62 + .../thinking-effort-openai-max-clamp.test.js | 40 + tests/unit/thinking-levels-gpt56-sol.test.js | 21 + tests/unit/token-refresh-dispatch.test.js | 22 + tests/unit/translator-custom-prefix.test.js | 55 + tests/unit/translator-helpers-edge.test.js | 47 + .../translator-request-normalization.test.js | 186 ++ tests/unit/usage-concern.test.js | 69 + tests/unit/usage-dispatch.test.js | 39 + tests/unit/venice-provider.test.js | 41 + tests/unit/web-cookie-validation.test.js | 187 ++ tests/unit/xai-oauth-service.test.js | 125 ++ tests/unit/xai-tokenRefresh.test.js | 63 + tests/vitest.config.js | 29 + 1298 files changed, 194683 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/docker-publish.yml create mode 100644 .github/workflows/gitbook-pages.yml create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 .vscode/settings.json create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 DOCKER.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README.wehub.md create mode 100644 README.zh-CN.md create mode 100644 captain-definition create mode 100644 cli/.gitignore create mode 100644 cli/.npmignore create mode 100644 cli/LICENSE create mode 100644 cli/README.md create mode 100755 cli/cli.js create mode 100644 cli/hooks/postinstall.js create mode 100644 cli/hooks/sqliteRuntime.js create mode 100644 cli/hooks/trayRuntime.js create mode 100644 cli/package.json create mode 100644 cli/scripts/build-cli.js create mode 100644 cli/scripts/buildMitm.js create mode 100644 cli/src/cli/api/client.js create mode 100644 cli/src/cli/menus/apiKeys.js create mode 100644 cli/src/cli/menus/cliTools.js create mode 100644 cli/src/cli/menus/combos.js create mode 100644 cli/src/cli/menus/providers.js create mode 100644 cli/src/cli/menus/settings.js create mode 100644 cli/src/cli/terminalUI.js create mode 100644 cli/src/cli/tray/autostart.js create mode 100644 cli/src/cli/tray/icon.ico create mode 100644 cli/src/cli/tray/icon.png create mode 100644 cli/src/cli/tray/tray.js create mode 100644 cli/src/cli/tray/tray.ps1 create mode 100644 cli/src/cli/tray/trayWin.js create mode 100644 cli/src/cli/utils/clipboard.js create mode 100644 cli/src/cli/utils/display.js create mode 100644 cli/src/cli/utils/endpoint.js create mode 100644 cli/src/cli/utils/format.js create mode 100644 cli/src/cli/utils/input.js create mode 100644 cli/src/cli/utils/menuHelper.js create mode 100644 cli/src/cli/utils/modelSelector.js create mode 100644 custom-server.js create mode 100644 docker-compose.yml create mode 100644 docs/ARCHITECTURE.md create mode 100644 eslint.config.mjs create mode 100644 gitbook/.gitignore create mode 100644 gitbook/app/[lang]/[...slug]/page.js create mode 100644 gitbook/app/[lang]/page.js create mode 100644 gitbook/app/globals.css create mode 100644 gitbook/app/layout.js create mode 100644 gitbook/app/page.js create mode 100644 gitbook/components/DocsContent.js create mode 100644 gitbook/components/DocsHeader.js create mode 100644 gitbook/components/DocsLayout.js create mode 100644 gitbook/components/DocsSidebar.js create mode 100644 gitbook/components/DocsToc.js create mode 100644 gitbook/components/LanguageSwitcher.js create mode 100644 gitbook/constants/docsConfig.js create mode 100644 gitbook/constants/languages.js create mode 100644 gitbook/content/en/deployment/cloud.md create mode 100644 gitbook/content/en/deployment/localhost.md create mode 100644 gitbook/content/en/faq.md create mode 100644 gitbook/content/en/features/combos.md create mode 100644 gitbook/content/en/features/quota-tracking.md create mode 100644 gitbook/content/en/features/smart-routing.md create mode 100644 gitbook/content/en/getting-started/installation.md create mode 100644 gitbook/content/en/getting-started/quick-start.md create mode 100644 gitbook/content/en/index.md create mode 100644 gitbook/content/en/integration/claude-code.md create mode 100644 gitbook/content/en/integration/cline.md create mode 100644 gitbook/content/en/integration/codex.md create mode 100644 gitbook/content/en/integration/continue.md create mode 100644 gitbook/content/en/integration/cursor.md create mode 100644 gitbook/content/en/integration/other-tools.md create mode 100644 gitbook/content/en/integration/roo.md create mode 100644 gitbook/content/en/providers/cheap.md create mode 100644 gitbook/content/en/providers/free.md create mode 100644 gitbook/content/en/providers/subscription.md create mode 100644 gitbook/content/en/troubleshooting.md create mode 100644 gitbook/content/es/deployment/cloud.md create mode 100644 gitbook/content/es/deployment/localhost.md create mode 100644 gitbook/content/es/faq.md create mode 100644 gitbook/content/es/features/combos.md create mode 100644 gitbook/content/es/features/quota-tracking.md create mode 100644 gitbook/content/es/features/smart-routing.md create mode 100644 gitbook/content/es/getting-started/installation.md create mode 100644 gitbook/content/es/getting-started/quick-start.md create mode 100644 gitbook/content/es/index.md create mode 100644 gitbook/content/es/integration/claude-code.md create mode 100644 gitbook/content/es/integration/cline.md create mode 100644 gitbook/content/es/integration/codex.md create mode 100644 gitbook/content/es/integration/continue.md create mode 100644 gitbook/content/es/integration/cursor.md create mode 100644 gitbook/content/es/integration/other-tools.md create mode 100644 gitbook/content/es/integration/roo.md create mode 100644 gitbook/content/es/providers/cheap.md create mode 100644 gitbook/content/es/providers/free.md create mode 100644 gitbook/content/es/providers/subscription.md create mode 100644 gitbook/content/es/troubleshooting.md create mode 100644 gitbook/content/ja/deployment/cloud.md create mode 100644 gitbook/content/ja/deployment/localhost.md create mode 100644 gitbook/content/ja/faq.md create mode 100644 gitbook/content/ja/features/combos.md create mode 100644 gitbook/content/ja/features/quota-tracking.md create mode 100644 gitbook/content/ja/features/smart-routing.md create mode 100644 gitbook/content/ja/getting-started/installation.md create mode 100644 gitbook/content/ja/getting-started/quick-start.md create mode 100644 gitbook/content/ja/index.md create mode 100644 gitbook/content/ja/integration/claude-code.md create mode 100644 gitbook/content/ja/integration/cline.md create mode 100644 gitbook/content/ja/integration/codex.md create mode 100644 gitbook/content/ja/integration/continue.md create mode 100644 gitbook/content/ja/integration/cursor.md create mode 100644 gitbook/content/ja/integration/other-tools.md create mode 100644 gitbook/content/ja/integration/roo.md create mode 100644 gitbook/content/ja/providers/cheap.md create mode 100644 gitbook/content/ja/providers/free.md create mode 100644 gitbook/content/ja/providers/subscription.md create mode 100644 gitbook/content/ja/troubleshooting.md create mode 100644 gitbook/content/vi/deployment/cloud.md create mode 100644 gitbook/content/vi/deployment/localhost.md create mode 100644 gitbook/content/vi/faq.md create mode 100644 gitbook/content/vi/features/combos.md create mode 100644 gitbook/content/vi/features/quota-tracking.md create mode 100644 gitbook/content/vi/features/smart-routing.md create mode 100644 gitbook/content/vi/getting-started/installation.md create mode 100644 gitbook/content/vi/getting-started/quick-start.md create mode 100644 gitbook/content/vi/index.md create mode 100644 gitbook/content/vi/integration/claude-code.md create mode 100644 gitbook/content/vi/integration/cline.md create mode 100644 gitbook/content/vi/integration/codex.md create mode 100644 gitbook/content/vi/integration/continue.md create mode 100644 gitbook/content/vi/integration/cursor.md create mode 100644 gitbook/content/vi/integration/other-tools.md create mode 100644 gitbook/content/vi/integration/roo.md create mode 100644 gitbook/content/vi/providers/cheap.md create mode 100644 gitbook/content/vi/providers/free.md create mode 100644 gitbook/content/vi/providers/subscription.md create mode 100644 gitbook/content/vi/troubleshooting.md create mode 100644 gitbook/content/zh-CN/deployment/cloud.md create mode 100644 gitbook/content/zh-CN/deployment/localhost.md create mode 100644 gitbook/content/zh-CN/faq.md create mode 100644 gitbook/content/zh-CN/features/combos.md create mode 100644 gitbook/content/zh-CN/features/quota-tracking.md create mode 100644 gitbook/content/zh-CN/features/smart-routing.md create mode 100644 gitbook/content/zh-CN/getting-started/installation.md create mode 100644 gitbook/content/zh-CN/getting-started/quick-start.md create mode 100644 gitbook/content/zh-CN/index.md create mode 100644 gitbook/content/zh-CN/integration/claude-code.md create mode 100644 gitbook/content/zh-CN/integration/cline.md create mode 100644 gitbook/content/zh-CN/integration/codex.md create mode 100644 gitbook/content/zh-CN/integration/continue.md create mode 100644 gitbook/content/zh-CN/integration/cursor.md create mode 100644 gitbook/content/zh-CN/integration/other-tools.md create mode 100644 gitbook/content/zh-CN/integration/roo.md create mode 100644 gitbook/content/zh-CN/providers/cheap.md create mode 100644 gitbook/content/zh-CN/providers/free.md create mode 100644 gitbook/content/zh-CN/providers/subscription.md create mode 100644 gitbook/content/zh-CN/troubleshooting.md create mode 100644 gitbook/jsconfig.json create mode 100644 gitbook/lib/content.js create mode 100644 gitbook/next.config.mjs create mode 100644 gitbook/package.json create mode 100644 gitbook/postcss.config.mjs create mode 100644 gitbook/utils/markdown.js create mode 100644 i18n/README.ja-JP.md create mode 100644 i18n/README.ru.md create mode 100644 i18n/README.vi.md create mode 100644 i18n/README.zh-CN.md create mode 100644 images/9router.png create mode 100644 images/fusion-combo-ui.png create mode 100644 jsconfig.json create mode 100644 next.config.mjs create mode 100644 open-sse/.npmignore create mode 100644 open-sse/AGENTS.md create mode 100644 open-sse/config/appConstants.js create mode 100644 open-sse/config/codexInstructions.js create mode 100644 open-sse/config/constants.js create mode 100644 open-sse/config/defaultThinkingSignature.js create mode 100644 open-sse/config/errorConfig.js create mode 100644 open-sse/config/googleTtsLanguages.js create mode 100644 open-sse/config/kiroConstants.js create mode 100644 open-sse/config/mediaConfig.js create mode 100644 open-sse/config/models.js create mode 100644 open-sse/config/ollamaModels.js create mode 100644 open-sse/config/providerModels.js create mode 100644 open-sse/config/providers.js create mode 100644 open-sse/config/runtimeConfig.js create mode 100644 open-sse/config/ttsModels.js create mode 100644 open-sse/executors/antigravity.js create mode 100644 open-sse/executors/azure.js create mode 100644 open-sse/executors/base.js create mode 100644 open-sse/executors/codebuddy-cn.js create mode 100644 open-sse/executors/codex.js create mode 100644 open-sse/executors/commandcode.js create mode 100644 open-sse/executors/cursor.js create mode 100644 open-sse/executors/default.js create mode 100644 open-sse/executors/gemini-cli.js create mode 100644 open-sse/executors/github.js create mode 100644 open-sse/executors/grok-cli.js create mode 100644 open-sse/executors/grok-web.js create mode 100644 open-sse/executors/iflow.js create mode 100644 open-sse/executors/index.js create mode 100644 open-sse/executors/kimchi.js create mode 100644 open-sse/executors/kiro.js create mode 100644 open-sse/executors/mimo-free.js create mode 100644 open-sse/executors/ollama-local.js create mode 100644 open-sse/executors/opencode-go.js create mode 100644 open-sse/executors/opencode.js create mode 100644 open-sse/executors/perplexity-web.js create mode 100644 open-sse/executors/qoder.js create mode 100644 open-sse/executors/qwen.js create mode 100644 open-sse/executors/vertex.js create mode 100644 open-sse/executors/xiaomi-tokenplan.js create mode 100644 open-sse/handlers/chatCore.js create mode 100644 open-sse/handlers/chatCore/nonStreamingHandler.js create mode 100644 open-sse/handlers/chatCore/requestDetail.js create mode 100644 open-sse/handlers/chatCore/sseToJsonHandler.js create mode 100644 open-sse/handlers/chatCore/streamingHandler.js create mode 100644 open-sse/handlers/embeddingProviders/_base.js create mode 100644 open-sse/handlers/embeddingProviders/gemini.js create mode 100644 open-sse/handlers/embeddingProviders/index.js create mode 100644 open-sse/handlers/embeddingProviders/openai.js create mode 100644 open-sse/handlers/embeddingProviders/openaiCompatNode.js create mode 100644 open-sse/handlers/embeddingsCore.js create mode 100644 open-sse/handlers/fetch/index.js create mode 100644 open-sse/handlers/imageGenerationCore.js create mode 100644 open-sse/handlers/imageProviders/_base.js create mode 100644 open-sse/handlers/imageProviders/antigravity.js create mode 100644 open-sse/handlers/imageProviders/blackForestLabs.js create mode 100644 open-sse/handlers/imageProviders/cloudflareAi.js create mode 100644 open-sse/handlers/imageProviders/codex.js create mode 100644 open-sse/handlers/imageProviders/comfyui.js create mode 100644 open-sse/handlers/imageProviders/falAi.js create mode 100644 open-sse/handlers/imageProviders/gemini.js create mode 100644 open-sse/handlers/imageProviders/huggingface.js create mode 100644 open-sse/handlers/imageProviders/index.js create mode 100644 open-sse/handlers/imageProviders/nanobanana.js create mode 100644 open-sse/handlers/imageProviders/openai.js create mode 100644 open-sse/handlers/imageProviders/runwayml.js create mode 100644 open-sse/handlers/imageProviders/sdwebui.js create mode 100644 open-sse/handlers/imageProviders/stabilityAi.js create mode 100644 open-sse/handlers/responsesHandler.js create mode 100644 open-sse/handlers/search/callers.js create mode 100644 open-sse/handlers/search/chatSearch.js create mode 100644 open-sse/handlers/search/index.js create mode 100644 open-sse/handlers/search/normalizers.js create mode 100644 open-sse/handlers/sttCore.js create mode 100644 open-sse/handlers/ttsCore.js create mode 100644 open-sse/handlers/ttsProviders/_base.js create mode 100644 open-sse/handlers/ttsProviders/edgeTts.js create mode 100644 open-sse/handlers/ttsProviders/elevenlabs.js create mode 100644 open-sse/handlers/ttsProviders/gemini.js create mode 100644 open-sse/handlers/ttsProviders/genericFormats.js create mode 100644 open-sse/handlers/ttsProviders/googleTts.js create mode 100644 open-sse/handlers/ttsProviders/index.js create mode 100644 open-sse/handlers/ttsProviders/localDevice.js create mode 100644 open-sse/handlers/ttsProviders/minimax.js create mode 100644 open-sse/handlers/ttsProviders/openai.js create mode 100644 open-sse/handlers/ttsProviders/openrouter.js create mode 100644 open-sse/index.js create mode 100644 open-sse/providers/REGISTRY_TEMPLATE.js create mode 100644 open-sse/providers/capabilities.js create mode 100644 open-sse/providers/index.js create mode 100644 open-sse/providers/models/helpers.js create mode 100644 open-sse/providers/models/namePatterns.js create mode 100644 open-sse/providers/models/schema.js create mode 100644 open-sse/providers/pricing.js create mode 100644 open-sse/providers/registry/alicode-intl.js create mode 100644 open-sse/providers/registry/alicode.js create mode 100644 open-sse/providers/registry/anthropic.js create mode 100644 open-sse/providers/registry/antigravity.js create mode 100644 open-sse/providers/registry/assemblyai.js create mode 100644 open-sse/providers/registry/aws-polly.js create mode 100644 open-sse/providers/registry/azure.js create mode 100644 open-sse/providers/registry/black-forest-labs.js create mode 100644 open-sse/providers/registry/blackbox.js create mode 100644 open-sse/providers/registry/brave-search.js create mode 100644 open-sse/providers/registry/byteplus.js create mode 100644 open-sse/providers/registry/cartesia.js create mode 100644 open-sse/providers/registry/cerebras.js create mode 100644 open-sse/providers/registry/chutes.js create mode 100644 open-sse/providers/registry/claude.js create mode 100644 open-sse/providers/registry/cline.js create mode 100644 open-sse/providers/registry/clinepass.js create mode 100644 open-sse/providers/registry/cloudflare-ai.js create mode 100644 open-sse/providers/registry/codebuddy-cn.js create mode 100644 open-sse/providers/registry/codex.js create mode 100644 open-sse/providers/registry/cohere.js create mode 100644 open-sse/providers/registry/comfyui.js create mode 100644 open-sse/providers/registry/commandcode.js create mode 100644 open-sse/providers/registry/coqui.js create mode 100644 open-sse/providers/registry/cursor.js create mode 100644 open-sse/providers/registry/deepgram.js create mode 100644 open-sse/providers/registry/deepseek.js create mode 100644 open-sse/providers/registry/edge-tts.js create mode 100644 open-sse/providers/registry/elevenlabs.js create mode 100644 open-sse/providers/registry/exa.js create mode 100644 open-sse/providers/registry/fal-ai.js create mode 100644 open-sse/providers/registry/featherless.js create mode 100644 open-sse/providers/registry/firecrawl.js create mode 100644 open-sse/providers/registry/fireworks.js create mode 100644 open-sse/providers/registry/gemini-cli.js create mode 100644 open-sse/providers/registry/gemini.js create mode 100644 open-sse/providers/registry/github.js create mode 100644 open-sse/providers/registry/gitlab.js create mode 100644 open-sse/providers/registry/glm-cn.js create mode 100644 open-sse/providers/registry/glm.js create mode 100644 open-sse/providers/registry/google-pse.js create mode 100644 open-sse/providers/registry/google-tts.js create mode 100644 open-sse/providers/registry/grok-cli.js create mode 100644 open-sse/providers/registry/grok-web.js create mode 100644 open-sse/providers/registry/groq.js create mode 100644 open-sse/providers/registry/huggingface.js create mode 100644 open-sse/providers/registry/hyperbolic.js create mode 100644 open-sse/providers/registry/iflow.js create mode 100644 open-sse/providers/registry/index.js create mode 100644 open-sse/providers/registry/inworld.js create mode 100644 open-sse/providers/registry/jina-ai.js create mode 100644 open-sse/providers/registry/jina-reader.js create mode 100644 open-sse/providers/registry/kilocode.js create mode 100644 open-sse/providers/registry/kimchi.js create mode 100644 open-sse/providers/registry/kimi-coding.js create mode 100644 open-sse/providers/registry/kimi.js create mode 100644 open-sse/providers/registry/kiro.js create mode 100644 open-sse/providers/registry/linkup.js create mode 100644 open-sse/providers/registry/local-device.js create mode 100644 open-sse/providers/registry/mimo-free.js create mode 100644 open-sse/providers/registry/minimax-cn.js create mode 100644 open-sse/providers/registry/minimax.js create mode 100644 open-sse/providers/registry/mistral.js create mode 100644 open-sse/providers/registry/mmf.js create mode 100644 open-sse/providers/registry/nanobanana.js create mode 100644 open-sse/providers/registry/nebius.js create mode 100644 open-sse/providers/registry/nvidia.js create mode 100644 open-sse/providers/registry/ollama-local.js create mode 100644 open-sse/providers/registry/ollama.js create mode 100644 open-sse/providers/registry/openai.js create mode 100644 open-sse/providers/registry/opencode-go.js create mode 100644 open-sse/providers/registry/opencode.js create mode 100644 open-sse/providers/registry/openrouter.js create mode 100644 open-sse/providers/registry/perplexity-agent.js create mode 100644 open-sse/providers/registry/perplexity-web.js create mode 100644 open-sse/providers/registry/perplexity.js create mode 100644 open-sse/providers/registry/playht.js create mode 100644 open-sse/providers/registry/qoder.js create mode 100644 open-sse/providers/registry/qwen.js create mode 100644 open-sse/providers/registry/recraft.js create mode 100644 open-sse/providers/registry/runwayml.js create mode 100644 open-sse/providers/registry/sdwebui.js create mode 100644 open-sse/providers/registry/searchapi.js create mode 100644 open-sse/providers/registry/searxng.js create mode 100644 open-sse/providers/registry/serper.js create mode 100644 open-sse/providers/registry/siliconflow.js create mode 100644 open-sse/providers/registry/stability-ai.js create mode 100644 open-sse/providers/registry/tavily.js create mode 100644 open-sse/providers/registry/together.js create mode 100644 open-sse/providers/registry/topaz.js create mode 100644 open-sse/providers/registry/tortoise.js create mode 100644 open-sse/providers/registry/venice.js create mode 100644 open-sse/providers/registry/vercel-ai-gateway.js create mode 100644 open-sse/providers/registry/vertex-partner.js create mode 100644 open-sse/providers/registry/vertex.js create mode 100644 open-sse/providers/registry/volcengine-ark.js create mode 100644 open-sse/providers/registry/voyage-ai.js create mode 100644 open-sse/providers/registry/xai.js create mode 100644 open-sse/providers/registry/xiaomi-mimo.js create mode 100644 open-sse/providers/registry/xiaomi-tokenplan.js create mode 100644 open-sse/providers/registry/youcom.js create mode 100644 open-sse/providers/schema.js create mode 100644 open-sse/providers/shared.js create mode 100644 open-sse/providers/thinkingLevels.js create mode 100644 open-sse/rtk/applyFilter.js create mode 100644 open-sse/rtk/autodetect.js create mode 100644 open-sse/rtk/caveman.js create mode 100644 open-sse/rtk/cavemanPrompts.js create mode 100644 open-sse/rtk/constants.js create mode 100644 open-sse/rtk/filters/buildOutput.js create mode 100644 open-sse/rtk/filters/dedupLog.js create mode 100644 open-sse/rtk/filters/find.js create mode 100644 open-sse/rtk/filters/gitDiff.js create mode 100644 open-sse/rtk/filters/gitLog.js create mode 100644 open-sse/rtk/filters/gitStatus.js create mode 100644 open-sse/rtk/filters/grep.js create mode 100644 open-sse/rtk/filters/ls.js create mode 100644 open-sse/rtk/filters/readNumbered.js create mode 100644 open-sse/rtk/filters/searchList.js create mode 100644 open-sse/rtk/filters/smartTruncate.js create mode 100644 open-sse/rtk/filters/tree.js create mode 100644 open-sse/rtk/headroom.js create mode 100644 open-sse/rtk/index.js create mode 100644 open-sse/rtk/ponytail.js create mode 100644 open-sse/rtk/ponytailPrompt.js create mode 100644 open-sse/rtk/pxpipe.js create mode 100644 open-sse/rtk/registry.js create mode 100644 open-sse/rtk/systemInject.js create mode 100644 open-sse/services/accountFallback.js create mode 100644 open-sse/services/clinepassModels.js create mode 100644 open-sse/services/combo.js create mode 100644 open-sse/services/compact.js create mode 100644 open-sse/services/copilotModels.js create mode 100644 open-sse/services/kimchiModels.js create mode 100644 open-sse/services/kiroModels.js create mode 100644 open-sse/services/model.js create mode 100644 open-sse/services/oauthCredentialManager.js create mode 100644 open-sse/services/projectId.js create mode 100644 open-sse/services/provider.js create mode 100644 open-sse/services/qoderModels.js create mode 100644 open-sse/services/tokenRefresh.js create mode 100644 open-sse/services/tokenRefresh/dedup.js create mode 100644 open-sse/services/tokenRefresh/providers.js create mode 100644 open-sse/services/usage.js create mode 100644 open-sse/services/usage/claude.js create mode 100644 open-sse/services/usage/codebuddy-cn.js create mode 100644 open-sse/services/usage/codex.js create mode 100644 open-sse/services/usage/github.js create mode 100644 open-sse/services/usage/google.js create mode 100644 open-sse/services/usage/grok-cli.js create mode 100644 open-sse/services/usage/kiro.js create mode 100644 open-sse/services/usage/minimax.js create mode 100644 open-sse/services/usage/misc.js create mode 100644 open-sse/services/usage/shared.js create mode 100644 open-sse/shared/clineAuth.js create mode 100644 open-sse/shared/machineId.js create mode 100644 open-sse/shared/qoder/constants.js create mode 100644 open-sse/shared/qoder/cosy.js create mode 100644 open-sse/shared/qoder/encoding.js create mode 100644 open-sse/transformer/responsesTransformer.js create mode 100644 open-sse/transformer/streamToJsonConverter.js create mode 100644 open-sse/translator/concerns/chunk.js create mode 100644 open-sse/translator/concerns/finishReason.js create mode 100644 open-sse/translator/concerns/image.js create mode 100644 open-sse/translator/concerns/json.js create mode 100644 open-sse/translator/concerns/message.js create mode 100644 open-sse/translator/concerns/modality.js create mode 100644 open-sse/translator/concerns/paramSupport.js create mode 100644 open-sse/translator/concerns/prefetch.js create mode 100644 open-sse/translator/concerns/reasoning.js create mode 100644 open-sse/translator/concerns/thinking.js create mode 100644 open-sse/translator/concerns/thinkingUnified.js create mode 100644 open-sse/translator/concerns/toolCall.js create mode 100644 open-sse/translator/concerns/usage.js create mode 100644 open-sse/translator/formats.js create mode 100644 open-sse/translator/formats/claude.js create mode 100644 open-sse/translator/formats/gemini.js create mode 100644 open-sse/translator/formats/maxTokens.js create mode 100644 open-sse/translator/formats/openai.js create mode 100644 open-sse/translator/formats/responsesApi.js create mode 100644 open-sse/translator/index.js create mode 100644 open-sse/translator/request/antigravity-to-openai.js create mode 100644 open-sse/translator/request/claude-to-kiro.js create mode 100644 open-sse/translator/request/claude-to-openai.js create mode 100644 open-sse/translator/request/gemini-to-openai.js create mode 100644 open-sse/translator/request/openai-responses.js create mode 100644 open-sse/translator/request/openai-to-claude.js create mode 100644 open-sse/translator/request/openai-to-commandcode.js create mode 100644 open-sse/translator/request/openai-to-cursor.js create mode 100644 open-sse/translator/request/openai-to-gemini.js create mode 100644 open-sse/translator/request/openai-to-kiro.js create mode 100644 open-sse/translator/request/openai-to-ollama.js create mode 100644 open-sse/translator/request/openai-to-vertex.js create mode 100644 open-sse/translator/response/claude-to-openai.js create mode 100644 open-sse/translator/response/commandcode-to-openai.js create mode 100644 open-sse/translator/response/cursor-to-openai.js create mode 100644 open-sse/translator/response/gemini-to-openai.js create mode 100644 open-sse/translator/response/kiro-to-claude.js create mode 100644 open-sse/translator/response/kiro-to-openai.js create mode 100644 open-sse/translator/response/ollama-to-openai.js create mode 100644 open-sse/translator/response/openai-responses.js create mode 100644 open-sse/translator/response/openai-to-antigravity.js create mode 100644 open-sse/translator/response/openai-to-claude.js create mode 100644 open-sse/translator/schema/blocks.js create mode 100644 open-sse/translator/schema/defaults.js create mode 100644 open-sse/translator/schema/finishReasons.js create mode 100644 open-sse/translator/schema/index.js create mode 100644 open-sse/translator/schema/roles.js create mode 100644 open-sse/utils/bypassHandler.js create mode 100644 open-sse/utils/claudeCloaking.js create mode 100644 open-sse/utils/claudeHeaderCache.js create mode 100644 open-sse/utils/claudeSignature.js create mode 100644 open-sse/utils/clientDetector.js create mode 100644 open-sse/utils/cursorChecksum.js create mode 100644 open-sse/utils/cursorProtobuf.js create mode 100644 open-sse/utils/debugLog.js create mode 100644 open-sse/utils/error.js create mode 100644 open-sse/utils/ollamaTransform.js create mode 100644 open-sse/utils/proxyFetch.js create mode 100644 open-sse/utils/reasoningContentInjector.js create mode 100644 open-sse/utils/requestLogger.js create mode 100644 open-sse/utils/responsesStreamHelpers.js create mode 100644 open-sse/utils/sessionManager.js create mode 100644 open-sse/utils/sse.js create mode 100644 open-sse/utils/sseConstants.js create mode 100644 open-sse/utils/stream.js create mode 100644 open-sse/utils/streamHandler.js create mode 100644 open-sse/utils/streamHelpers.js create mode 100644 open-sse/utils/toolDeduper.js create mode 100644 open-sse/utils/usageTracking.js create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 public/favicon.svg create mode 100644 public/file.svg create mode 100644 public/globe.svg create mode 100644 public/i18n/literals/ar.json create mode 100644 public/i18n/literals/bn.json create mode 100644 public/i18n/literals/cs.json create mode 100644 public/i18n/literals/da.json create mode 100644 public/i18n/literals/de.json create mode 100644 public/i18n/literals/el.json create mode 100644 public/i18n/literals/es.json create mode 100644 public/i18n/literals/fa.json create mode 100644 public/i18n/literals/fi.json create mode 100644 public/i18n/literals/fr.json create mode 100644 public/i18n/literals/he.json create mode 100644 public/i18n/literals/hi.json create mode 100644 public/i18n/literals/hu.json create mode 100644 public/i18n/literals/id.json create mode 100644 public/i18n/literals/it.json create mode 100644 public/i18n/literals/ja.json create mode 100644 public/i18n/literals/ko.json create mode 100644 public/i18n/literals/nl.json create mode 100644 public/i18n/literals/no.json create mode 100644 public/i18n/literals/pl.json create mode 100644 public/i18n/literals/pt-BR.json create mode 100644 public/i18n/literals/pt-PT.json create mode 100644 public/i18n/literals/ro.json create mode 100644 public/i18n/literals/ru.json create mode 100644 public/i18n/literals/sv.json create mode 100644 public/i18n/literals/th.json create mode 100644 public/i18n/literals/tl.json create mode 100644 public/i18n/literals/tr.json create mode 100644 public/i18n/literals/uk.json create mode 100644 public/i18n/literals/ur.json create mode 100644 public/i18n/literals/vi.json create mode 100644 public/i18n/literals/zh-CN.json create mode 100644 public/i18n/literals/zh-TW.json create mode 100644 public/icons/icon-192.svg create mode 100644 public/icons/icon-512.svg create mode 100644 public/next.svg create mode 100644 public/providers/alicode-intl.png create mode 100644 public/providers/alicode.png create mode 100644 public/providers/amp.png create mode 100644 public/providers/anthropic-m.png create mode 100644 public/providers/anthropic.png create mode 100644 public/providers/antigravity.png create mode 100644 public/providers/assemblyai.png create mode 100644 public/providers/aws-polly.png create mode 100644 public/providers/azure.png create mode 100644 public/providers/black-forest-labs.png create mode 100644 public/providers/blackbox.png create mode 100644 public/providers/brave-search.png create mode 100644 public/providers/byteplus.png create mode 100644 public/providers/cartesia.png create mode 100644 public/providers/cerebras.png create mode 100644 public/providers/chutes.png create mode 100644 public/providers/claude.png create mode 100644 public/providers/cline.png create mode 100644 public/providers/clinepass.png create mode 100644 public/providers/cloudflare-ai.png create mode 100644 public/providers/codebuddy-cn.png create mode 100644 public/providers/codex.png create mode 100644 public/providers/cohere.png create mode 100644 public/providers/comfyui.png create mode 100644 public/providers/commandcode.png create mode 100644 public/providers/continue.png create mode 100644 public/providers/copilot.png create mode 100644 public/providers/coqui.png create mode 100644 public/providers/cursor.png create mode 100644 public/providers/deepgram.png create mode 100644 public/providers/deepseek-tui.png create mode 100644 public/providers/deepseek.png create mode 100644 public/providers/droid.png create mode 100644 public/providers/edge-tts.png create mode 100644 public/providers/elevenlabs.png create mode 100644 public/providers/exa.png create mode 100644 public/providers/fal-ai.png create mode 100644 public/providers/firecrawl.png create mode 100644 public/providers/fireworks.png create mode 100644 public/providers/gemini-cli.png create mode 100644 public/providers/gemini.png create mode 100644 public/providers/github.png create mode 100644 public/providers/glm-cn.png create mode 100644 public/providers/glm.png create mode 100644 public/providers/google-pse.png create mode 100644 public/providers/google-tts.png create mode 100644 public/providers/grok-cli.png create mode 100644 public/providers/grok-web.png create mode 100644 public/providers/groq.png create mode 100644 public/providers/hermes.png create mode 100644 public/providers/huggingface.png create mode 100644 public/providers/hyperbolic.png create mode 100644 public/providers/iflow.png create mode 100644 public/providers/inworld.png create mode 100644 public/providers/jcode.png create mode 100644 public/providers/jina-ai.png create mode 100644 public/providers/jina-reader.png create mode 100644 public/providers/kilocode.png create mode 100644 public/providers/kimchi.png create mode 100644 public/providers/kimchi.svg create mode 100644 public/providers/kimi-coding.png create mode 100644 public/providers/kimi.png create mode 100644 public/providers/kiro.png create mode 100644 public/providers/linkup.png create mode 100644 public/providers/local-device.png create mode 100644 public/providers/mimo-free.png create mode 100644 public/providers/minimax-cn.png create mode 100644 public/providers/minimax.png create mode 100644 public/providers/mistral.png create mode 100644 public/providers/nanobanana.png create mode 100644 public/providers/nebius.png create mode 100644 public/providers/nvidia.png create mode 100644 public/providers/oai-cc.png create mode 100644 public/providers/oai-r.png create mode 100644 public/providers/ollama-local.png create mode 100644 public/providers/ollama.png create mode 100644 public/providers/openai.png create mode 100644 public/providers/openclaw.png create mode 100644 public/providers/opencode-go.png create mode 100644 public/providers/opencode.png create mode 100644 public/providers/openrouter.png create mode 100644 public/providers/perplexity-web.png create mode 100644 public/providers/perplexity.png create mode 100644 public/providers/playht.png create mode 100644 public/providers/qoder.png create mode 100644 public/providers/qwen.png create mode 100644 public/providers/recraft.png create mode 100644 public/providers/roo.png create mode 100644 public/providers/runwayml.png create mode 100644 public/providers/sdwebui.png create mode 100644 public/providers/searchapi.png create mode 100644 public/providers/searxng.png create mode 100644 public/providers/serper.png create mode 100644 public/providers/siliconflow.png create mode 100644 public/providers/stability-ai.png create mode 100644 public/providers/tavily.png create mode 100644 public/providers/together.png create mode 100644 public/providers/topaz.png create mode 100644 public/providers/tortoise.png create mode 100644 public/providers/vertex-partner.png create mode 100644 public/providers/vertex.png create mode 100644 public/providers/volcengine-ark.png create mode 100644 public/providers/voyage-ai.png create mode 100644 public/providers/xai.png create mode 100644 public/providers/xiaomi-mimo.png create mode 100644 public/providers/xiaomi-tokenplan.png create mode 100644 public/providers/youcom.png create mode 100644 public/sw.js create mode 100644 public/vercel.svg create mode 100644 public/window.svg create mode 100644 scripts/injectDisplayToRegistry.mjs create mode 100644 scripts/migrate-registry.mjs create mode 100644 scripts/test-combo-autoswitch.mjs create mode 100755 scripts/translate-readme.js create mode 100644 skills/9router-chat/SKILL.md create mode 100644 skills/9router-embeddings/SKILL.md create mode 100644 skills/9router-image/SKILL.md create mode 100644 skills/9router-stt/SKILL.md create mode 100644 skills/9router-tts/SKILL.md create mode 100644 skills/9router-web-fetch/SKILL.md create mode 100644 skills/9router-web-search/SKILL.md create mode 100644 skills/9router/SKILL.md create mode 100644 skills/README.md create mode 100644 src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js create mode 100644 src/app/(dashboard)/dashboard/basic-chat/page.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/[toolId]/page.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/ApiKeySelect.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/DeepSeekTuiToolCard.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/EndpointPresetControl.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/JcodeToolCard.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/MitmLinkCard.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/ToolSummaryCard.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointMatch.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/index.js create mode 100644 src/app/(dashboard)/dashboard/cli-tools/page.js create mode 100644 src/app/(dashboard)/dashboard/combos/page.js create mode 100644 src/app/(dashboard)/dashboard/console-log/ConsoleLogClient.js create mode 100644 src/app/(dashboard)/dashboard/console-log/page.js create mode 100644 src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js create mode 100644 src/app/(dashboard)/dashboard/endpoint/components/EndpointRow.js create mode 100644 src/app/(dashboard)/dashboard/endpoint/components/SecurityWarning.js create mode 100644 src/app/(dashboard)/dashboard/endpoint/components/StatusAlert.js create mode 100644 src/app/(dashboard)/dashboard/endpoint/components/Tooltip.js create mode 100644 src/app/(dashboard)/dashboard/endpoint/endpointConstants.js create mode 100644 src/app/(dashboard)/dashboard/endpoint/endpointPing.js create mode 100644 src/app/(dashboard)/dashboard/endpoint/page.js create mode 100644 src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/EmbeddingExampleCard.js create mode 100644 src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js create mode 100644 src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/SttExampleCard.js create mode 100644 src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/TtsExampleCard.js create mode 100644 src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/exampleShared.js create mode 100644 src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js create mode 100644 src/app/(dashboard)/dashboard/media-providers/[kind]/page.js create mode 100644 src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js create mode 100644 src/app/(dashboard)/dashboard/media-providers/web/page.js create mode 100644 src/app/(dashboard)/dashboard/mitm/MitmPageClient.js create mode 100644 src/app/(dashboard)/dashboard/mitm/page.js create mode 100644 src/app/(dashboard)/dashboard/page.js create mode 100644 src/app/(dashboard)/dashboard/profile/page.js create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/AddCustomModelModal.js create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/BulkImportCodexModal.js create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/CooldownTimer.js create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/EditCompatibleNodeModal.js create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/PassthroughModelsSection.js create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/page.js create mode 100644 src/app/(dashboard)/dashboard/providers/components/AddCompatibleModal.js create mode 100644 src/app/(dashboard)/dashboard/providers/components/ConnectionsCard.js create mode 100644 src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityBadge.js create mode 100644 src/app/(dashboard)/dashboard/providers/components/ModelsCard.js create mode 100644 src/app/(dashboard)/dashboard/providers/new/page.js create mode 100644 src/app/(dashboard)/dashboard/providers/page.js create mode 100644 src/app/(dashboard)/dashboard/proxy-pools/page.js create mode 100644 src/app/(dashboard)/dashboard/pxpipe/PxpipeClient.js create mode 100644 src/app/(dashboard)/dashboard/pxpipe/page.js create mode 100644 src/app/(dashboard)/dashboard/quota/page.js create mode 100644 src/app/(dashboard)/dashboard/skills/page.js create mode 100644 src/app/(dashboard)/dashboard/token-saver/TokenSaverClient.js create mode 100644 src/app/(dashboard)/dashboard/token-saver/page.js create mode 100644 src/app/(dashboard)/dashboard/translator/page.js create mode 100644 src/app/(dashboard)/dashboard/usage/components/OverviewCards.js create mode 100644 src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js create mode 100644 src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaProgressBar.js create mode 100644 src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js create mode 100644 src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js create mode 100644 src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js create mode 100644 src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js create mode 100644 src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js create mode 100644 src/app/(dashboard)/dashboard/usage/components/UsageChart.js create mode 100644 src/app/(dashboard)/dashboard/usage/components/UsageTable.js create mode 100644 src/app/(dashboard)/dashboard/usage/page.js create mode 100644 src/app/(dashboard)/layout.js create mode 100644 src/app/api/auth/login/route.js create mode 100644 src/app/api/auth/logout/route.js create mode 100644 src/app/api/auth/oidc/callback/route.js create mode 100644 src/app/api/auth/oidc/start/route.js create mode 100644 src/app/api/auth/oidc/test/route.js create mode 100644 src/app/api/auth/reset-password/route.js create mode 100644 src/app/api/auth/status/route.js create mode 100644 src/app/api/cli-tools/all-statuses/route.js create mode 100644 src/app/api/cli-tools/antigravity-mitm/alias/route.js create mode 100644 src/app/api/cli-tools/antigravity-mitm/route.js create mode 100644 src/app/api/cli-tools/claude-settings/route.js create mode 100644 src/app/api/cli-tools/cline-settings/route.js create mode 100644 src/app/api/cli-tools/codex-settings/route.js create mode 100644 src/app/api/cli-tools/copilot-settings/route.js create mode 100644 src/app/api/cli-tools/cowork-mcp-registry/route.js create mode 100644 src/app/api/cli-tools/cowork-mcp-tools/route.js create mode 100644 src/app/api/cli-tools/cowork-settings/route.js create mode 100644 src/app/api/cli-tools/deepseek-tui-settings/route.js create mode 100644 src/app/api/cli-tools/droid-settings/route.js create mode 100644 src/app/api/cli-tools/hermes-settings/route.js create mode 100644 src/app/api/cli-tools/jcode-settings/route.js create mode 100644 src/app/api/cli-tools/kilo-settings/route.js create mode 100644 src/app/api/cli-tools/openclaw-settings/route.js create mode 100644 src/app/api/cli-tools/opencode-settings/route.js create mode 100644 src/app/api/combos/[id]/route.js create mode 100644 src/app/api/combos/route.js create mode 100644 src/app/api/headroom/extras/route.js create mode 100644 src/app/api/headroom/proxy/[...path]/route.js create mode 100644 src/app/api/headroom/restart/route.js create mode 100644 src/app/api/headroom/start/route.js create mode 100644 src/app/api/headroom/status/route.js create mode 100644 src/app/api/headroom/stop/route.js create mode 100644 src/app/api/health/route.js create mode 100644 src/app/api/init/route.js create mode 100644 src/app/api/keys/[id]/route.js create mode 100644 src/app/api/keys/route.js create mode 100644 src/app/api/locale/route.js create mode 100644 src/app/api/mcp/[plugin]/message/route.js create mode 100644 src/app/api/mcp/[plugin]/sse/route.js create mode 100644 src/app/api/media-providers/tts/deepgram/voices/route.js create mode 100644 src/app/api/media-providers/tts/elevenlabs/voices/route.js create mode 100644 src/app/api/media-providers/tts/inworld/voices/route.js create mode 100644 src/app/api/media-providers/tts/minimax/voices/route.js create mode 100644 src/app/api/media-providers/tts/voices/route.js create mode 100644 src/app/api/models/alias/route.js create mode 100644 src/app/api/models/availability/route.js create mode 100644 src/app/api/models/custom/route.js create mode 100644 src/app/api/models/disabled/route.js create mode 100644 src/app/api/models/route.js create mode 100644 src/app/api/models/test/ping.js create mode 100644 src/app/api/models/test/route.js create mode 100644 src/app/api/oauth/[provider]/[action]/route.js create mode 100644 src/app/api/oauth/codex/bulk-import/route.js create mode 100644 src/app/api/oauth/codex/import-token/route.js create mode 100644 src/app/api/oauth/cursor/auto-import/route.js create mode 100644 src/app/api/oauth/cursor/import/route.js create mode 100644 src/app/api/oauth/gitlab/pat/route.js create mode 100644 src/app/api/oauth/iflow/cookie/route.js create mode 100644 src/app/api/oauth/kiro/api-key/route.js create mode 100644 src/app/api/oauth/kiro/auto-import/route.js create mode 100644 src/app/api/oauth/kiro/import-cli-proxy/route.js create mode 100644 src/app/api/oauth/kiro/import/route.js create mode 100644 src/app/api/oauth/kiro/social-authorize/route.js create mode 100644 src/app/api/oauth/kiro/social-exchange/route.js create mode 100644 src/app/api/pricing/route.js create mode 100644 src/app/api/provider-nodes/[id]/route.js create mode 100644 src/app/api/provider-nodes/route.js create mode 100644 src/app/api/provider-nodes/validate/route.js create mode 100644 src/app/api/providers/[id]/models/route.js create mode 100644 src/app/api/providers/[id]/route.js create mode 100644 src/app/api/providers/[id]/test-models/route.js create mode 100644 src/app/api/providers/[id]/test/route.js create mode 100644 src/app/api/providers/[id]/test/testUtils.js create mode 100644 src/app/api/providers/client/route.js create mode 100644 src/app/api/providers/kilo/free-models/route.js create mode 100644 src/app/api/providers/route.js create mode 100644 src/app/api/providers/suggested-models/filters.js create mode 100644 src/app/api/providers/suggested-models/route.js create mode 100644 src/app/api/providers/test-batch/route.js create mode 100644 src/app/api/providers/validate/route.js create mode 100644 src/app/api/proxy-pools/[id]/route.js create mode 100644 src/app/api/proxy-pools/[id]/test/route.js create mode 100644 src/app/api/proxy-pools/cloudflare-deploy/route.js create mode 100644 src/app/api/proxy-pools/deno-deploy/route.js create mode 100644 src/app/api/proxy-pools/route.js create mode 100644 src/app/api/proxy-pools/vercel-deploy/route.js create mode 100644 src/app/api/pxpipe/health/route.js create mode 100644 src/app/api/pxpipe/install/route.js create mode 100644 src/app/api/pxpipe/logs/route.js create mode 100644 src/app/api/pxpipe/restart/route.js create mode 100644 src/app/api/pxpipe/start/route.js create mode 100644 src/app/api/pxpipe/stats/route.js create mode 100644 src/app/api/pxpipe/status/route.js create mode 100644 src/app/api/pxpipe/stop/route.js create mode 100644 src/app/api/settings/database/route.js create mode 100644 src/app/api/settings/proxy-test/route.js create mode 100644 src/app/api/settings/require-login/route.js create mode 100644 src/app/api/settings/route.js create mode 100644 src/app/api/shutdown/route.js create mode 100644 src/app/api/tags/route.js create mode 100644 src/app/api/translator/console-logs/route.js create mode 100644 src/app/api/translator/console-logs/stream/route.js create mode 100644 src/app/api/translator/load/route.js create mode 100644 src/app/api/translator/save/route.js create mode 100644 src/app/api/translator/send/route.js create mode 100644 src/app/api/translator/translate/route.js create mode 100644 src/app/api/tunnel/disable/route.js create mode 100644 src/app/api/tunnel/enable/route.js create mode 100644 src/app/api/tunnel/status/route.js create mode 100644 src/app/api/tunnel/tailscale-check/route.js create mode 100644 src/app/api/tunnel/tailscale-disable/route.js create mode 100644 src/app/api/tunnel/tailscale-enable/route.js create mode 100644 src/app/api/tunnel/tailscale-install/route.js create mode 100644 src/app/api/usage/[connectionId]/codex-reset-credits/route.js create mode 100644 src/app/api/usage/[connectionId]/route.js create mode 100644 src/app/api/usage/chart/route.js create mode 100644 src/app/api/usage/history/route.js create mode 100644 src/app/api/usage/logs/route.js create mode 100644 src/app/api/usage/providers/route.js create mode 100644 src/app/api/usage/request-details/route.js create mode 100644 src/app/api/usage/request-logs/route.js create mode 100644 src/app/api/usage/stats/route.js create mode 100644 src/app/api/usage/stream/route.js create mode 100644 src/app/api/v1/api/chat/route.js create mode 100644 src/app/api/v1/audio/speech/route.js create mode 100644 src/app/api/v1/audio/transcriptions/route.js create mode 100644 src/app/api/v1/audio/voices/route.js create mode 100644 src/app/api/v1/chat/completions/route.js create mode 100644 src/app/api/v1/embeddings/route.js create mode 100644 src/app/api/v1/images/generations/route.js create mode 100644 src/app/api/v1/messages/count_tokens/route.js create mode 100644 src/app/api/v1/messages/route.js create mode 100644 src/app/api/v1/models/[kind]/route.js create mode 100644 src/app/api/v1/models/info/route.js create mode 100644 src/app/api/v1/models/route.js create mode 100644 src/app/api/v1/responses/compact/route.js create mode 100644 src/app/api/v1/responses/route.js create mode 100644 src/app/api/v1/route.js create mode 100644 src/app/api/v1/search/route.js create mode 100644 src/app/api/v1/web/fetch/route.js create mode 100644 src/app/api/v1beta/models/[...path]/route.js create mode 100644 src/app/api/v1beta/models/route.js create mode 100644 src/app/api/version/route.js create mode 100644 src/app/api/version/shutdown/route.js create mode 100644 src/app/api/version/update/route.js create mode 100644 src/app/callback/page.js create mode 100644 src/app/dashboard/settings/pricing/page.js create mode 100644 src/app/favicon.ico create mode 100644 src/app/globals.css create mode 100644 src/app/landing/components/AnimatedBackground.js create mode 100644 src/app/landing/components/Features.js create mode 100644 src/app/landing/components/FlowAnimation.js create mode 100644 src/app/landing/components/Footer.js create mode 100644 src/app/landing/components/GetStarted.js create mode 100644 src/app/landing/components/HeroSection.js create mode 100644 src/app/landing/components/HowItWorks.js create mode 100644 src/app/landing/components/Navigation.js create mode 100644 src/app/landing/page.js create mode 100644 src/app/layout.js create mode 100644 src/app/login/page.js create mode 100644 src/app/manifest.js create mode 100644 src/app/page.js create mode 100644 src/dashboardGuard.js create mode 100644 src/i18n/RuntimeI18nProvider.js create mode 100644 src/i18n/config.js create mode 100644 src/i18n/runtime.js create mode 100644 src/lib/appUpdater.js create mode 100644 src/lib/auth/dashboardSession.js create mode 100644 src/lib/auth/loginLimiter.js create mode 100644 src/lib/auth/oidc.js create mode 100644 src/lib/consoleLogBuffer.js create mode 100644 src/lib/dataDir.js create mode 100644 src/lib/db/adapters/betterSqliteAdapter.js create mode 100644 src/lib/db/adapters/bunSqliteAdapter.js create mode 100644 src/lib/db/adapters/nodeSqliteAdapter.js create mode 100644 src/lib/db/adapters/sqljsAdapter.js create mode 100644 src/lib/db/backup.js create mode 100644 src/lib/db/driver.js create mode 100644 src/lib/db/helpers/jsonCol.js create mode 100644 src/lib/db/helpers/kvStore.js create mode 100644 src/lib/db/helpers/metaStore.js create mode 100644 src/lib/db/index.js create mode 100644 src/lib/db/migrate.js create mode 100644 src/lib/db/migrations/001-initial.js create mode 100644 src/lib/db/migrations/index.js create mode 100644 src/lib/db/paths.js create mode 100644 src/lib/db/repos/aliasRepo.js create mode 100644 src/lib/db/repos/apiKeysRepo.js create mode 100644 src/lib/db/repos/combosRepo.js create mode 100644 src/lib/db/repos/connectionsRepo.js create mode 100644 src/lib/db/repos/disabledModelsRepo.js create mode 100644 src/lib/db/repos/nodesRepo.js create mode 100644 src/lib/db/repos/pricingRepo.js create mode 100644 src/lib/db/repos/proxyPoolsRepo.js create mode 100644 src/lib/db/repos/requestDetailsRepo.js create mode 100644 src/lib/db/repos/settingsRepo.js create mode 100644 src/lib/db/repos/usageRepo.js create mode 100644 src/lib/db/schema.js create mode 100644 src/lib/db/version.js create mode 100644 src/lib/disabledModelsDb.js create mode 100644 src/lib/headroom/detect.js create mode 100644 src/lib/headroom/process.js create mode 100644 src/lib/localDb.js create mode 100644 src/lib/mcp/stdioSseBridge.js create mode 100644 src/lib/mitmAliasCache.js create mode 100644 src/lib/network/connectionProxy.js create mode 100644 src/lib/network/initOutboundProxy.js create mode 100644 src/lib/network/outboundProxy.js create mode 100644 src/lib/network/proxyTest.js create mode 100644 src/lib/oauth/constants/oauth.js create mode 100644 src/lib/oauth/constants/xai.js create mode 100644 src/lib/oauth/kiroExternalIdp.js create mode 100644 src/lib/oauth/providerHelpers.js create mode 100644 src/lib/oauth/providers.js create mode 100644 src/lib/oauth/services/antigravity.js create mode 100644 src/lib/oauth/services/claude.js create mode 100644 src/lib/oauth/services/codex.js create mode 100644 src/lib/oauth/services/cursor.js create mode 100644 src/lib/oauth/services/gemini.js create mode 100644 src/lib/oauth/services/github.js create mode 100644 src/lib/oauth/services/iflow.js create mode 100644 src/lib/oauth/services/index.js create mode 100644 src/lib/oauth/services/kimchi.js create mode 100644 src/lib/oauth/services/kiro.js create mode 100644 src/lib/oauth/services/oauth.js create mode 100644 src/lib/oauth/services/openai.js create mode 100644 src/lib/oauth/services/qoder.js create mode 100644 src/lib/oauth/services/qwen.js create mode 100644 src/lib/oauth/services/xai.js create mode 100644 src/lib/oauth/utils/banner.js create mode 100644 src/lib/oauth/utils/pkce.js create mode 100644 src/lib/oauth/utils/server.js create mode 100644 src/lib/oauth/utils/ui.js create mode 100644 src/lib/providerNormalization.js create mode 100644 src/lib/pxpipe/events.js create mode 100644 src/lib/pxpipe/install.js create mode 100644 src/lib/pxpipe/loader.js create mode 100644 src/lib/pxpipe/service.js create mode 100644 src/lib/qoder/constants.js create mode 100644 src/lib/qoder/cosy.js create mode 100644 src/lib/qoder/encoding.js create mode 100644 src/lib/requestDetailsDb.js create mode 100644 src/lib/tunnel/cloudflare/cloudflared.js create mode 100644 src/lib/tunnel/cloudflare/config.js create mode 100644 src/lib/tunnel/cloudflare/healthCheck.js create mode 100644 src/lib/tunnel/cloudflare/manager.js create mode 100644 src/lib/tunnel/cloudflare/pid.js create mode 100644 src/lib/tunnel/index.js create mode 100644 src/lib/tunnel/shared/dnsResolver.js create mode 100644 src/lib/tunnel/shared/internetCheck.js create mode 100644 src/lib/tunnel/shared/state.js create mode 100644 src/lib/tunnel/shared/watchdogConfig.js create mode 100644 src/lib/tunnel/tailscale/config.js create mode 100644 src/lib/tunnel/tailscale/healthCheck.js create mode 100644 src/lib/tunnel/tailscale/manager.js create mode 100644 src/lib/tunnel/tailscale/tailscale.js create mode 100644 src/lib/updater/updater.js create mode 100644 src/lib/usageDb.js create mode 100644 src/mitm/antigravityIdeVersion.js create mode 100644 src/mitm/cert/generate.js create mode 100644 src/mitm/cert/install.js create mode 100644 src/mitm/cert/rootCA.js create mode 100644 src/mitm/config.js create mode 100644 src/mitm/dbReader.js create mode 100644 src/mitm/dns/dnsConfig.js create mode 100644 src/mitm/handlers/antigravity.js create mode 100644 src/mitm/handlers/base.js create mode 100644 src/mitm/handlers/copilot.js create mode 100644 src/mitm/handlers/cursor.js create mode 100644 src/mitm/handlers/kiro.js create mode 100644 src/mitm/logger.js create mode 100644 src/mitm/manager.js create mode 100644 src/mitm/paths.js create mode 100644 src/mitm/server.js create mode 100644 src/mitm/winElevated.js create mode 100644 src/models/index.js create mode 100644 src/proxy.js create mode 100644 src/shared/components/AddCustomEmbeddingModal.js create mode 100644 src/shared/components/Avatar.js create mode 100644 src/shared/components/Badge.js create mode 100644 src/shared/components/Button.js create mode 100644 src/shared/components/CapacityBadges.js create mode 100644 src/shared/components/Card.js create mode 100644 src/shared/components/ChangelogModal.js create mode 100644 src/shared/components/ComboFormModal.js create mode 100644 src/shared/components/CursorAuthModal.js create mode 100644 src/shared/components/DonateModal.js create mode 100644 src/shared/components/Drawer.js create mode 100644 src/shared/components/EditConnectionModal.js create mode 100644 src/shared/components/Footer.js create mode 100644 src/shared/components/GitLabAuthModal.js create mode 100644 src/shared/components/Header.js create mode 100644 src/shared/components/HeaderLanguage.js create mode 100644 src/shared/components/HeaderMenu.js create mode 100644 src/shared/components/IFlowCookieModal.js create mode 100644 src/shared/components/Input.js create mode 100644 src/shared/components/KiroAuthModal.js create mode 100644 src/shared/components/KiroOAuthWrapper.js create mode 100644 src/shared/components/KiroSocialOAuthModal.js create mode 100644 src/shared/components/LanguageSwitcher.js create mode 100644 src/shared/components/Loading.js create mode 100644 src/shared/components/ManualConfigModal.js create mode 100644 src/shared/components/McpMarketplaceModal.js create mode 100644 src/shared/components/Modal.js create mode 100644 src/shared/components/ModelSelectModal.js create mode 100644 src/shared/components/NineRemoteButton.js create mode 100644 src/shared/components/NineRemotePromoModal.js create mode 100644 src/shared/components/NoAuthProxyCard.js create mode 100644 src/shared/components/OAuthModal.js create mode 100644 src/shared/components/Pagination.js create mode 100644 src/shared/components/PricingModal.js create mode 100644 src/shared/components/ProviderIcon.js create mode 100644 src/shared/components/ProviderInfoCard.js create mode 100644 src/shared/components/RequestLogger.js create mode 100644 src/shared/components/SegmentedControl.js create mode 100644 src/shared/components/Select.js create mode 100644 src/shared/components/Sidebar.js create mode 100644 src/shared/components/ThemeProvider.js create mode 100644 src/shared/components/ThemeToggle.js create mode 100644 src/shared/components/Toggle.js create mode 100644 src/shared/components/Tooltip.js create mode 100644 src/shared/components/UsageStats.js create mode 100644 src/shared/components/index.js create mode 100644 src/shared/components/layouts/AuthLayout.js create mode 100644 src/shared/components/layouts/DashboardLayout.js create mode 100644 src/shared/components/layouts/index.js create mode 100644 src/shared/constants/cliTools.js create mode 100644 src/shared/constants/colors.js create mode 100644 src/shared/constants/config.js create mode 100644 src/shared/constants/coworkPlugins.js create mode 100644 src/shared/constants/index.js create mode 100644 src/shared/constants/locales.js create mode 100644 src/shared/constants/mitmToolHosts.js create mode 100644 src/shared/constants/models.js create mode 100644 src/shared/constants/providers.js create mode 100644 src/shared/constants/providersDisplay.js create mode 100644 src/shared/constants/skills.js create mode 100644 src/shared/constants/ttsProviders.js create mode 100644 src/shared/hooks/index.js create mode 100644 src/shared/hooks/useCopyToClipboard.js create mode 100644 src/shared/hooks/useModelCaps.js create mode 100644 src/shared/hooks/useTheme.js create mode 100644 src/shared/services/bootstrap.js create mode 100644 src/shared/services/initializeApp.js create mode 100644 src/shared/services/quotaAutoPing.js create mode 100644 src/shared/utils/api.js create mode 100644 src/shared/utils/apiKey.js create mode 100644 src/shared/utils/clineAuth.js create mode 100644 src/shared/utils/cn.js create mode 100644 src/shared/utils/connectionStatus.js create mode 100644 src/shared/utils/index.js create mode 100644 src/shared/utils/machine.js create mode 100644 src/shared/utils/machineId.js create mode 100644 src/shared/utils/providerCustomModels.js create mode 100644 src/shared/utils/providerModelsFetcher.js create mode 100644 src/shared/utils/ssrfGuard.js create mode 100644 src/sse/handlers/chat.js create mode 100644 src/sse/handlers/embeddings.js create mode 100644 src/sse/handlers/fetch.js create mode 100644 src/sse/handlers/imageGeneration.js create mode 100644 src/sse/handlers/search.js create mode 100644 src/sse/handlers/stt.js create mode 100644 src/sse/handlers/tts.js create mode 100644 src/sse/services/auth.js create mode 100644 src/sse/services/model.js create mode 100644 src/sse/services/tokenRefresh.js create mode 100644 src/sse/utils/logger.js create mode 100644 src/store/headerSearchStore.js create mode 100644 src/store/index.js create mode 100644 src/store/notificationStore.js create mode 100644 src/store/providerStore.js create mode 100644 src/store/settingsStore.js create mode 100644 src/store/themeStore.js create mode 100644 src/store/userStore.js create mode 100755 start.sh create mode 100644 tests/.gitignore create mode 100644 tests/README.md create mode 100644 tests/__baseline__/alias-baseline.json create mode 100644 tests/__baseline__/baseline-results.json create mode 100644 tests/__baseline__/current.json create mode 100644 tests/__baseline__/known-fails.txt create mode 100644 tests/__baseline__/oauth-urls-baseline.json create mode 100644 tests/__baseline__/providers-baseline.json create mode 100644 tests/__baseline__/snapshot-providers.mjs create mode 100644 tests/__baseline__/verify-alias.mjs create mode 100644 tests/__baseline__/verify-no-regression.mjs create mode 100644 tests/__baseline__/verify-oauth-urls.mjs create mode 100644 tests/__baseline__/verify-providers.mjs create mode 100644 tests/package.json create mode 100644 tests/translator/AGENTS.md create mode 100644 tests/translator/__snapshots__/golden-request.test.js.snap create mode 100644 tests/translator/__snapshots__/golden-response-stream.test.js.snap create mode 100644 tests/translator/__snapshots__/golden-translator-concerns.test.js.snap create mode 100644 tests/translator/__snapshots__/golden-url-header.test.js.snap create mode 100644 tests/translator/bugs-antigravity.test.js create mode 100644 tests/translator/bugs-claudeCode-context.test.js create mode 100644 tests/translator/bugs-codexCli-responses.test.js create mode 100644 tests/translator/bugs-gemini-cursor-commandcode.test.js create mode 100644 tests/translator/bugs-kiro.test.js create mode 100644 tests/translator/bugs-openai-bridge.test.js create mode 100644 tests/translator/bugs-toClaude-context.test.js create mode 100644 tests/translator/claude-kiro-direct.test.js create mode 100644 tests/translator/coverage-all-models.test.js create mode 100644 tests/translator/format-roundtrip.test.js create mode 100644 tests/translator/golden-request.test.js create mode 100644 tests/translator/golden-response-stream.test.js create mode 100644 tests/translator/golden-translator-concerns.test.js create mode 100644 tests/translator/golden-url-header.test.js create mode 100644 tests/translator/matrix.js create mode 100644 tests/translator/real/all-formats.real.test.js create mode 100644 tests/translator/real/antigravity-models.real.test.js create mode 100644 tests/translator/real/file-base64-survey.real.test.js create mode 100644 tests/translator/real/nvidia-thinking.e2e.test.js create mode 100644 tests/translator/real/provider-cases.real.test.js create mode 100644 tests/translator/real/smoke-providers.real.test.js create mode 100644 tests/translator/real/thinking.real.test.js create mode 100644 tests/translator/real/vision-capability-survey.real.test.js create mode 100644 tests/translator/registerAll.js create mode 100644 tests/translator/thinking-unified.test.js create mode 100644 tests/unit/alicode-cache-control-2069.test.js create mode 100644 tests/unit/antigravity-cache.test.js create mode 100644 tests/unit/antigravity-mitm.test.js create mode 100644 tests/unit/antigravity-oauth-client.test.js create mode 100644 tests/unit/antigravity-retry-hook.test.js create mode 100644 tests/unit/antigravity-usage-headers.test.js create mode 100644 tests/unit/base-executor-retry.test.js create mode 100644 tests/unit/buildOutputFilter.test.js create mode 100644 tests/unit/buildOutputFilterAdversarial.test.js create mode 100644 tests/unit/cached-token-e2e.test.js create mode 100644 tests/unit/cached-token-usage.test.js create mode 100644 tests/unit/capabilities-opus-context.test.js create mode 100644 tests/unit/capabilities-service-kind.test.js create mode 100644 tests/unit/capabilities.test.js create mode 100644 tests/unit/caveman-prompts.test.js create mode 100644 tests/unit/claude-cloaking.test.js create mode 100644 tests/unit/claude-header-forwarding.test.js create mode 100644 tests/unit/codebuddy-cn-bonus-recurring.test.js create mode 100644 tests/unit/codebuddy-reasoning-optin.test.js create mode 100644 tests/unit/codex-fast-capacity.test.js create mode 100644 tests/unit/codex-image-fetch.test.js create mode 100644 tests/unit/codex-refresh-token.test.js create mode 100644 tests/unit/codex-reset-credits.test.js create mode 100644 tests/unit/codex-tool-normalization.test.js create mode 100644 tests/unit/combo-autoswitch.test.js create mode 100644 tests/unit/combo-fusion.test.js create mode 100644 tests/unit/combo-routing.test.js create mode 100644 tests/unit/commandcode-to-openai.test.js create mode 100644 tests/unit/compatible-provider-connections.test.js create mode 100644 tests/unit/count-tokens.test.js create mode 100644 tests/unit/cursor-composer-thinking.test.js create mode 100644 tests/unit/dashboard-guard.test.js create mode 100644 tests/unit/db-benchmark.test.js create mode 100644 tests/unit/db-concurrent.test.js create mode 100644 tests/unit/db-driver-chain.test.js create mode 100644 tests/unit/db-migration-chain.test.js create mode 100644 tests/unit/db-sqlite-vs-lowdb.test.js create mode 100644 tests/unit/embeddings.cloud.test.js create mode 100644 tests/unit/embeddingsCore.test.js create mode 100644 tests/unit/executor-const-guard.test.js create mode 100644 tests/unit/file-block-routing.test.js create mode 100644 tests/unit/finish-reason-concern.test.js create mode 100644 tests/unit/force-stream-config.test.js create mode 100644 tests/unit/gemini-native-endpoint.test.js create mode 100644 tests/unit/gemini-tts.test.js create mode 100644 tests/unit/gemini-usage-projectid.test.js create mode 100644 tests/unit/github-responses-routing.test.js create mode 100644 tests/unit/grok-cli-executor.test.js create mode 100644 tests/unit/grok-cli-oauth-probe.test.js create mode 100644 tests/unit/grok-cli-usage.test.js create mode 100644 tests/unit/headroom-chat-core.test.js create mode 100644 tests/unit/headroom-detect.test.js create mode 100644 tests/unit/headroom-responses-format.test.js create mode 100644 tests/unit/headroom.test.js create mode 100644 tests/unit/hf-model-routing.test.js create mode 100644 tests/unit/image-fetch-hardening.test.js create mode 100644 tests/unit/image-generation.test.js create mode 100644 tests/unit/kimchi-strip-reasoning.test.js create mode 100644 tests/unit/kimchi.test.js create mode 100644 tests/unit/kiro-external-idp.test.js create mode 100644 tests/unit/kiro-model-slots.test.js create mode 100644 tests/unit/kiro-profile-arn.test.js create mode 100644 tests/unit/kiro-thinking-strip.test.js create mode 100644 tests/unit/mimo-free.live.test.js create mode 100644 tests/unit/mimo-free.test.js create mode 100644 tests/unit/minimax-tts.test.js create mode 100644 tests/unit/minimax-usage.test.js create mode 100644 tests/unit/minimax-voices.test.js create mode 100644 tests/unit/mitm-root-ca.test.js create mode 100644 tests/unit/modality-strip.test.js create mode 100644 tests/unit/model-name-regex.test.js create mode 100644 tests/unit/model-routing.test.js create mode 100644 tests/unit/model-test-routing.test.js create mode 100644 tests/unit/multimodal-drop-lock.test.js create mode 100644 tests/unit/oauth-cursor-auto-import.test.js create mode 100644 tests/unit/openai-responses-multiturn.test.js create mode 100644 tests/unit/openai-responses-terminal-event.test.js create mode 100644 tests/unit/openai-to-claude-response-tools.test.js create mode 100644 tests/unit/openai-to-claude-tools-no-type.test.js create mode 100644 tests/unit/openai-to-claude.test.js create mode 100644 tests/unit/openai-to-commandcode.test.js create mode 100644 tests/unit/openai-to-kiro.test.js create mode 100644 tests/unit/openai-to-ollama-malformed.test.js create mode 100644 tests/unit/opencode-go-models.test.js create mode 100644 tests/unit/param-support.test.js create mode 100644 tests/unit/perplexity-web.test.js create mode 100644 tests/unit/prefetch-images.test.js create mode 100644 tests/unit/provider-custom-models.test.js create mode 100644 tests/unit/provider-display-split.test.js create mode 100644 tests/unit/provider-models-minimax-m3.test.js create mode 100644 tests/unit/provider-pricing-minimax-m3.test.js create mode 100644 tests/unit/provider-test-models-routing.test.js create mode 100644 tests/unit/provider-thinking-config.test.js create mode 100644 tests/unit/provider-validation.test.js create mode 100644 tests/unit/pxpipe.test.js create mode 100644 tests/unit/qoder.test.js create mode 100644 tests/unit/quota-auto-ping.test.js create mode 100644 tests/unit/reasoningContentInjector.test.js create mode 100644 tests/unit/request-details-tab.test.js create mode 100644 tests/unit/responses-abort-terminal.test.js create mode 100644 tests/unit/rtk.e2e.test.js create mode 100644 tests/unit/rtk.multi-provider.e2e.test.js create mode 100644 tests/unit/rtk.test.js create mode 100644 tests/unit/rtkFindWindows.test.js create mode 100644 tests/unit/rtkKiro.test.js create mode 100644 tests/unit/searxng-provider-config.test.js create mode 100644 tests/unit/security-audit.test.js create mode 100644 tests/unit/session-manager.test.js create mode 100644 tests/unit/thinking-effort-openai-max-clamp.test.js create mode 100644 tests/unit/thinking-levels-gpt56-sol.test.js create mode 100644 tests/unit/token-refresh-dispatch.test.js create mode 100644 tests/unit/translator-custom-prefix.test.js create mode 100644 tests/unit/translator-helpers-edge.test.js create mode 100644 tests/unit/translator-request-normalization.test.js create mode 100644 tests/unit/usage-concern.test.js create mode 100644 tests/unit/usage-dispatch.test.js create mode 100644 tests/unit/venice-provider.test.js create mode 100644 tests/unit/web-cookie-validation.test.js create mode 100644 tests/unit/xai-oauth-service.test.js create mode 100644 tests/unit/xai-tokenRefresh.test.js create mode 100644 tests/vitest.config.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5b921cd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,32 @@ +# VCS +.git +**/.git + +# Editor +.vscode +**/.vscode + +# Dependencies and build output +node_modules +.next +out +build +dist +coverage + +# Runtime data and logs +data +logs + +# Local env files (inject at runtime via --env-file or -e) +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Debug logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6ed8f81 --- /dev/null +++ b/.env.example @@ -0,0 +1,41 @@ +# 9Router environment contract +# This file reflects actual runtime usage in the current codebase. + +# Required +JWT_SECRET=change-me-to-a-long-random-secret +INITIAL_PASSWORD=change-me +DATA_DIR=/var/lib/9router + +# Recommended runtime variables +PORT=20128 +NODE_ENV=production + +# Recommended security and ops variables +API_KEY_SECRET=endpoint-proxy-api-key-secret +MACHINE_ID_SALT=endpoint-proxy-salt +ENABLE_REQUEST_LOGS=false +OBSERVABILITY_ENABLED=true +AUTH_COOKIE_SECURE=false +REQUIRE_API_KEY=false + +# Cloud sync variables +# Must point to this running instance so internal sync jobs can call /api/sync/cloud. +# Server-side preferred variables: +BASE_URL=http://localhost:20128 +CLOUD_URL=https://9router.com +# Backward-compatible/public variables: +NEXT_PUBLIC_BASE_URL=http://localhost:20128 +NEXT_PUBLIC_CLOUD_URL=https://9router.com + +# Optional outbound proxy variables for upstream provider calls +# Lowercase variants are also supported: http_proxy, https_proxy, all_proxy, no_proxy +# HTTP_PROXY=http://127.0.0.1:7890 +# HTTPS_PROXY=http://127.0.0.1:7890 +# ALL_PROXY=socks5://127.0.0.1:7890 +# NO_PROXY=localhost,127.0.0.1 + +# Optional SearXNG endpoint for the built-in unauthenticated web-search provider. +# SEARXNG_URL=http://searxng:8080/search + +# Currently unused by application runtime (kept as reference) +# INSTANCE_NAME=9router diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e180032 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,2 @@ +version: 2 +updates: [] diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..e8ef957 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,60 @@ +name: Build and Push Docker Image + +on: + push: + tags: + - "v*" + workflow_dispatch: + +env: + GHCR_IMAGE: ghcr.io/${{ github.repository }} + DOCKERHUB_IMAGE: decolua/9router + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ${{ env.GHCR_IMAGE }} + ${{ env.DOCKERHUB_IMAGE }} + tags: | + type=semver,pattern={{version}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=${{ env.GHCR_IMAGE }}:buildcache + cache-to: type=registry,ref=${{ env.GHCR_IMAGE }}:buildcache,mode=max + platforms: linux/amd64,linux/arm64 + provenance: false + sbom: false diff --git a/.github/workflows/gitbook-pages.yml b/.github/workflows/gitbook-pages.yml new file mode 100644 index 0000000..797e1dc --- /dev/null +++ b/.github/workflows/gitbook-pages.yml @@ -0,0 +1,50 @@ +name: Deploy GitBook to 9router.github.io + +on: + push: + branches: [main, master] + paths: + - "gitbook/**" + - ".github/workflows/gitbook-pages.yml" + workflow_dispatch: + +concurrency: + group: gitbook-pages + cancel-in-progress: true + +jobs: + build-deploy: + runs-on: ubuntu-latest + defaults: + run: + working-directory: gitbook + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Install deps + run: npm install --no-audit --no-fund + + - name: Build static export + run: npm run build + env: + NODE_ENV: production + NEXT_PUBLIC_BASE_PATH: "" + + - name: Add .nojekyll + run: touch out/.nojekyll + + - name: Deploy to 9router.github.io + uses: peaceiris/actions-gh-pages@v4 + with: + deploy_key: ${{ secrets.GH_PAGES_DEPLOY_KEY }} + external_repository: 9router/9router.github.io + publish_branch: main + publish_dir: gitbook/out + force_orphan: true + user_name: github-actions[bot] + user_email: github-actions[bot]@users.noreply.github.com + commit_message: "deploy: ${{ github.sha }}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd7c67f --- /dev/null +++ b/.gitignore @@ -0,0 +1,80 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/.next-cli-build/ +/out/ +cli/.build-home/ +product +# production +/build +.idea/ + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +.bin/* +data/ +logs/* +source/* +.cursor/* +docs/* +!docs/ARCHITECTURE.md +test/* +open-sse/test/* +RM.vn.md +RM.md +cursor/* +PUBLIC.md +Thanks.md +PUBLIC.en.md +PR/* +package-lock.json + + +#Ignore vscode AI rules +.github/instructions/codacy.instructions.md +README1.md +deploy*.sh +ecosystem.config.* + +scripts/agSniffer/* +gitbooks/* +gitbook/README.md + +# Refactor backup reference (do not bundle/lint) +open-sse.old/ +.graphifyignore +graphify-out/* +.next-analyze/* diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..24e8f32 --- /dev/null +++ b/.npmignore @@ -0,0 +1,31 @@ +# Database files - NEVER publish +data/ +**/data/ +**/db.json + +# Development +src/ +docs/ +test/ +agents/ +scripts/ +worker/ +shared-sse/ +copilot-api/ +CLIProxyAPI/ + +# Config files +*.md +!README.md +.gitignore +.env* +jsconfig.json +eslint.config.mjs +postcss.config.mjs +next.config.mjs +tsconfig.json + +# Build artifacts that shouldn't be published +.next/cache/ +.next/standalone/data/ + diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8e3d0f1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,20 @@ +{ + "css.lint.unknownAtRules": "ignore", + "sonarlint.rules": { + "css:S4662": { + "level": "off" + }, + "javascript:S6747": { + "level": "off" + }, + "javascript:S7764": { + "level": "off" + }, + "javascript:S6772": { + "level": "off" + }, + "javascript:S3776": { + "level": "off" + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c6ba213 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,380 @@ +# v0.5.30 (2026-07-10) + +## Features +- **Perplexity**: add Agent API provider (#2492) +- **Grok CLI**: add Grok CLI / Grok Build provider with OAuth device-code flow (#2502) +- **Featherless**: add OpenAI-compatible provider presets +- **SearXNG**: configure endpoint via SEARXNG_URL env (#2499) +- **Providers**: add max thinking level for gpt-5.6-sol (#2500) +- **Headroom**: add extras detection and install UI (#2403) +- **Headroom**: activate/uninstall extras + fix interpreter detection +- **PXPipe**: PXPIPE token saver — multimodal prompt compression (#2465) +- **Proxy-Pools**: auto-rotate strategy for no-auth providers (#2409) + +## Fixes +- **Cloudflare-AI**: support accountId in bulk key import (#2449) +- **DB**: backup on schema change, MCP child cleanup, codex models, usage providers OOM +- **Codex**: avoid bare-email OAuth dedup (#2477) +- **CLI**: allow staged app bundle builds (#2479) +- **Headroom**: compress Kiro conversation state (#2488) +- **Gemini-CLI**: raise output floor for thinking and add validated toolConfig (#2486) +- **GitHub**: label Copilot profiles by account identity (#2498) +- **OpenAI-to-Claude**: unwrap bare {function:{…}} tools without parent type (#2473) +- **Translator**: clamp thinking effort max->xhigh for OpenAI format (#2466) +- **RTK/find**: detect and group Windows backslash-style find output (#2448) +- **Codex**: handle fast tier and capacity SSE (#2452) +- **Volcengine-ark**: clamp Kimi max_tokens to 32768 endpoint cap +- **Antigravity**: align provider fingerprint with IDE Desktop 2.1.1 (#2389) +- **Pricing**: update Claude/Codex model rates and add new models + +## Improvements +- **i18n(zh-CN)**: complete Chinese translations for all UI strings (#2436) +- **API**: caching for tunnel and version status endpoints +- **Perf**: faster dev startup and lighter bundle + +# v0.5.20 (2026-07-07) + +## Features +- **Thinking**: per-model thinking level picker on provider page — appends `(level)` suffix to copied model names for forced reasoning effort across all formats (openai, claude, gemini, deepseek, kimi, qwen, zai, minimax, hunyuan, step) +- **RTK**: add JS-native git-log filter (#2423) +- **Caveman**: add targeted upstream-aligned style rules (#2424) +- **i18n**: add Farsi (fa) language support (#2385) + +## Fixes +- **Thinking**: strip `(level)` suffix from upstream `body.model` so providers no longer reject requests +- **Translator**: preserve developer instructions in openai-responses conversion (#2434) +- **count_tokens**: count structured Anthropic blocks (#2419) +- **Volcengine-ark**: clamp GLM-5 max_tokens to model output ceiling (#2428) +- **Kimi**: normalize reasoning_effort to backend enum (#2427) +- **Claude**: reconcile max_tokens vs thinking budget and lift per-model ceiling (#2381) +- **Kiro**: deliver system prompt natively, add Opus 4.5/4.7/4.8, tolerate dash version ids (#2366) +- **Headroom**: proxy dashboard through app (#2372) +- **MITM**: recover from stale lock file on server start + +# v0.5.18 (2026-07-03) + +## Features +- **Usage**: track cached tokens + correct input/output/cache cost (#2209) — hodtien +- **Codex**: show reset credit expiry details (#2290) — Rafli Ahmad Zulfikar +- **NVIDIA**: add new models and capabilities — decolua +- **ClinePass**: add provider support — sternelee + +## Fixes +- **Usage**: dedupe streaming request-details log entries — Qin Li +- **Claude**: drop foreign thinking signatures in passthrough — decolua +- Prevent non-SSE stream pipe crash and cross-IdP account overwrites (#2244) — KunN-21 +- **Kiro**: route IdC auth to regional CodeWhisperer surface (#2297) — Volodymyr Saakian +- **Kiro**: add Claude Sonnet 5 model support (#2264) — Edison42 +- **Xiaomi-tokenplan**: region selector, key validation, multi-connection (#2251) — MiQieR +- **Translator**: strict Anthropic content block compliance (#2225) — Sahrul Ramadhan Hardiansyah +- **Kimchi**: strip reasoning_content echo to bound multi-turn input tokens — KunN-21 +- **Kimchi**: bump User-Agent to kimchi/0.1.40 (#2256) — Ansh7473 +- **Codebuddy-cn**: strip empty tool_calls arrays to preserve reasoning — zmf +- **Antigravity**: preserve Claude tool delta index (#2223) — Sutarto Jordan Chrisfivo +- **MITM**: generate root CA on server startup (#2228) — Sutarto Jordan Chrisfivo + +# v0.5.15 (2026-06-29) + +## Features +- Add Kimchi OAuth provider — Nant361 +- Refine Qwen vision/video + thinking model patterns — decolua +- Opt-in Codex auto-ping quota keep-alive — Emirhan + +## Fixes +- **Responses**: handle response.done terminal events (#2142) — rifuki +- **Headroom**: skip unsafe responses tool history (#2132) — Sutarto Jordan Chrisfivo +- **Translator**: map mid-conversation system message to user (claude→openai) — decolua +- **Gemini**: normalize contents to prevent 400 invalid_argument (#2192) — warelik +- **Gemini**: backfill thoughtSignature + suppress stream done sentinel — WARELIK +- **Alicode**: preserve cache_control for DashScope providers (#2069) — Rex +- **Antigravity**: strip deprecated/readOnly/writeOnly from tool schemas — iletai, Yudhistira-Official +- **CodeBuddy CN**: show bonus packs as one-time, not monthly-replenishing — whale9820 +- **Kiro**: strip leaked tags from content stream (#2158) — hamsa0x7 +- **Tray**: make Windows context menu DPI-aware — Emirhan +- **Kilocode**: expose full gateway catalog in combo model picker — jellylarper +- **OpenCode**: fix Go GLM — decolua + +# v0.5.12 (2026-06-26) + +## Features +- Add token-saver dashboard page — decolua +- Add bulk delete for provider connections — teddytkz +- Resolve GitHub Copilot model catalog from upstream — caiqinzhou +- Add Venice AI provider — Brokenc0de +- Add Kiro external_idp import for Microsoft SSO (CLIProxyAPI) — Stevanus Pangau +- Overhaul Blackbox provider catalog + WebUI test support — suryacagur + +## Fixes +- Provider thinking compatibility (DeepSeek/Gemini) — Mink Nguyen +- Stop double-counting streaming usage at source — decolua +- Usage logging dedupe to reduce stats churn — Mink Nguyen +- Prevent non-JSON SSE lines / duplicate [DONE] from breaking clients (PR #2046) — qianze +- Resolve Gemini TTS models from catalog — nguyenha935 +- Support Kiro IDC (organization) token import — quanturbo +- Preserve forced streaming for JSON clients (#2031) — Joseph Yaksich +- Preserve Responses text format (Codex) — tenglong +- Support Gemini native TTS generateContent endpoint — nguyenha935 +- Add missing zh-CN endpoint key label (i18n) — weimaozhen +- CodeBuddy: only send reasoning params when client requests reasoning (#2071) — Rex +- CodeBuddy CN: show one-shot bonus packs as expiring, not monthly-replenishing +- Show custom provider models in combo picker — Sapto +- Docker: add docker-compose.yml with headroom enabled by default — nitsuahlabs +- Clarify token diagnostics vs provider billing (headroom, #1998) — Sutarto Jordan Chrisfivo +- Translate openai-responses input through OpenAI for compression (#1998) — Ankit +- Kiro: report 1M context window for claude-opus-4.8 — EdisonPVE +- Avoid stale redirects after auth changes (#2100) — Emirhan +- Mark Claude Opus 4.7 (dashed id) as 1M context — Brokenc0de +- Preserve reasoning effort through Codex translations — ntdung6868 +- Token-saver: full width card layout — decolua +- Antigravity: retry transient upstream failures — Sutarto Jordan Chrisfivo +- Param-support: handle strip rules without match/drop (#1960) — Joseph Yaksich +- Translator: resolve custom provider prefix in debug endpoint (#1083) — hamsa0x7 + +# v0.5.8 (2026-06-21) + +## Features +- **Antigravity**: native image generation support (image models tagged kind:image, hiển thị trong media-providers UI) +- **CodeBuddy CN**: API key auth + credit quota tracker +- **CodeBuddy CN**: short model prefix alias "cbcn" + +## Fixes +- **MiniMax-M3**: enable vision capability +- **Headroom**: support Docker sidecar proxy +- **Antigravity**: image executor fixes +- **mimo-free**: Chrome User-Agent rotation to bypass anti-abuse gate +- **cloudflare-ai**: flatten content-part arrays to string to avoid oneOf 400 (#1926) +- **Translator**: normalize tools to Anthropic-native shape for non-Anthropic providers +- **CLI**: handle Next.js 16 nested standalone output path (#1940) +- **Codex**: preserve custom tools during request normalization +- **next.config**: add new route for responses endpoint to API + +# v0.5.6 (2026-06-20) + +## Features +- **Ponytail**: minimalist code generation feature +- **Headroom**: proxy lifecycle management + dashboard UI (one-click start/stop, install detection, status probing, token saver, claude↔openai shape conversion) +- **CodeBuddy CN**: new OAuth provider (copilot.tencent.com) — 15-model catalog, /v2 inference, forced streaming, OpenAI-style reasoning +- **OpenCode-Go**: align models with official endpoints; route Qwen 3.7 MiniMax via /v1/messages, GLM/Kimi/DeepSeek/MiMo via /chat/completions + +## Fixes +- **Anthropic-compatible validation**: use POST /v1/messages (GET /models not spec, false "invalid" for valid keys) +- **CLI tools**: tolerate JSONC configs in all 8 settings routes (opencode, openclaw, kilo, droid, cowork, copilot, claude, cline) +- **Gemini/Antigravity**: preserve 'pattern' in tool schema translation (glob/grep) +- **Combo/Fusion**: flatten Anthropic-style tool messages in panel calls (prevent 503) +- **Models**: store provider custom models by provider scope +- **Perplexity**: use /v1/models endpoint for key validation + +# v0.5.4 (2026-06-18) + +## Fixes +- **Kiro**: honor thinking effort budgets +- **AG/Kiro/Xiaomi**: provider fixes +- **Combo/Fusion**: flatten tool history in panel calls to prevent 503 +- **LLM selector**: show custom vision models in selector and model list +- **Image**: prevent compatible nodes from shadowing provider aliases + +# v0.5.2 (2026-06-17) + +## Features +- **Combo Fusion strategy** — fans the prompt out to all member models in parallel, then a configurable judge model synthesizes one final answer (quorum-grace, anonymized sources, graceful degradation) +- **Per-combo strategy selector** — pick `fallback` / `round-robin` / `fusion` / `capacity` per combo (replaces the old round-robin toggle), with a judge picker for fusion +- **Capacity auto-switch** — reorders models per request so images/PDFs route to capable models first +- **Kiro headless API-key auth** (`ksk_`) + direct `claude↔kiro` route that avoids the lossy OpenAI two-hop pivot +- **Claude auto-ping** — warms the 5h quota window right after reset so a fresh window starts immediately (per-connection toggle) + +## Fixes +- **Claude 429**: stop hammering the OAuth usage endpoint — cache resetAt, throttle quota refresh to 3 min, cool down after a 429 (chat unaffected) +- **Usage logs always empty**: missing `await` on `getAdapter()` in `getRecentLogs` made `/api/usage/logs` & `/api/usage/request-logs` return nothing +- **Executors**: strip params unsupported by the provider/model (drops deprecated `temperature` for claude-opus-4 → Anthropic 400) +- **Translator**: derive deterministic tool_call ids for gemini/antigravity → OpenAI so function call/response pair correctly (fixes tool-pairing 400s) +- **Antigravity**: strip `optional` from tool schemas before sending to Gemini +- **Claude-to-OpenAI**: handle OpenAI-format responses in the non-streaming path (e.g. xiaomi-tokenplan) +- **Usage views**: show edited connection names consistently across Providers & Quota Tracker +- **Security**: hardened reverse-proxy local-access trust +- **Security**: SSRF hardening on web fetch + +## Internal +- Large **open-sse / translator refactor** (~40 commits): unified provider/model registry (LiteLLM-style `models[]` + `kind` field, 100 co-located registry files), single-sourced media/OAuth/refresh/token URLs, registry-based dispatch for usage & token-refresh, DRY translator concerns (buildUsage, encodeDataUri, finishReasonMap, chunkBuilder, reasoningDelta…), ESM-safe registry init, large-file splits, dead-code removal, and golden/no-regression test gates + +# v0.4.80 (2026-06-13) + +## Features +- Vercel AI Gateway: support embeddings, images and credit usage (#1183) +- Add MiMo Free no-auth provider (#1789) +- Vertex: support ADC `authorized_user` credential +- Cowork: re-enable Claude Cowork with preset-only stdio MCP +- Codex: bulk add accounts via JSON (#1719) +- Kiro: enable multi-endpoint failover for GenerateAssistantResponse (#1722) + +## Fixes +- Security: re-auth on DB export/import + SSRF guard on web fetch +- Auth: real client IP rate-limiting + remote default-password guard +- Cerebras/Mistral: strip unsupported `client_metadata` from downstream requests (#1742) +- SiliconFlow: update baseUrl `.cn` -> `.com` + curate verified model list (#1760) +- Gemini-to-OpenAI: route unsigned thought parts to `reasoning_content` (#1752) +- Claude-to-OpenAI: strip Anthropic billing header from system prompt (#1765) +- Anthropic-compatible: send Bearer auth for third-party gateways (#1795) +- Usage-stats: avoid partial stats on initial SSE race (#1767) +- Proxy: use `export default` in proxy.js for Next.js 16 middleware detection +- Claude passthrough: add body normalization +- GitHub Copilot: refresh missing/expired token on models discovery (#1727) + add mappable gpt-5-mini/gpt-5.4-nano slots for Copilot MITM (#1653) +- Kiro: auto-resolve profileArn to prevent 403 on IDC login, enhance profile ARN resolution, update endpoint to `runtime.us-east-1.kiro.dev` (#1713) +- Tunnel: detect system-installed Tailscale via dual-socket probe (#1723) + non-blocking probes to prevent UI freeze +- CommandCode: force `stream=true` in transformRequest (#1706) +- Qoder: increase timeouts for reasoning models and improve stream handling +- Dashboard: show provider node name instead of connection name in topology (#1770) + show explicit `kind="llm"` combos on combos page (#1684) + +## Docs +- README: add Indonesian 9Router tutorial video (#1709) + +# v0.4.71 (2026-06-06) + +## Features +- Caveman: add wenyan classical Chinese levels and sync upstream prompts; locale-based visibility on endpoint page +- i18n: endpoint exposure notice across multiple languages + Russian README +- Antigravity: add gemini-3.5-flash-extra-low (Low) model +- xiaomi-tokenplan: add Claude-native MiMo V2.5 Pro alias via dedicated executor +- Qoder: fetch latest model + dashboard import-model button (#1642) +- MiniMax: add MiniMax-M3 + update Quota Tracker coding/CN (#1631) + +## Fixes +- Codex: harden streaming timeouts (stall/connect raised to 60s, configurable per-provider), accept `response.done` event, and always emit a terminal `response.failed` + `[DONE]` for Responses passthrough when a stream closes, stalls, or aborts before a terminal event — prevents codex clients from hanging (#1648, #1680, #1688, #1618) +- Codex: durable OAuth refresh lifecycle (#1664) +- Tunnel: skip virtual interfaces to prevent false netchange watchdog +- Claude: fix forced tool_choice 400 on cc/ OAuth route (#1592) +- Proxy: raise Next client body limit to 128MB via `NINEROUTER_PROXY_CLIENT_MAX_BODY_SIZE` (#1529, #1572) +- MiniMax: echo `reasoning_content` on follow-up turns to avoid 400 (#1543) +- Kiro: handle 400 on tool-bearing history without client tools; add mappable "auto" model slot; fix binary EventStream crash + add models & TTS tool filtering +- Antigravity: passthrough tab-autocomplete + mark default agent slot mandatory +- Qoder: allow `qmodel_latest` model key (#1638) +- Providers: restore one-connection guard for compatible/embedding nodes +- Model-test: route image/STT probes to their real endpoints, harden STT ping; add opencode-go + xiaomi-tokenplan to connection test (#1576, #1628) + +## Improvements +- Dashboard: reorganize menu actions across sidebar/header/profile +- Translator: add data-driven coverage, bug-exposing cases, and real provider smoke tests + +# v0.4.66 (2026-05-29) + +## Features +- Add Qoder provider: device-flow OAuth, COSY signing, WAF-bypass body encoding, live model catalog, dashboard quota tracker, 11 models (#1372) +- Add new models: Claude Opus 4.8 (Claude Code), GPT 5.4 Mini (Codex) + +## Fixes +- DeepSeek thinking mode: echo `reasoning_content` back on follow-up/tool-call turns so OpenCode-free and custom providers no longer 400 with "reasoning_content must be passed back" (#1543) +- Reasoning injector: match deepseek/kimi model ids case-insensitively (covers custom providers using capitalized model names) +- OpenCode suggested-models: include free models without the `-free` suffix, e.g. `big-pickle` (#1535) + +## Improvements +- Codex: trim sunset models, keep gpt-5.5 / gpt-5.4 / gpt-5.3-codex family, add gpt-5.4-mini +- volcengine-ark: refresh model list (add DeepSeek-V4-Flash/Pro, drop EOL entries) +- Lower stream stall timeout 35s → 30s for faster hang detection + +# v0.4.63 (2026-05-26) + +## Fixes +- GitHub Copilot: never route Gemini/Claude models to the `/responses` endpoint; prevents misleading "does not support Responses API" 400s (#1062) +- proxyFetch: restore missing `Readable` import causing runtime `ReferenceError` in DNS-bypass fetch path + +## Improvements +- Lower stream stall timeout from 60s → 35s for faster hang detection + +# v0.4.62 (2026-05-26) + +## Fixes +- Codex: auto-retry when upstream drops mid-stream (no more hangs) +- Codex: fix random 400/404 errors, tool-calling failures, and unstable prompt cache +- MITM: support Antigravity 2.x +- Sanitize Read tool args to prevent retry loops from non-Anthropic models (#1144) +- Implement json_schema fallback for OpenAI-compatible providers without native Structured Output (#1343) +- Strip empty Read pages argument in OpenAI-to-Claude translator (#1354) +- Forward Gemini output dimensions for embeddings (#1366) +- Resolve setState-in-effect errors in dashboard components (#1362) +- Gemini CLI: reuse stored OAuth project IDs for quota checks and show clearer setup guidance when the project is missing (#1271, #1428) + +## Features +- Add Cloudflare Workers proxy deployer and pool integration (#1360) +- Add Deno Deploy relays support and improved proxy pools dashboard layout (#1437) + +## Improvements +- Refactor Tunnel into dedicated Cloudflare and Tailscale manager modules +- Refactor tokenRefresh service with in-flight dedup to prevent refresh_token_reused errors + +# v0.4.59 (2026-05-21) + +## Fixes +- OAuth: fix login flow on Windows + +# v0.4.58 (2026-05-21) + +## Features +- xAI Grok provider (OAuth, API key, image) +- Provider limits: paginated accounts with page size controls + +## Fixes +- Tailscale: fix connection status on Windows (#1300) +- Tunnel: fix false "checking" when tunnel URL is reachable +- Stream: fix pipe errors on client disconnect/abort + +# v0.4.55 (2026-05-18) + +## Features +- Xiaomi MiMo Token Plan: region selector (Singapore / China / Europe) — keys are cluster-specific +- Antigravity: risk confirmation dialog before first connection +- Gemini CLI: surface upstream retry delay on 429 errors + +## Fixes +- MITM: cannot kill process on macOS under sudo (lsof not found in PATH) +- Stream: false-positive stall timeout on Claude reasoning / Kiro responses +- Tunnel: cannot re-enable after disable (stuck state) +- Tunnel: cloudflared error messages now include log tail for easier debugging +- Language switcher: applies selected locale immediately on close (#1234) +- Antigravity OAuth: metadata now matches the official client + +## Improvements +- Gemini CLI: bump engine to 0.34.0 +- Re-hide `qwen` (OAuth EOL) and `iflow` (not ready) providers + +# v0.4.52 (2026-05-17) + +## Features +- Add Vercel AI Gateway provider support (#1183) +- rtk: Kiro format tool result compression — handle conversationState.history & currentMessage, preserve error results, ~13.6% savings (#1194) + +## Fixes +- openclaw: normalize agent.model object form `{primary, fallbacks}` before .startsWith → fix TypeError & 'not configured' status (#1216) +- Usage Details pagination: stay inside mobile viewport <640px (#1218) +- Fix test model error +- Fix MIMO provider in Codex +- Disable log file creation when using MITM AG + +# v0.4.50 (2026-05-16) + +## Fixes +- Fix duplicate tray icon on macOS when hiding to tray +- Fix tray not showing in background mode on macOS +- Fix hide to tray broken on Windows/Linux +- Fix Shutdown button in web UI not working + +# v0.4.49 (2026-05-16) + +## Features +- Add Kiro provider support: full request/response translation, live model listing, reasoning content support +- Add `buildOutput` RTK filter with autodetect for npm/yarn/cargo build logs +- Add MITM warning notification in tray and dashboard + +## Improvements +- Add modalities (input/output) to model configuration for OpenCode +- Fix tray hide-to-tray: keep current process alive instead of spawning detached child (fixes macOS NSStatusItem ghost icon) +- Fix tray kill: graceful shutdown with SIGTERM/SIGKILL escalation +- Fix SIGHUP handling so macOS terminal close doesn't kill tray process +- Hide deprecated providers (qwen, iflow, antigravity) +- Update i18n across 32 languages + +## Fixes +- Fix model check (test-models) blocked by dashboardGuard: pass machineId-based CLI token in internal self-calls + +# v0.4.46 (2026-05-15) + +## Breaking Changes +- Tunnel public URL changed — old tunnel links no longer work, please reconnect to get the new URL \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d7c2134 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,91 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +9Router (`9router-app`) — a local AI routing gateway + Next.js dashboard. It exposes one OpenAI-compatible endpoint (`/v1/*`) and routes traffic across 40+ upstream providers with format translation, model-combo fallback, multi-account fallback, OAuth/API-key credential management, token refresh, quota/usage tracking, and optional cloud sync. + +Two published artifacts live in this one repo: +- The **dashboard + gateway** (root `package.json`, `9router-app`) — the Next.js server that does the actual routing. +- The **CLI launcher** (`cli/`, published to npm as `9router`) — a separate package that installs/starts the server and manages the tray. It has its own `package.json`, version, and build. + +The code lives in `src/` (Next.js app + dashboard/compat APIs), `open-sse/` (the provider-agnostic routing/translation engine), `cli/` (the launcher package), and `tests/`. + +## Commands + +Dashboard/gateway (run from repo root): +```bash +cp .env.example .env +npm install +PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev # dev (webpack, port 20127 by default via next dev) +npm run build && PORT=20128 HOSTNAME=0.0.0.0 npm run start # production +``` +- Bun variants: `npm run dev:bun` / `build:bun` / `start:bun`. +- Default runtime port is **20128** (dashboard at `/dashboard`, API at `/v1`). +- Lint: `npx eslint .` (config `eslint.config.mjs`, extends `eslint-config-next`). + +CLI package (`cli/`): +```bash +npm run cli:pack # build + npm pack from root +cd cli && npm run dev # nodemon watch +``` + +Tests (vitest, in `tests/`, an **independent** ESM package — not wired into root `npm test`): +```bash +npm install # ROOT deps first — tests import from src/ which needs `open`, `undici`, etc. +cd tests && npm install # then tests' own deps (vitest) → tests/node_modules (allowed by tests/.gitignore) +npx vitest run # all tests; auto-discovers tests/vitest.config.js +npx vitest run unit/capabilities.test.js # single file (path relative to tests/) +``` +> The committed `tests/package.json` `test` script hardcodes Unix paths (`NODE_PATH=/tmp/node_modules …`) — a shared-install workaround from upstream. On Windows (or anywhere), ignore it and use the `npx vitest` form above; `vitest.config.js` resolves the `open-sse`/`@/` aliases from the repo root regardless of where vitest lives. +> +> **The suite is NOT expected to be all-green on a plain checkout.** ~938 pass, ~64 fail. Judge regressions with `tests/__baseline__/verify-no-regression.mjs`, not a raw run. Expected red: +> - 26 catalogued in `tests/__baseline__/known-fails.txt` (rtk, oauth-cursor-auto-import, translator-request-normalization, …). +> - `unit/embeddings.cloud.test.js` imports `cloud/src/handlers/embeddings.js` — the `cloud/` worker dir is **not in this repo**, so it always fails here. +> - `unit/xai-oauth-service.test.js` times out (5s) when the xAI endpoint-discovery fetch isn't reachable/mocked. +> - `real/*.real.test.js` make live provider calls — need credentials, skip otherwise. +- `*.real.test.js` under `tests/translator/real/` make live provider calls — skip unless credentials are set. +- Regression baselines: `tests/__baseline__/verify-*.mjs` compare against committed snapshots (providers, aliases, OAuth URLs). Run these after touching provider registry / alias logic. + +## Architecture + +Two authoritative docs already exist — read them before working in these areas rather than re-deriving: +- `docs/ARCHITECTURE.md` — full system: request lifecycle, combo/account fallback, OAuth + token refresh, cloud sync, data model. +- `open-sse/AGENTS.md` — the routing/translation engine's own conventions and "how to add a provider/executor/translator". **Read this before editing anything under `open-sse/`.** + +### Request flow (the thing to understand first) +`src/app/api/v1/*` route (Next rewrite maps `/v1/*` → `/api/v1/*` in `next.config.mjs`) +→ `src/sse/handlers/chat.js` (parse, combo expansion, account-selection loop) +→ `open-sse/handlers/chatCore.js` (detect source format, translate request, dispatch to executor, retry/refresh, stream setup) +→ `open-sse/executors/*` (per-provider upstream call; `default.js` handles any OpenAI-compatible provider) +→ `open-sse/translator/*` (client format ↔ provider format) +→ SSE back to client. + +`src/sse/` is the app-side entry glue; `open-sse/` is the provider-agnostic engine (also usable standalone). Cross that boundary consciously. + +### Translator engine (`open-sse/translator/`) +- Pivots through **OpenAI as the intermediate format**. A translator registered on an exact `source:target` pair (e.g. `claude:kiro`) runs as a **direct route**, skipping the lossy double-hop. Prefer a direct route for fragile pairs (thinking blocks, tool ids, non-base64 images, `is_error`). +- Translators **self-register** via `register(from, to, reqFn, resFn)` as an import side effect — a new translator file MUST be imported in `open-sse/translator/index.js` or it never runs. +- Never hardcode role/block/model strings — use `open-sse/translator/schema/` and `open-sse/config/` constants. Config-driven and DRY is enforced by convention here. + +### Provider registry (`open-sse/providers/registry/*`) +- One file per provider. `providers/registry/index.js` is an **auto-generated** static import list — regenerate it with `scripts/migrate-registry.mjs` / `injectDisplayToRegistry.mjs`, don't hand-edit. +- Add a provider: copy `providers/REGISTRY_TEMPLATE.js`, add models to `config/providerModels.js`. Only add an executor for non-OpenAI-compatible upstreams. + +### Persistence — IMPORTANT (ARCHITECTURE.md is stale here) +State is **no longer `db.json`**. It's a SQLite layer under `src/lib/db/` with an adapter fallback chain (`driver.js`): `bun:sqlite` → `better-sqlite3` (optional native dep) → `node:sqlite` (Node ≥22.5) → `sql.js` (pure-JS fallback, always works). `better-sqlite3` is deliberately in `optionalDependencies` so install never fails without build tools. +- `src/lib/localDb.js` is a **backward-compat shim** re-exporting `src/lib/db/index.js`. New code should import from `@/lib/db/index.js`; per-entity logic lives in `src/lib/db/repos/*`. Schema/migrations in `src/lib/db/migrations/`. +- DB file location resolves via `src/lib/db/paths.js` (`DATA_DIR`, else `~/.9router/`). +- Usage/logs (`src/lib/usageDb.js`, `usage.json` + `log.txt`) still live under `~/.9router` and do **not** follow `DATA_DIR`. + +### RTK token saver (`open-sse/rtk/`) +Pre-translate hooks that compress `tool_result` content in-place to cut tokens. **Fail-open**: any error returns null and leaves the body untouched — never throw out of them. Skips `is_error`/`status:"error"` results to preserve traces. + +## Conventions & gotchas + +- Plain JavaScript (ESM), no TypeScript. `@/*` path alias → `src/*` (`jsconfig.json`). +- `custom-server.js` wraps the Next standalone server to derive client IP from the TCP socket and strip attacker-controlled `X-Forwarded-For` — trusting forwarding headers only from a loopback reverse proxy. Preserve this when touching request/IP/rate-limit code. +- Security-sensitive env: `JWT_SECRET` (session cookie), `INITIAL_PASSWORD` (default `123456` — must override), `API_KEY_SECRET`, `MACHINE_ID_SALT`. Full env contract in `.env.example` and ARCHITECTURE.md's env matrix. +- Binary/protobuf upstreams (kiro EventStream, cursor protobuf, commandcode NDJSON) don't round-trip through OpenAI — they're handled inside their own executor, not the translator. +- Versioning: root and `cli/` are versioned independently; changes are logged in `CHANGELOG.md`. Commit style is Conventional Commits (`fix(translator): …`, `feat(...)`). diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..1f280d9 --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,132 @@ +# Docker + +Run 9Router in a container. Published image: [`decolua/9router`](https://hub.docker.com/r/decolua/9router) — multi-platform `linux/amd64` + `linux/arm64`. + +--- + +# 👤 For Users + +## Quick start + +```bash +docker run -d \ + -p 20128:20128 \ + -v "$HOME/.9router:/app/data" \ + -e DATA_DIR=/app/data \ + --name 9router \ + decolua/9router:latest +``` + +App listens on port `20128`. Open: http://localhost:20128 + +## Manage container + +```bash +docker logs -f 9router # view logs +docker stop 9router # stop +docker start 9router # start again +docker rm -f 9router # remove +``` + +## Data persistence + +```bash +-v "$HOME/.9router:/app/data" \ +-e DATA_DIR=/app/data +``` + +Without `DATA_DIR`, the app falls back to `~/.9router/` (macOS/Linux) or `%APPDATA%\9router\` (Windows). In the container, `DATA_DIR=/app/data` makes the bind mount work. + +Data layout under `$DATA_DIR/`: + +```text +$DATA_DIR/ +├── db/ +│ ├── data.sqlite # main SQLite database +│ └── backups/ # auto backups +└── ... # certs, logs, runtime configs +``` + +Host path: `$HOME/.9router/db/data.sqlite` +Container path: `/app/data/db/data.sqlite` + +## Optional env vars + +```bash +docker run -d \ + -p 20128:20128 \ + -v "$HOME/.9router:/app/data" \ + -e DATA_DIR=/app/data \ + -e PORT=20128 \ + -e HOSTNAME=0.0.0.0 \ + -e DEBUG=true \ + --name 9router \ + decolua/9router:latest +``` + +## Optional Headroom sidecar + +The 9Router image does not bundle Python or Headroom. To use Headroom in Docker, run it as a separate service and point 9Router at that proxy: + +```yaml +services: + 9router: + image: decolua/9router:latest + ports: + - "20128:20128" + volumes: + - "$HOME/.9router:/app/data" + environment: + DATA_DIR: /app/data + HEADROOM_URL: http://headroom:8787 + depends_on: + - headroom + + headroom: + image: ghcr.io/chopratejas/headroom:latest + ports: + - "8787:8787" +``` + +In the dashboard, open `Endpoint` → `Token Saver` → `Headroom`, confirm the URL is `http://headroom:8787`, recheck status, then enable Headroom. + +If Headroom runs on the Docker host instead of as a sidecar, use `http://host.docker.internal:8787` on macOS/Windows. On Linux, add `--add-host=host.docker.internal:host-gateway` or the equivalent compose `extra_hosts` entry. + +## Update to latest + +```bash +docker pull decolua/9router:latest +docker rm -f 9router +# re-run the quick start command +``` + +--- + +# 🛠 For Developers + +## Build image locally (test) + +```bash +cd app && docker build -t 9router . + +docker run --rm -p 20128:20128 \ + -v "$HOME/.9router:/app/data" \ + -e DATA_DIR=/app/data \ + 9router +``` + +## Publish (automatic via CI) + +Push a git tag `v*` → GitHub Actions builds multi-platform (amd64+arm64) and pushes to: +- `ghcr.io/decolua/9router:v{version}` + `:latest` +- `decolua/9router:v{version}` + `:latest` + +```bash +# Use scripts/release.js (recommended) +node scripts/release.js "Release title" "Notes" + +# Or manually +git tag v0.4.x && git push origin v0.4.x +``` + +Workflow: `app/.github/workflows/docker-publish.yml` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5abe1f2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +# syntax=docker/dockerfile:1.7 +ARG NODE_IMAGE=node:22-alpine +FROM ${NODE_IMAGE} AS base +WORKDIR /app + +FROM base AS builder + +RUN apk --no-cache upgrade && apk --no-cache add python3 make g++ linux-headers + +COPY package.json ./ +RUN --mount=type=cache,target=/root/.npm \ + npm install + +COPY . ./ +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +FROM ${NODE_IMAGE} AS runner +WORKDIR /app + +LABEL org.opencontainers.image.title="9router" + +ENV NODE_ENV=production +ENV PORT=20128 +ENV HOSTNAME=0.0.0.0 +ENV NEXT_TELEMETRY_DISABLED=1 +ENV DATA_DIR=/app/data + +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/custom-server.js ./custom-server.js +COPY --from=builder /app/open-sse ./open-sse +# Next file tracing can omit sibling files; MITM runs server.js as a separate process. +COPY --from=builder /app/src/mitm ./src/mitm +# Standalone node_modules may omit deps only required by the MITM child process. +COPY --from=builder /app/node_modules/node-forge ./node_modules/node-forge +# Ensure `next` is available at runtime in case tracing did not include it. +COPY --from=builder /app/node_modules/next ./node_modules/next + +RUN mkdir -p /app/data && chown -R node:node /app && \ + mkdir -p /app/data-home && chown node:node /app/data-home && \ + ln -sf /app/data-home /root/.9router 2>/dev/null || true + +# Fix permissions at runtime (handles mounted volumes) +RUN apk --no-cache upgrade && apk --no-cache add su-exec && \ + printf '#!/bin/sh\nchown -R node:node /app/data /app/data-home 2>/dev/null\nexec su-exec node "$@"\n' > /entrypoint.sh && \ + chmod +x /entrypoint.sh + +EXPOSE 20128 + +ENTRYPOINT ["/entrypoint.sh"] +CMD ["node", "custom-server.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9219235 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024-2026 decolua and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b6cd76b --- /dev/null +++ b/README.md @@ -0,0 +1,1444 @@ +
+ 9Router Dashboard + + # 9Router - FREE AI Router & Token Saver + + **Never stop coding. Save 20-40% tokens with RTK + auto-fallback to FREE & cheap AI models.** + + **Connect All AI Code Tools (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) to 40+ AI Providers & 100+ Models.** + + [![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router) + [![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router) + [![Docker Pulls](https://img.shields.io/docker/pulls/decolua/9router.svg?logo=docker&label=Docker%20pulls)](https://hub.docker.com/r/decolua/9router) + [![GHCR](https://img.shields.io/badge/GHCR-decolua%2F9router-blue?logo=github)](https://github.com/decolua/9router/pkgs/container/9router) + [![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) + +decolua%2F9router | Trendshift + +[🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com) + +[🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) + +
+ +--- + +## 🤔 Why 9Router? + +**Stop wasting money, tokens and hitting limits:** + +- ❌ Subscription quota expires unused every month +- ❌ Rate limits stop you mid-coding +- ❌ Tool outputs (git diff, grep, ls...) burn tokens fast +- ❌ Expensive APIs ($20-50/month per provider) +- ❌ Manual switching between providers + +**9Router solves this:** + +- ✅ **RTK Token Saver** - Auto-compress tool_result content, save 20-40% tokens per request +- ✅ **Maximize subscriptions** - Track quota, use every bit before reset +- ✅ **Auto fallback** - Subscription → Cheap → Free, zero downtime +- ✅ **Multi-account** - Round-robin between accounts per provider +- ✅ **Universal** - Works with Claude Code, Codex, Cursor, Cline, any CLI tool + +--- + +## 🔄 How It Works + +``` +┌─────────────┐ +│ Your CLI │ (Claude Code, Codex, OpenClaw, Cursor, Cline...) +│ Tool │ +└──────┬──────┘ + │ http://localhost:20128/v1 + ↓ +┌─────────────────────────────────────────────┐ +│ 9Router (Smart Router) │ +│ • RTK Token Saver (cut tool_result tokens) │ +│ • Format translation (OpenAI ↔ Claude) │ +│ • Quota tracking │ +│ • Auto token refresh │ +└──────┬──────────────────────────────────────┘ + │ + ├─→ [Tier 1: SUBSCRIPTION] Claude Code, Codex, GitHub Copilot + │ ↓ quota exhausted + ├─→ [Tier 2: CHEAP] GLM ($0.6/1M), MiniMax ($0.2/1M) + │ ↓ budget limit + └─→ [Tier 3: FREE] Kiro, OpenCode Free, Vertex ($300 credits) + +Result: Never stop coding, minimal cost + 20-40% token savings via RTK +``` + +--- + +## ⚡ Quick Start + +**1. Install globally:** + +```bash +npm install -g 9router +9router +``` + +🎉 Dashboard opens at `http://localhost:20128` + +**2. Connect a FREE provider (no signup needed):** + +Dashboard → Providers → Connect **Kiro AI** (free Claude unlimited) or **OpenCode Free** (no auth) → Done! + +**3. Use in your CLI tool:** + +``` +Claude Code/Codex/OpenClaw/Cursor/Cline Settings: + Endpoint: http://localhost:20128/v1 + API Key: [copy from dashboard] + Model: kr/claude-sonnet-4.5 +``` + +**That's it!** Start coding with FREE AI models. + +**Alternative: run from source (this repository):** + +This repository package is private (`9router-app`), so source/Docker execution is the expected local development path. + +```bash +cp .env.example .env +npm install +PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev +``` + +Production mode: + +```bash +npm run build +PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start +``` + +Default URLs: + +- Dashboard: `http://localhost:20128/dashboard` +- OpenAI-compatible API: `http://localhost:20128/v1` + +--- + +## Video Guides + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Tiết kiệm chi phí LLM với 9Router +
+ 🇻🇳 Tiếng Việt
+ Tiết kiệm chi phí LLM cho OpenClaw với 9Router
by Mì AI
+
+ + 9Router + Claude Code FREE Unlimited Setup +
+ 🇵🇰 اردو / हिन्दी
+ 9Router + Claude Code FREE Unlimited Setup
by Build AI With Hamid
+
+ + 9Router Setup Tutorial +
+ 🇺🇸 English
+ 9Router + Claude Code FREE Setup
by Build AI With Hamid
+
+ + 9Router Setup Tutorial +
+ 🇺🇸 English
+ 9Router + Claude Code FREE Setup
by Build AI With Hamid
+
+ + Claude Code FREE Forever +
+ 🇺🇸 English
+ Claude Code FREE Forever — Unlimited Models
by Build AI With Hamid
+
+ + Claude CLI Free Setup +
+ 🇺🇸 English
+ Claude CLI Free Setup with 9Router 🚀
by CodeVerse Soban
+
+ + Cài đặt OpenClaw Free A-Z +
+ 🇻🇳 Tiếng Việt
+ Cài Đặt OpenClaw Free Từ A-Z + 9Router
by Mai Gia
+
+ + FREE OpenClaw with Claude Opus +
+ 🇺🇸 English
+ FREE OpenClaw + Claude Opus 4.6
by Build AI With Hamid
+
+ + Claude CLI Free Setup +
+ 🇮🇩 Indonesia
+ Koding 24 Jam Anti Rate Limit! Hemat Token AI 65% | Tutorial Quick Setup 9Router 🚀
by Krisswuh
+
+ + Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB +
+ 🇮🇩 Indonesia
+ Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB
by Krisswuh
+
+ +
+ +> 🎬 **Made a video about 9Router?** Submit a [Pull Request](https://github.com/decolua/9router/pulls) adding your video to this section — we'll merge it! + +--- + +## 🛠️ Supported CLI Tools + +9Router works seamlessly with all major AI coding tools: + +
+ + + + + + + + + + + + + + + + + +
+ Claude Code
+ Claude-Code +
+ OpenClaw
+ OpenClaw +
+ Codex
+ Codex +
+ OpenCode
+ OpenCode +
+ Cursor
+ Cursor +
+ Antigravity
+ Antigravity +
+ Cline
+ Cline +
+ Continue
+ Continue +
+ Droid
+ Droid +
+ Roo
+ Roo +
+ Copilot
+ Copilot +
+ Kilo Code
+ Kilo Code +
+
+ +--- + +## 🌐 Supported Providers + +### 🔐 OAuth Providers + +
+ + + + + + + + + +
+ Claude Code
+ Claude-Code +
+ Antigravity
+ Antigravity +
+ Codex
+ Codex +
+ GitHub
+ GitHub +
+ Cursor
+ Cursor +
+ Kimchi
+ Kimchi +
+
+ +### 🆓 Free Providers + +
+ + + + + + +
+ Kiro
+ Kiro AI
+ Claude 4.5 + GLM-5 + MiniMax
Unlimited FREE
+
+ OpenCode Free
+ OpenCode Free
+ No auth • Auto-fetch models
Unlimited FREE
+
+ Vertex AI
+ Vertex AI
+ Gemini 3 Pro + GLM-5 + DeepSeek
$300 credits free
+
+
+ +> **Note:** iFlow, Qwen and Gemini CLI free tiers were discontinued in 2026. Use Kiro / OpenCode Free / Vertex instead. + +### 🔑 API Key Providers (40+) + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ OpenRouter
+ OpenRouter +
+ GLM
+ GLM +
+ Kimi
+ Kimi +
+ MiniMax
+ MiniMax +
+ OpenAI
+ OpenAI +
+ Anthropic
+ Anthropic +
+ Gemini
+ Gemini +
+ DeepSeek
+ DeepSeek +
+ Groq
+ Groq +
+ xAI
+ xAI +
+ Mistral
+ Mistral +
+ Perplexity
+ Perplexity +
+ Together
+ Together AI +
+ Fireworks
+ Fireworks +
+ Cerebras
+ Cerebras +
+ Cohere
+ Cohere +
+ NVIDIA
+ NVIDIA +
+ SiliconFlow
+ SiliconFlow +
+

...and 20+ more providers including Nebius, Chutes, Hyperbolic, and custom OpenAI/Anthropic compatible endpoints

+
+ +--- + +## 💡 Key Features + +| Feature | What It Does | Why It Matters | +| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------- | +| 🚀 **RTK Token Saver** ([RTK](https://github.com/rtk-ai/rtk) ⭐40K) | Compress tool outputs (`git diff`, `grep`, `ls`, `tree`...) before sending to LLM | Save **20-40% input tokens** per request | +| 🧠 **Headroom Token Saver** ([Headroom](https://github.com/chopratejas/headroom)) | Optional external `/v1/compress` proxy before provider routing | Save more context tokens without changing clients | +| 🪨 **Caveman Mode** ([Caveman](https://github.com/JuliusBrussee/caveman) ⭐52K) | Inject caveman-speak prompt → LLM replies terse, technical substance preserved | Save **up to 65% output tokens** | +| 🐴 **Ponytail** ([Ponytail](https://github.com/DietrichGebert/ponytail)) | Inject "lazy senior dev" prompt → LLM writes minimal, YAGNI-first code (Lite/Full/Ultra) | **Fewer output tokens, less refactoring** | +| 🎯 **Smart 3-Tier Fallback** | Auto-route: Subscription → Cheap → Free | Never stop coding, zero downtime | +| 📊 **Real-Time Quota Tracking** | Live token count + reset countdown | Maximize subscription value | +| 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | Works with any CLI tool | +| 👥 **Multi-Account Support** | Multiple accounts per provider | Load balancing + redundancy | +| 🔄 **Auto Token Refresh** | OAuth tokens refresh automatically | No manual re-login needed | +| 🎨 **Custom Combos** | Create unlimited model combinations | Tailor fallback to your needs | +| 📝 **Request Logging** | Debug mode with full request/response logs | Troubleshoot issues easily | +| 💾 **Cloud Sync** | Sync config across devices | Same setup everywhere | +| 📊 **Usage Analytics** | Track tokens, cost, trends over time | Optimize spending | +| 🌐 **Deploy Anywhere** | Localhost, VPS, Docker, Cloudflare Workers | Flexible deployment options | + +
+📖 Feature Details + +### 🚀 RTK Token Saver + +Tool outputs (`git diff`, `grep`, `find`, `ls`, `tree`, log dumps...) often eat 30-50% of your prompt budget. RTK detects them and applies smart, lossless compression **before** the request hits the LLM: + +- **Filters:** `git-diff`, `git-status`, `grep`, `find`, `ls`, `tree`, `dedup-log`, `smart-truncate`, `read-numbered`, `search-list` +- **Auto-detect:** No config needed — RTK peeks the first 1KB of each `tool_result` and picks the right filter. +- **Safe by design:** If a filter fails, throws, or makes output bigger, RTK silently keeps the original text. Errors never break your request. +- **Universal:** Works across all formats (OpenAI, Claude, Gemini, Cursor, Kiro, OpenAI Responses) because it runs **before** any format translation. +- **Default ON:** Toggle anytime in Dashboard → Endpoint settings. + +``` +Without RTK: 47K tokens sent to LLM +With RTK: 28K tokens sent to LLM (40% saved · same context · same answer) +``` + +### 🧠 Headroom Token Saver + +Headroom is optional and runs separately. 9Router calls Headroom's local `/v1/compress` endpoint, then keeps normal routing, fallback, auth, and usage tracking: + +``` +Client → 9Router → Headroom /v1/compress → 9Router → provider +``` + +Local setup: + +```bash +pip install "headroom-ai[proxy]" +headroom proxy --port 8787 +``` + +Enable in Dashboard → Endpoint → Token Saver → Headroom. Default URL: `http://localhost:8787`. + +Docker examples: + +```bash +# Headroom service in same Docker network +http://headroom:8787 + +# Headroom running on host machine +http://host.docker.internal:8787 +``` + +If Headroom is down or returns an error, 9Router fails open and sends the original request. + +### 🐴 Ponytail (Lazy Senior Dev) + +Ponytail injects a _"lazy senior dev"_ system prompt into every request, biasing the LLM toward minimal, YAGNI-first code — deletion over addition, stdlib over new deps, one-liners over abstractions. Adapted from [DietrichGebert/ponytail](https://github.com/DietrichGebert/ponytail). + +- **Lite** — Build what's asked, name the lazier alternative. +- **Full** — YAGNI ladder enforced: stdlib → native → existing deps → one-liner → minimal code. +- **Ultra** — YAGNI extremist: deletion first, ship the one-liner, challenge the rest of the requirement in the same response. + +``` +Without Ponytail: verbose code, extra abstractions, "just in case" scaffolding +With Ponytail: shortest working diff, no unrequested abstractions, fewer tokens +``` + +Never trades away: input validation, error handling that prevents data loss, security, accessibility, or anything explicitly requested. Enable in Dashboard → Endpoint → Ponytail. Stacks with Caveman (output terseness) and RTK (input compression). + +### 🎯 Smart 3-Tier Fallback + +Create combos with automatic fallback: + +``` +Combo: "my-coding-stack" + 1. cc/claude-opus-4-6 (your subscription) + 2. glm/glm-4.7 (cheap backup, $0.6/1M) + 3. if/kimi-k2-thinking (free fallback) + +→ Auto switches when quota runs out or errors occur +``` + +### 📊 Real-Time Quota Tracking + +- Token consumption per provider +- Reset countdown (5-hour, daily, weekly) +- Cost estimation for paid tiers +- Monthly spending reports + +### 🔄 Format Translation + +Seamless translation between formats: + +- **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **Cursor** ↔ **Kiro** ↔ **Vertex** ↔ **Antigravity** ↔ **Ollama** ↔ **OpenAI Responses** +- Your CLI tool sends OpenAI format → 9Router translates → Provider receives native format +- Works with any tool that supports custom OpenAI endpoints + +### 👥 Multi-Account Support + +- Add multiple accounts per provider +- Auto round-robin or priority-based routing +- Fallback to next account when one hits quota + +### 🔄 Auto Token Refresh + +- OAuth tokens automatically refresh before expiration +- No manual re-authentication needed +- Seamless experience across all providers + +### 🎨 Custom Combos + +- Create unlimited model combinations +- Mix subscription, cheap, and free tiers +- Name your combos for easy access +- Share combos across devices with Cloud Sync + +### 📝 Request Logging + +- Enable debug mode for full request/response logs +- Track API calls, headers, and payloads +- Troubleshoot integration issues +- Export logs for analysis + +### 💾 Cloud Sync + +- Sync providers, combos, and settings across devices +- Automatic background sync +- Secure encrypted storage +- Access your setup from anywhere + +#### Cloud Runtime Notes + +- Prefer server-side cloud variables in production: + - `BASE_URL` (internal callback URL used by sync scheduler) + - `CLOUD_URL` (cloud sync endpoint base) +- `NEXT_PUBLIC_BASE_URL` and `NEXT_PUBLIC_CLOUD_URL` are still supported for compatibility/UI, but server runtime now prioritizes `BASE_URL`/`CLOUD_URL`. +- Cloud sync requests now use timeout + fail-fast behavior to avoid UI hanging when cloud DNS/network is unavailable. + +### 📊 Usage Analytics + +- Track token usage per provider and model +- Cost estimation and spending trends +- Monthly reports and insights +- Optimize your AI spending + +> **💡 IMPORTANT - Understanding Dashboard Costs:** +> +> The "cost" displayed in Usage Analytics is **for tracking and comparison purposes only**. +> 9Router itself **never charges** you anything. You only pay providers directly (if using paid services). +> +> **Example:** If your dashboard shows "$290 total cost" while using iFlow models, this represents +> what you would have paid using paid APIs directly. Your actual cost = **$0** (iFlow is free unlimited). +> +> Think of it as a "savings tracker" showing how much you're saving by using free models or +> routing through 9Router! + +### 🌐 Deploy Anywhere + +- 💻 **Localhost** - Default, works offline +- ☁️ **VPS/Cloud** - Share across devices +- 🐳 **Docker** - One-command deployment +- 🚀 **Cloudflare Workers** - Global edge network + +
+ +--- + +## 💰 Pricing at a Glance + +| Tier | Provider | Cost | Quota Reset | Best For | +| ------------------- | --------------------- | ------------ | ---------------- | --------------------------------------- | +| **🚀 TOKEN SAVER** | **RTK (built-in)** | **FREE** | Always on | **Save 20-40% tokens on EVERY request** | +| **💳 SUBSCRIPTION** | Claude Code (Pro/Max) | $20-200/mo | 5h + weekly | Already subscribed | +| | Codex (Plus/Pro) | $20-200/mo | 5h + weekly | OpenAI users | +| | GitHub Copilot | $10-19/mo | Monthly | GitHub users | +| | Cursor IDE | $20/mo | Monthly | Cursor users | +| **💰 CHEAP** | GLM-5.1 / GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup | +| | MiniMax M2.7 | $0.2/1M | 5-hour rolling | Cheapest option | +| | Kimi K2.5 | $9/mo flat | 10M tokens/mo | Predictable cost | +| **🆓 FREE** | Kiro AI | $0 | Unlimited | Claude 4.5 + GLM-5 + MiniMax free | +| | OpenCode Free | $0 | Unlimited | No auth, auto-fetch models | +| | Vertex AI | $300 credits | New GCP accounts | Gemini 3 Pro + DeepSeek + GLM-5 | + +**💡 Pro Tip:** RTK + Kiro AI + OpenCode Free combo = **$0 cost + 20-40% token savings**! + +--- + +### 📊 Understanding 9Router Costs & Billing + +**9Router Billing Reality:** + +✅ **9Router software = FREE forever** (open source, never charges) +✅ **Dashboard "costs" = Display/tracking only** (not actual bills) +✅ **You pay providers directly** (subscriptions or API fees) +✅ **FREE providers stay FREE** (iFlow, Kiro, Qwen = $0 unlimited) +❌ **9Router never sends invoices** or charges your card + +**How Cost Display Works:** + +The dashboard shows **estimated costs** as if you were using paid APIs directly. This is **not billing** - it's a comparison tool to show your savings. + +**Example Scenario:** + +``` +Dashboard Display: +• Total Requests: 1,662 +• Total Tokens: 47M +• Display Cost: $290 + +Reality Check: +• Provider: iFlow (FREE unlimited) +• Actual Payment: $0.00 +• What $290 Means: Amount you SAVED by using free models! +``` + +**Payment Rules:** + +- **Subscription providers** (Claude Code, Codex): Pay them directly via their websites +- **Cheap providers** (GLM, MiniMax): Pay them directly, 9Router just routes +- **FREE providers** (iFlow, Kiro, Qwen): Genuinely free forever, no hidden charges +- **9Router**: Never charges anything, ever + +--- + +## 🎯 Use Cases + +### Case 1: "I have Claude Pro subscription" + +**Problem:** Quota expires unused, rate limits during heavy coding + +**Solution:** + +``` +Combo: "maximize-claude" + 1. cc/claude-opus-4-7 (use subscription fully) + 2. glm/glm-5.1 (cheap backup when quota out) + 3. kr/claude-sonnet-4.5 (free emergency fallback) + +Monthly cost: $20 (subscription) + ~$5 (backup) = $25 total +vs. $20 + hitting limits = frustration +``` + +### Case 2: "I want zero cost" + +**Problem:** Can't afford subscriptions, need reliable AI coding + +**Solution:** + +``` +Combo: "free-forever" + 1. kr/claude-sonnet-4.5 (Claude 4.5 free unlimited) + 2. kr/glm-5 (GLM-5 free via Kiro) + 3. oc/ (OpenCode Free, no auth) + +Monthly cost: $0 +Quality: Production-ready models + RTK saves 20-40% tokens +``` + +### Case 3: "I need 24/7 coding, no interruptions" + +**Problem:** Deadlines, can't afford downtime + +**Solution:** + +``` +Combo: "always-on" + 1. cc/claude-opus-4-7 (best quality) + 2. cx/gpt-5.5 (second subscription) + 3. glm/glm-5.1 (cheap, resets daily) + 4. minimax/MiniMax-M2.7 (cheapest, 5h reset) + 5. kr/claude-sonnet-4.5 (free unlimited) + +Result: 5 layers of fallback = zero downtime +Monthly cost: $20-200 (subscriptions) + $10-20 (backup) +``` + +### Case 4: "I want FREE AI in OpenClaw" + +**Problem:** Need AI assistant in messaging apps (WhatsApp, Telegram, Slack...), completely free + +**Solution:** + +``` +Combo: "openclaw-free" + 1. kr/claude-sonnet-4.5 (Claude 4.5 free) + 2. kr/glm-5 (GLM-5 free) + 3. kr/MiniMax-M2.5 (MiniMax free) + +Monthly cost: $0 +Access via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal... +``` + +--- + +## ❓ Frequently Asked Questions + +
+📊 Why does my dashboard show high costs? + +The dashboard tracks your token usage and displays **estimated costs** as if you were using paid APIs directly. This is **not actual billing** - it's a reference to show how much you're saving by using free models or existing subscriptions through 9Router. + +**Example:** + +- **Dashboard shows:** "$290 total cost" +- **Reality:** You're using iFlow (FREE unlimited) +- **Your actual cost:** **$0.00** +- **What $290 means:** Amount you **saved** by using free models instead of paid APIs! + +The cost display is a "savings tracker" to help you understand your usage patterns and optimization opportunities. + +
+ +
+💳 Will I be charged by 9Router? + +**No.** 9Router is free, open-source software that runs on your own computer. It never charges you anything. + +**You only pay:** + +- ✅ **Subscription providers** (Claude Code $20/mo, Codex $20-200/mo) → Pay them directly on their websites +- ✅ **Cheap providers** (GLM, MiniMax) → Pay them directly, 9Router just routes your requests +- ❌ **9Router itself** → **Never charges anything, ever** + +9Router is a local proxy/router. It doesn't have your credit card, can't send invoices, and has no billing system. It's completely free software. + +
+ +
+🆓 Are FREE providers really unlimited? + +**Yes!** The current FREE providers (Kiro, OpenCode Free, Vertex) are genuinely free with **no hidden charges**. + +These are free services offered by those respective companies: + +- **Kiro AI**: Free unlimited Claude 4.5 + GLM-5 + MiniMax via AWS Builder ID / Google / GitHub OAuth +- **OpenCode Free**: No-auth passthrough proxy, models auto-fetched from `opencode.ai/zen/v1/models` +- **Vertex AI**: $300 free credits for new Google Cloud accounts (90 days) + +9Router just routes your requests to them - there's no "catch" or future billing. They're truly free services, and 9Router makes them easy to use with fallback support. + +**Discontinued free tiers (no longer recommended):** + +- ❌ **iFlow**: Was free unlimited, now changed to paid (2026) +- ❌ **Qwen Code**: Free OAuth tier discontinued by Alibaba on 2026-04-15 +- ❌ **Gemini CLI**: Still works, but using it with non-CLI tools (Claude, Codex, Cursor...) may result in account bans — only use if you stick to Gemini CLI itself + +
+ +
+💰 How do I minimize my actual AI costs? + +**Free-First Strategy:** + +1. **Start with 100% free combo:** + + ``` + 1. gc/gemini-3-flash (180K/month free from Google) + 2. if/kimi-k2-thinking (unlimited free from iFlow) + 3. qw/qwen3-coder-plus (unlimited free from Qwen) + ``` + + **Cost: $0/month** + +2. **Add cheap backup** only if you need it: + + ``` + 4. glm/glm-4.7 ($0.6/1M tokens) + ``` + + **Additional cost: Only pay for what you actually use** + +3. **Use subscription providers last:** + - Only if you already have them + - 9Router helps maximize their value through quota tracking + +**Result:** Most users can operate at $0/month using only free tiers! + +
+ +
+📈 What if my usage suddenly spikes? + +9Router's smart fallback prevents surprise charges: + +**Scenario:** You're on a coding sprint and blow through your quotas + +**Without 9Router:** + +- ❌ Hit rate limit → Work stops → Frustration +- ❌ Or: Accidentally rack up huge API bills + +**With 9Router:** + +- ✅ Subscription hits limit → Auto-fallback to cheap tier +- ✅ Cheap tier gets expensive → Auto-fallback to free tier +- ✅ Never stop coding → Predictable costs + +**You're in control:** Set spending limits per provider in dashboard, and 9Router respects them. + +
+ +--- + +## 📖 Setup Guide + +
+🔐 Subscription Providers (Maximize Value) + +### Claude Code (Pro/Max) + +```bash +Dashboard → Providers → Connect Claude Code +→ OAuth login → Auto token refresh +→ 5-hour + weekly quota tracking + +Models: + cc/claude-opus-4-7 + cc/claude-opus-4-6 + cc/claude-sonnet-4-6 + cc/claude-haiku-4-5-20251001 +``` + +**Pro Tip:** Use Opus for complex tasks, Sonnet for speed. 9Router tracks quota per model! + +### OpenAI Codex (Plus/Pro) + +```bash +Dashboard → Providers → Connect Codex +→ OAuth login (port 1455) +→ 5-hour + weekly reset + +Models: + cx/gpt-5.5 + cx/gpt-5.4 + cx/gpt-5.3-codex + cx/gpt-5.2-codex +``` + +### GitHub Copilot + +```bash +Dashboard → Providers → Connect GitHub +→ OAuth via GitHub +→ Monthly reset (1st of month) + +Models: + gh/gpt-5.4 + gh/claude-opus-4.7 + gh/claude-sonnet-4.6 + gh/gemini-3.1-pro-preview + gh/grok-code-fast-1 +``` + +### Cursor IDE + +```bash +Dashboard → Providers → Connect Cursor +→ OAuth login +→ Monthly subscription + +Models: + cu/claude-4.6-opus-max + cu/claude-4.5-sonnet-thinking + cu/gpt-5.3-codex +``` + +
+ +
+💰 Cheap Providers (Backup) + +### GLM-5.1 / GLM-4.7 (Daily reset, $0.6/1M) + +1. Sign up: [Zhipu AI](https://open.bigmodel.cn/) +2. Get API key from Coding Plan +3. Dashboard → Add API Key: + - Provider: `glm` + - API Key: `your-key` + +**Use:** `glm/glm-5.1`, `glm/glm-5`, `glm/glm-4.7` + +**Pro Tip:** Coding Plan offers 3× quota at 1/7 cost! Reset daily 10:00 AM. + +### MiniMax M2.7 (5h reset, $0.20/1M) + +1. Sign up: [MiniMax](https://www.minimax.io/) +2. Get API key +3. Dashboard → Add API Key + +**Use:** `minimax/MiniMax-M2.7`, `minimax/MiniMax-M2.5` + +**Pro Tip:** Cheapest option for long context (1M tokens)! + +### Kimi K2.5 ($9/month flat) + +1. Subscribe: [Moonshot AI](https://platform.moonshot.ai/) +2. Get API key +3. Dashboard → Add API Key + +**Use:** `kimi/kimi-k2.5`, `kimi/kimi-k2.5-thinking` + +**Pro Tip:** Fixed $9/month for 10M tokens = $0.90/1M effective cost! + +
+ +
+🆓 FREE Providers (Recommended) + +### Kiro AI (Claude 4.5 + GLM-5 + MiniMax FREE) + +```bash +Dashboard → Connect Kiro +→ AWS Builder ID, AWS IAM Identity Center, Google, or GitHub +→ Unlimited usage + +Models: + kr/claude-sonnet-4.5 + kr/claude-haiku-4.5 + kr/glm-5 + kr/MiniMax-M2.5 + kr/qwen3-coder-next + kr/deepseek-3.2 +``` + +**Pro Tip:** Best free option for Claude. No API key, no payment, fully unlimited. + +### OpenCode Free (No auth, auto-fetch models) + +```bash +Dashboard → Connect OpenCode Free +→ No login required (passthrough proxy) +→ Models auto-fetched from opencode.ai/zen/v1/models +``` + +**Pro Tip:** Fastest setup. Just connect and start coding. + +### Vertex AI ($300 free credits for new GCP accounts) + +```bash +Dashboard → Connect Vertex AI +→ Upload Google Cloud Service Account JSON +→ Enable Vertex AI API in your GCP project + +Models: + vertex/gemini-3.1-pro-preview + vertex/gemini-3-flash-preview + vertex/gemini-2.5-flash + +Vertex Partner (Anthropic / DeepSeek / GLM / Qwen via Vertex): + vertex-partner/glm-5-maas + vertex-partner/deepseek-v3.2-maas + vertex-partner/qwen3-next-80b-a3b-thinking-maas +``` + +**Pro Tip:** New Google Cloud accounts get $300 credits free for 90 days. Plenty for daily coding. + +
+ +
+🎨 Create Combos + +### Example 1: Maximize Subscription → Cheap Backup + +``` +Dashboard → Combos → Create New + +Name: premium-coding +Models: + 1. cc/claude-opus-4-7 (Subscription primary) + 2. glm/glm-5.1 (Cheap backup, $0.6/1M) + 3. minimax/MiniMax-M2.7 (Cheapest fallback, $0.20/1M) + +Use in CLI: premium-coding + +Monthly cost example (100M tokens): + 80M via Claude (subscription): $0 extra + 15M via GLM: $9 + 5M via MiniMax: $1 + Total: $10 + your subscription +``` + +### Example 2: Free-Only (Zero Cost) + +``` +Name: free-combo +Models: + 1. kr/claude-sonnet-4.5 (Claude 4.5 free unlimited) + 2. kr/glm-5 (GLM-5 free via Kiro) + 3. vertex/gemini-3.1-pro-preview ($300 free credits) + +Cost: $0 forever (+ 20-40% token savings via RTK)! +``` + +
+ +
+🔧 CLI Integration + +### Cursor IDE + +``` +Settings → Models → Advanced: + OpenAI API Base URL: http://localhost:20128/v1 + OpenAI API Key: [from 9router dashboard] + Model: cc/claude-opus-4-7 +``` + +Or use combo: `premium-coding` + +### Claude Code + +Edit `~/.claude/config.json`: + +```json +{ + "anthropic_api_base": "http://localhost:20128/v1", + "anthropic_api_key": "your-9router-api-key" +} +``` + +### Codex CLI + +```bash +export OPENAI_BASE_URL="http://localhost:20128" +export OPENAI_API_KEY="your-9router-api-key" + +codex "your prompt" +``` + +### OpenClaw + +**Option 1 — Dashboard (recommended):** + +``` +Dashboard → CLI Tools → OpenClaw → Select Model → Apply +``` + +**Option 2 — Manual:** Edit `~/.openclaw/openclaw.json`: + +```json +{ + "agents": { + "defaults": { + "model": { + "primary": "9router/kr/claude-sonnet-4.5" + } + } + }, + "models": { + "providers": { + "9router": { + "baseUrl": "http://127.0.0.1:20128/v1", + "apiKey": "sk_9router", + "api": "openai-completions", + "models": [ + { + "id": "kr/claude-sonnet-4.5", + "name": "Claude Sonnet 4.5 (Kiro Free)" + } + ] + } + } + } +} +``` + +> **Note:** OpenClaw only works with local 9Router. Use `127.0.0.1` instead of `localhost` to avoid IPv6 resolution issues. + +### Cline / Continue / RooCode + +``` +Provider: OpenAI Compatible +Base URL: http://localhost:20128/v1 +API Key: [from dashboard] +Model: cc/claude-opus-4-7 +``` + +
+ +
+🚀 Deployment + +### VPS Deployment + +```bash +# Clone and install +git clone https://github.com/decolua/9router.git +cd 9router +npm install +npm run build + +# Configure +export JWT_SECRET="your-secure-secret-change-this" +export INITIAL_PASSWORD="your-password" +export DATA_DIR="/var/lib/9router" +export PORT="20128" +export HOSTNAME="0.0.0.0" +export NODE_ENV="production" +export NEXT_PUBLIC_BASE_URL="http://localhost:20128" +export NEXT_PUBLIC_CLOUD_URL="https://9router.com" +export API_KEY_SECRET="endpoint-proxy-api-key-secret" +export MACHINE_ID_SALT="endpoint-proxy-salt" + +# Start +npm run start + +# Or use PM2 +npm install -g pm2 +pm2 start npm --name 9router -- start +pm2 save +pm2 startup +``` + +### Docker + +Published images (multi-platform `linux/amd64` + `linux/arm64`): + +- Docker Hub: [`decolua/9router`](https://hub.docker.com/r/decolua/9router) +- GHCR: [`ghcr.io/decolua/9router`](https://github.com/decolua/9router/pkgs/container/9router) + +**Quick start (use published image):** + +```bash +docker run -d \ + --name 9router \ + -p 20128:20128 \ + -v "$HOME/.9router:/app/data" \ + -e DATA_DIR=/app/data \ + decolua/9router:latest +``` + +→ Open http://localhost:20128 + +**Build from source (dev):** + +```bash +git clone https://github.com/decolua/9router.git +cd 9router/app +docker build -t 9router . +docker run -d --name 9router -p 20128:20128 \ + -v "$HOME/.9router:/app/data" -e DATA_DIR=/app/data 9router +``` + +**Container defaults:** + +- `PORT=20128` +- `HOSTNAME=0.0.0.0` + +**Useful commands:** + +```bash +docker logs -f 9router +docker restart 9router +docker stop 9router && docker rm 9router +docker pull decolua/9router:latest # update to latest +``` + +**Data persistence:** `$HOME/.9router/db/data.sqlite` on host ↔ `/app/data/db/data.sqlite` in container. + +### Environment Variables + +| Variable | Default | Description | +| ---------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- | +| `JWT_SECRET` | Auto-generated (`~/.9router/jwt-secret`) | JWT signing secret for dashboard auth cookie (override to share across instances) | +| `INITIAL_PASSWORD` | `123456` | First login password when no saved hash exists | +| `DATA_DIR` | `~/.9router` | Main app data location (SQLite at `$DATA_DIR/db/data.sqlite`) | +| `PORT` | framework default | Service port (`20128` in examples) | +| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) | +| `NODE_ENV` | runtime default | Set `production` for deploy | +| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL used by cloud sync jobs | +| `CLOUD_URL` | `https://9router.com` | Server-side cloud sync endpoint base URL | +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | Backward-compatible/public base URL (prefer `BASE_URL` for server runtime) | +| `NEXT_PUBLIC_CLOUD_URL` | `https://9router.com` | Backward-compatible/public cloud URL (prefer `CLOUD_URL` for server runtime) | +| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys | +| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | Salt for stable machine ID hashing | +| `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs under `logs/` | +| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (set `true` behind HTTPS reverse proxy) | +| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` routes (recommended for internet-exposed deploys) | +| `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` | empty | Optional outbound proxy for upstream provider calls | +| `SEARXNG_URL` | `http://localhost:8888/search` | Endpoint for the built-in unauthenticated SearXNG web-search provider | + +Notes: + +- Lowercase proxy variables are also supported: `http_proxy`, `https_proxy`, `all_proxy`, `no_proxy`. +- `.env` is not baked into Docker image (`.dockerignore`); inject runtime config with `--env-file` or `-e`. +- On Windows, `APPDATA` can be used for local storage path resolution. +- `INSTANCE_NAME` appears in older docs/env templates, but is currently not used at runtime. + +### Runtime Files and Storage + +- Main app state: `${DATA_DIR}/db/data.sqlite` (SQLite — providers, combos, aliases, keys, settings, usage history) +- Auto backups: `${DATA_DIR}/db/backups/` +- Optional request/translator logs: `/logs/...` when `ENABLE_REQUEST_LOGS=true` +- Both `${DATA_DIR}` and `~/.9router` resolve to the same location in a Docker container — the symlink `/root/.9router -> /app/data` is created at build time. + +
+ +--- + +## 📊 Available Models + +
+View all available models + +**Claude Code (`cc/`)** - Pro/Max: + +- `cc/claude-opus-4-7` +- `cc/claude-opus-4-6` +- `cc/claude-sonnet-4-6` +- `cc/claude-sonnet-4-5-20250929` +- `cc/claude-haiku-4-5-20251001` + +**Codex (`cx/`)** - Plus/Pro: + +- `cx/gpt-5.5` +- `cx/gpt-5.4` +- `cx/gpt-5.3-codex` +- `cx/gpt-5.2-codex` +- `cx/gpt-5.1-codex-max` + +**GitHub Copilot (`gh/`)**: + +- `gh/gpt-5.4` +- `gh/claude-opus-4.7` +- `gh/claude-sonnet-4.6` +- `gh/gemini-3.1-pro-preview` +- `gh/grok-code-fast-1` + +**Cursor (`cu/`)** - Subscription: + +- `cu/claude-4.6-opus-max` +- `cu/claude-4.5-sonnet-thinking` +- `cu/gpt-5.3-codex` +- `cu/kimi-k2.5` + +**GLM (`glm/`)** - $0.6/1M: + +- `glm/glm-5.1` +- `glm/glm-5` +- `glm/glm-4.7` + +**MiniMax (`minimax/`)** - $0.2/1M: + +- `minimax/MiniMax-M2.7` +- `minimax/MiniMax-M2.5` + +**Kimi (`kimi/`)** - $9/mo flat: + +- `kimi/kimi-k2.5` +- `kimi/kimi-k2.5-thinking` + +**Kiro (`kr/`)** - FREE unlimited: + +- `kr/claude-sonnet-4.5` +- `kr/claude-haiku-4.5` +- `kr/glm-5` +- `kr/MiniMax-M2.5` +- `kr/qwen3-coder-next` +- `kr/deepseek-3.2` + +**OpenCode Free (`oc/`)** - FREE no-auth: + +- Auto-fetched from `opencode.ai/zen/v1/models` + +**Vertex AI (`vertex/`)** - $300 free credits: + +- `vertex/gemini-3.1-pro-preview` +- `vertex/gemini-3-flash-preview` +- `vertex/gemini-2.5-flash` +- `vertex-partner/glm-5-maas` +- `vertex-partner/deepseek-v3.2-maas` + +
+ +--- + +## 🐛 Troubleshooting + +**"Language model did not provide messages"** + +- Provider quota exhausted → Check dashboard quota tracker +- Solution: Use combo fallback or switch to cheaper tier + +**Rate limiting** + +- Subscription quota out → Fallback to GLM/MiniMax +- Add combo: `cc/claude-opus-4-7 → glm/glm-5.1 → kr/claude-sonnet-4.5` + +**OAuth token expired** + +- Auto-refreshed by 9Router +- If issues persist: Dashboard → Provider → Reconnect + +**High costs** + +- Enable RTK in Dashboard → Endpoint settings (default ON, saves 20-40% tokens) +- Check usage stats in Dashboard +- Switch primary model to GLM/MiniMax +- Use free tier (Kiro, OpenCode Free, Vertex) for non-critical tasks + +**Dashboard opens on wrong port** + +- Set `PORT=20128` and `NEXT_PUBLIC_BASE_URL=http://localhost:20128` + +**First login not working** + +- Check `INITIAL_PASSWORD` in `.env` +- If unset, fallback password is `123456` + +**No request logs under `logs/`** + +- Set `ENABLE_REQUEST_LOGS=true` + +--- + +## 🛠️ Tech Stack + +- **Runtime**: Node.js 20+ +- **Framework**: Next.js 16 +- **UI**: React 19 + Tailwind CSS 4 +- **Database**: SQLite (better-sqlite3 / node:sqlite / sql.js fallback) +- **Streaming**: Server-Sent Events (SSE) +- **Auth**: OAuth 2.0 (PKCE) + JWT + API Keys + +--- + +## 📝 API Reference + +### Chat Completions + +```bash +POST http://localhost:20128/v1/chat/completions +Authorization: Bearer your-api-key +Content-Type: application/json + +{ + "model": "cc/claude-opus-4-6", + "messages": [ + {"role": "user", "content": "Write a function to..."} + ], + "stream": true +} +``` + +### List Models + +```bash +GET http://localhost:20128/v1/models +Authorization: Bearer your-api-key + +→ Returns all models + combos in OpenAI format +``` + +## 📧 Support + +- **Website**: [9router.com](https://9router.com) +- **GitHub**: [github.com/decolua/9router](https://github.com/decolua/9router) +- **Issues**: [github.com/decolua/9router/issues](https://github.com/decolua/9router/issues) + +--- + +## 👥 Contributors + +Thanks to all contributors who helped make 9Router better! + +[![Contributors](https://contrib.rocks/image?repo=decolua/9router&max=150&columns=15&anon=1&v=20260309)](https://github.com/decolua/9router/graphs/contributors) + +--- + +## 📊 Star Chart + +[![Star Chart](https://starchart.cc/decolua/9router.svg?variant=adaptive)](https://starchart.cc/decolua/9router) + +## 🔀 Forks + +**[OmniRoute](https://github.com/diegosouzapw/OmniRoute)** — A full-featured TypeScript fork of 9Router. Adds 36+ providers, 4-tier auto-fallback, multi-modal APIs (images, embeddings, audio, TTS), circuit breaker, semantic cache, LLM evaluations, and a polished dashboard. 368+ unit tests. Available via npm and Docker. + +--- + +## 🙏 Acknowledgments + +Built on the shoulders of giants: + +- **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — original Go implementation that inspired this JavaScript port. +- **[RTK](https://github.com/rtk-ai/rtk)** ![Stars](https://img.shields.io/github/stars/rtk-ai/rtk?style=flat&color=yellow) — Rust token-saver. 9Router ports its compression pipeline to JS → **−20-40% input tokens** on every request. +- **[Caveman](https://github.com/JuliusBrussee/caveman)** ![Stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow) by **[@JuliusBrussee](https://github.com/JuliusBrussee)** — viral _"why use many token when few token do trick"_. 9Router adapts its prompt → **−65% output tokens**. +- **[Ponytail](https://github.com/DietrichGebert/ponytail)** ![Stars](https://img.shields.io/github/stars/DietrichGebert/ponytail?style=flat&color=yellow) by **[@DietrichGebert](https://github.com/DietrichGebert)** — _"lazy senior dev"_ skill. 9Router injects its YAGNI-first ladder → **fewer tokens, less code, shorter diffs**. + +Huge thanks to these authors — without their work, 9Router's token-saving features wouldn't exist. ⭐ them on GitHub! + +--- + +## 📄 License + +MIT License - see [LICENSE](LICENSE) for details. + +--- + +
+ Built with ❤️ for developers who code 24/7 +
diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..0241f22 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`decolua/9router` +- 原始仓库:https://github.com/decolua/9router +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..8ba77ff --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,1311 @@ + +
+ 9Router Dashboard + + # 9Router - 免费 AI 路由器与 Token 节省器 + + **编程永不停歇。使用 RTK + 自动切换到免费/低价 AI 模型,节省 20-40% 的 tokens。** + + **将所有 AI 编程工具(Claude Code、Cursor、Antigravity、Copilot、Codex、Gemini、OpenCode、Cline、OpenClaw...)连接到 40+ AI 提供商和 100+ 模型。** + + [![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router) + [![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router) + [![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) + + decolua%2F9router | Trendshift + + [🚀 快速开始](#-快速开始) • [💡 功能特点](#-主要功能) • [📖 设置指南](#-设置指南) • [🌐 网站](https://9router.com) + + [🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) +
+ +--- + +## 🤔 为什么选择 9Router? + +**告别浪费金钱、tokens 和触碰限制的困扰:** + +- ❌ 订阅配额每月到期却未使用 +- ❌ 速率限制在编程中途打断你 +- ❌ 工具输出(git diff、grep、ls...)快速消耗 tokens +- ❌ 昂贵的 API(每个提供商 $20-50/月) +- ❌ 需要手动在提供商之间切换 + +**9Router 解决这一切:** + +- ✅ **RTK Token 节省器** - 自动压缩 tool_result 内容,每次请求节省 20-40% tokens +- ✅ **充分利用订阅** - 追踪配额,在重置前用尽每一分额度 +- ✅ **自动切换** - 订阅 → 低价 → 免费,零停机时间 +- ✅ **多账户支持** - 按提供商在账户之间轮询 +- ✅ **通用兼容** - 支持 Claude Code、Codex、Cursor、Cline 以及任何 CLI 工具 + +--- + +## 🔄 工作原理 + +``` +┌─────────────┐ +│ 你的 CLI │ (Claude Code、Codex、OpenClaw、Cursor、Cline...) +│ 工具 │ +└──────┬──────┘ + │ http://localhost:20128/v1 + ↓ +┌─────────────────────────────────────────────┐ +│ 9Router(智能路由器) │ +│ • RTK Token 节省器(减少 tool_result tokens)│ +│ • 格式转换(OpenAI ↔ Claude) │ +│ • 配额追踪 │ +│ • 自动刷新 token │ +└──────┬──────────────────────────────────────┘ + │ + ├─→ [第一层:订阅] Claude Code、Codex、GitHub Copilot + │ ↓ 配额耗尽 + ├─→ [第二层:低价] GLM ($0.6/1M)、MiniMax ($0.2/1M) + │ ↓ 预算超限 + └─→ [第三层:免费] Kiro、OpenCode Free、Vertex ($300 额度) + +结果:编程永不停歇,最小成本 + 通过 RTK 节省 20-40% tokens +``` + +--- + +## ⚡ 快速开始 + +**1. 全局安装:** + +```bash +npm install -g 9router +9router +``` + +🎉 控制面板在 `http://localhost:20128` 打开 + +**2. 连接免费提供商(无需注册):** + +控制面板 → 提供商 → 连接 **Kiro AI**(免费 Claude 无限量)或 **OpenCode Free**(无需认证)→ 完成! + +**3. 在 CLI 工具中使用:** + +``` +Claude Code/Codex/OpenClaw/Cursor/Cline 设置: + Endpoint: http://localhost:20128/v1 + API Key: [从控制面板复制] + Model: kr/claude-sonnet-4.5 +``` + +**就这么简单!** 开始使用免费 AI 模型编程。 + +**替代方案:从源码运行(本仓库):** + +本仓库的包是私有的(`9router-app`),所以源码/Docker 执行是预期的本地开发方式。 + +```bash +cp .env.example .env +npm install +PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev +``` + +生产模式: + +```bash +npm run build +PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start +``` + +默认 URL: +- 控制面板:`http://localhost:20128/dashboard` +- OpenAI 兼容 API:`http://localhost:20128/v1` + +--- + +## 视频教程 + +
+ + + + + + + + + + + + +
+ + 9Router Setup Tutorial +
+ 🇺🇸 English
+ 9Router + Claude Code 免费设置
by Build AI With Hamid
+
+ + Tiết kiệm chi phí LLM với 9Router +
+ 🇻🇳 Tiếng Việt
+ 使用 9Router 节省 OpenClaw 的 LLM 成本
by Mì AI
+
+ + Claude Code FREE Forever +
+ 🇺🇸 English
+ Claude Code 免费永久使用 — 无限模型
by Build AI With Hamid
+
+ + Claude CLI Free Setup +
+ 🇺🇸 English
+ 使用 9Router 免费设置 Claude CLI 🚀
by CodeVerse Soban
+
+ + Cài đặt OpenClaw Free A-Z +
+ 🇻🇳 Tiếng Việt
+ 从零开始安装 OpenClaw 免费版 + 9Router
by Mai Gia
+
+ + FREE OpenClaw with Claude Opus +
+ 🇺🇸 English
+ 免费 OpenClaw + Claude Opus 4.6
by Build AI With Hamid
+
+ +
+ +> 🎬 **制作了关于 9Router 的视频?** 提交 [Pull Request](https://github.com/decolua/9router/pulls),将你的视频添加到此部分 — 我们会合并它! + +--- + +## 🛠️ 支持的 CLI 工具 + +9Router 与所有主流 AI 编程工具无缝协作: + +
+ + + + + + + + + + + + + + + + + +
+ Claude Code
+ Claude-Code +
+ OpenClaw
+ OpenClaw +
+ Codex
+ Codex +
+ OpenCode
+ OpenCode +
+ Cursor
+ Cursor +
+ Antigravity
+ Antigravity +
+ Cline
+ Cline +
+ Continue
+ Continue +
+ Droid
+ Droid +
+ Roo
+ Roo +
+ Copilot
+ Copilot +
+ Kilo Code
+ Kilo Code +
+
+ +--- + +## 🌐 支持的提供商 + +### 🔐 OAuth 提供商 + +
+ + + + + + + + +
+ Claude Code
+ Claude-Code +
+ Antigravity
+ Antigravity +
+ Codex
+ Codex +
+ GitHub
+ GitHub +
+ Cursor
+ Cursor +
+
+ +### 🆓 免费提供商 + +
+ + + + + + +
+ Kiro
+ Kiro AI
+ Claude 4.5 + GLM-5 + MiniMax
无限免费
+
+ OpenCode Free
+ OpenCode Free
+ 无需认证 • 自动获取模型
无限免费
+
+ Vertex AI
+ Vertex AI
+ Gemini 3 Pro + GLM-5 + DeepSeek
$300 免费额度
+
+
+ +> **注意:** iFlow、Qwen 和 Gemini CLI 的免费等级已于 2026 年停止。请改用 Kiro / OpenCode Free / Vertex。 + +### 🔑 API Key 提供商(40+) + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ OpenRouter
+ OpenRouter +
+ GLM
+ GLM +
+ Kimi
+ Kimi +
+ MiniMax
+ MiniMax +
+ OpenAI
+ OpenAI +
+ Anthropic
+ Anthropic +
+ Gemini
+ Gemini +
+ DeepSeek
+ DeepSeek +
+ Groq
+ Groq +
+ xAI
+ xAI +
+ Mistral
+ Mistral +
+ Perplexity
+ Perplexity +
+ Together
+ Together AI +
+ Fireworks
+ Fireworks +
+ Cerebras
+ Cerebras +
+ Cohere
+ Cohere +
+ NVIDIA
+ NVIDIA +
+ SiliconFlow
+ SiliconFlow +
+

...以及 20+ 更多提供商,包括 Nebius、Chutes、Hyperbolic 和自定义 OpenAI/Anthropic 兼容端点

+
+ +--- + +## 💡 主要功能 + +| 功能 | 作用 | 为什么重要 | +|---------|--------------|----------------| +| 🚀 **RTK Token 节省器**([RTK](https://github.com/rtk-ai/rtk) ⭐40K) | 压缩工具输出(`git diff`、`grep`、`ls`、`tree`...)后再发送给 LLM | 每次请求节省 **20-40% 输入 tokens** | +| 🪨 **Caveman 模式**([Caveman](https://github.com/JuliusBrussee/caveman) ⭐52K) | 注入 caveman 风格提示词 → LLM 回复简洁,保留技术实质 | 节省 **高达 65% 输出 tokens** | +| 🎯 **智能三层切换** | 自动路由:订阅 → 低价 → 免费 | 编程永不停歇,零停机时间 | +| 📊 **实时配额追踪** | 实时 token 计数 + 重置倒计时 | 充分利用订阅价值 | +| 🔄 **格式转换** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | 兼容任何 CLI 工具 | +| 👥 **多账户支持** | 每个提供商支持多个账户 | 负载均衡 + 冗余备份 | +| 🔄 **自动 Token 刷新** | OAuth token 自动刷新 | 无需手动重新登录 | +| 🎨 **自定义组合** | 创建无限模型组合 | 自定义适合你的切换策略 | +| 📝 **请求日志** | 调试模式下的完整请求/响应日志 | 轻松排查问题 | +| 💾 **云同步** | 跨设备同步配置 | 处处相同设置 | +| 📊 **使用分析** | 追踪 tokens、成本、趋势 | 优化开支 | +| 🌐 **任意部署** | 本地、VPS、Docker、Cloudflare Workers | 灵活部署选项 | + +
+📖 功能详情 + +### 🚀 RTK Token 节省器 + +工具输出(`git diff`、`grep`、`find`、`ls`、`tree`、日志转储...)通常占用 30-50% 的提示词预算。RTK 在请求到达 LLM 之前检测并应用智能、无损压缩: + +- **过滤器:** `git-diff`、`git-status`、`grep`、`find`、`ls`、`tree`、`dedup-log`、`smart-truncate`、`read-numbered`、`search-list` +- **自动检测:** 无需配置 — RTK 检查每个 `tool_result` 的前 1KB,选择合适的过滤器。 +- **安全设计:** 如果过滤器失败、抛出异常或使输出变大,RTK 会静默保留原始文本。错误永远不会中断你的请求。 +- **通用兼容:** 适用于所有格式(OpenAI、Claude、Gemini、Cursor、Kiro、OpenAI Responses),因为它在任何格式转换**之前**运行。 +- **默认开启:** 可随时在控制面板 → 端点设置中切换。 + +``` +不使用 RTK:47K tokens 发送给 LLM +使用 RTK: 28K tokens 发送给 LLM (节省 40% · 相同上下文 · 相同答案) +``` + +### 🎯 智能三层切换 + +创建具有自动切换功能的组合: + +``` +组合:"my-coding-stack" + 1. cc/claude-opus-4-6 (你的订阅) + 2. glm/glm-4.7 (低价备份,$0.6/1M) + 3. if/kimi-k2-thinking (免费备选) + +→ 配额用完或出错时自动切换 +``` + +### 📊 实时配额追踪 + +- 每个提供商的 token 消耗 +- 重置倒计时(5小时、每日、每周) +- 付费等级的成本估算 +- 月度支出报告 + +### 🔄 格式转换 + +格式间无缝转换: +- **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **Cursor** ↔ **Kiro** ↔ **Vertex** ↔ **Antigravity** ↔ **Ollama** ↔ **OpenAI Responses** +- 你的 CLI 工具发送 OpenAI 格式 → 9Router 转换 → 提供商接收原生格式 +- 适用于任何支持自定义 OpenAI 端点的工具 + +### 👥 多账户支持 + +- 每个提供商添加多个账户 +- 自动轮询或基于优先级的路由 +- 当一个账户达到配额时切换到下一个 + +### 🔄 自动 Token 刷新 + +- OAuth token 在过期前自动刷新 +- 无需手动重新认证 +- 所有提供商的无缝体验 + +### 🎨 自定义组合 + +- 创建无限模型组合 +- 混合订阅、低价和免费等级 +- 为组合命名以便访问 +- 使用云同步跨设备共享组合 + +### 📝 请求日志 + +- 启用调试模式获取完整请求/响应日志 +- 追踪 API 调用、请求头和载荷 +- 排查集成问题 +- 导出日志进行分析 + +### 💾 云同步 + +- 跨设备同步提供商、组合和设置 +- 自动后台同步 +- 安全加密存储 +- 从任何地方访问你的设置 + +#### 云运行时说明 + +- 生产环境中优先使用服务端云变量: + - `BASE_URL`(云同步调度程序使用的内部回调 URL) + - `CLOUD_URL`(云同步端点基础 URL) +- `NEXT_PUBLIC_BASE_URL` 和 `NEXT_PUBLIC_CLOUD_URL` 仍用于兼容性/UI,但服务端运行时现在优先使用 `BASE_URL`/`CLOUD_URL`。 +- 云同步请求现在使用超时 + 快速失败行为,以避免云 DNS/网络不可用时 UI 挂起。 + +### 📊 使用分析 + +- 追踪每个提供商和模型的 token 使用量 +- 成本估算和支出趋势 +- 月度报告和洞察 +- 优化你的 AI 支出 + +> **💡 重要 - 了解控制面板成本:** +> +> 使用分析中显示的"成本"**仅用于追踪和比较目的**。 +> 9Router 本身**永远不会向你收费**。你只直接向提供商付款(如果使用付费服务)。 +> +> **示例:** 如果你的控制面板显示使用 iFlow 模型时"总成本 $290",这代表你如果直接使用付费 API 需要支付的金额。你的实际成本 = **$0**(iFlow 免费无限量)。 +> +> 把它想象成一个"节省追踪器",展示你通过使用免费模型或通过 9Router 路由节省了多少钱! + +### 🌐 任意部署 + +- 💻 **本地** - 默认,离线可用 +- ☁️ **VPS/云** - 跨设备共享 +- 🐳 **Docker** - 一键部署 +- 🚀 **Cloudflare Workers** - 全球边缘网络 + +
+ +--- + +## 💰 价格一览 + +| 等级 | 提供商 | 成本 | 配额重置 | 适用场景 | +|------|----------|------|-------------|----------| +| **🚀 TOKEN 节省器** | **RTK(内置)** | **免费** | 始终开启 | **每次请求节省 20-40% tokens** | +| **💳 订阅** | Claude Code (Pro/Max) | $20-200/月 | 5小时 + 每周 | 已有订阅的用户 | +| | Codex (Plus/Pro) | $20-200/月 | 5小时 + 每周 | OpenAI 用户 | +| | GitHub Copilot | $10-19/月 | 每月 | GitHub 用户 | +| | Cursor IDE | $20/月 | 每月 | Cursor 用户 | +| **💰 低价** | GLM-5.1 / GLM-4.7 | $0.6/1M | 每日 10AM | 预算备份 | +| | MiniMax M2.7 | $0.2/1M | 5小时滚动 | 最便宜选项 | +| | Kimi K2.5 | $9/月固定 | 10M tokens/月 | 可预测成本 | +| **🆓 免费** | Kiro AI | $0 | 无限量 | Claude 4.5 + GLM-5 + MiniMax 免费 | +| | OpenCode Free | $0 | 无限量 | 无需认证,自动获取模型 | +| | Vertex AI | $300 额度 | 新 GCP 账户 | Gemini 3 Pro + DeepSeek + GLM-5 | + +**💡 专业提示:** RTK + Kiro AI + OpenCode Free 组合 = **$0 成本 + 节省 20-40% tokens**! + +--- + +### 📊 理解 9Router 成本与计费 + +**9Router 计费真相:** + +✅ **9Router 软件 = 永久免费**(开源,绝不收费) +✅ **控制面板"成本" = 仅用于显示/追踪**(不是实际账单) +✅ **你直接向提供商付款**(订阅或 API 费用) +✅ **免费提供商保持免费**(iFlow、Kiro、Qwen = $0 无限量) +❌ **9Router 永不发送发票** 或扣款 + +**成本显示如何工作:** + +控制面板显示**估算成本**,如同你直接使用付费 API。这**不是计费** — 它是一个比较工具,展示你的节省。 + +**示例场景:** +``` +控制面板显示: +• 总请求数:1,662 +• 总 Tokens:47M +• 显示成本:$290 + +实际检查: +• 提供商:iFlow(免费无限量) +• 实际支付:$0.00 +• $290 意味着什么:通过使用免费模型节省的金额! +``` + +**付款规则:** +- **订阅提供商**(Claude Code、Codex):通过他们的网站直接付款 +- **低价提供商**(GLM、MiniMax):直接付款,9Router 只做路由 +- **免费提供商**(iFlow、Kiro、Qwen):真正的永久免费,无隐藏费用 +- **9Router**:从不收取任何费用,永远不会 + +--- + +## 🎯 使用场景 + +### 场景 1:"我有 Claude Pro 订阅" + +**问题:** 配额到期未用完,繁忙编码时遇到速率限制 + +**解决方案:** +``` +组合:"maximize-claude" + 1. cc/claude-opus-4-7 (充分利用订阅) + 2. glm/glm-5.1 (配额用完时的低价备份) + 3. kr/claude-sonnet-4.5 (免费紧急备选) + +月成本:$20(订阅)+ ~$5(备份)= $25 总计 +对比:$20 + 遇到限制 = 沮丧 +``` + +### 场景 2:"我想零成本" + +**问题:** 负担不起订阅,需要可靠的 AI 编码 + +**解决方案:** +``` +组合:"free-forever" + 1. kr/claude-sonnet-4.5 (Claude 4.5 免费无限量) + 2. kr/glm-5 (通过 Kiro 免费使用 GLM-5) + 3. oc/ (OpenCode Free,无需认证) + +月成本:$0 +质量:生产级模型 + RTK 节省 20-40% tokens +``` + +### 场景 3:"我需要 24/7 编码,不中断" + +**问题:** 截止日期紧迫,不能承受停机 + +**解决方案:** +``` +组合:"always-on" + 1. cc/claude-opus-4-7 (最佳质量) + 2. cx/gpt-5.5 (第二个订阅) + 3. glm/glm-5.1 (低价,每日重置) + 4. minimax/MiniMax-M2.7 (最便宜,5小时重置) + 5. kr/claude-sonnet-4.5 (免费无限量) + +结果:5 层切换 = 零停机时间 +月成本:$20-200(订阅)+ $10-20(备份) +``` + +### 场景 4:"我想在 OpenClaw 中使用免费 AI" + +**问题:** 需要在消息应用(WhatsApp、Telegram、Slack...)中使用 AI 助手,完全免费 + +**解决方案:** +``` +组合:"openclaw-free" + 1. kr/claude-sonnet-4.5 (Claude 4.5 免费) + 2. kr/glm-5 (GLM-5 免费) + 3. kr/MiniMax-M2.5 (MiniMax 免费) + +月成本:$0 +访问方式:WhatsApp、Telegram、Slack、Discord、iMessage、Signal... +``` + +--- + +## ❓ 常见问题 + +
+📊 为什么我的控制面板显示高成本? + +控制面板追踪你的 token 使用情况,并显示**估算成本**,如同你直接使用付费 API。这**不是实际计费** — 它是一个参考,展示你通过使用免费模型或通过 9Router 路由现有订阅节省了多少钱。 + +**示例:** +- **控制面板显示:** "$290 总成本" +- **实际情况:** 你在使用 iFlow(免费无限量) +- **你的实际成本:** **$0.00** +- **$290 的含义:** 你通过使用免费模型而不是付费 API **节省**的金额! + +成本显示是一个"节省追踪器",帮助你了解使用模式和优化机会。 + +
+ +
+💳 9Router 会扣我的钱吗? + +**不会。** 9Router 是在你自己的电脑上运行的开源软件。它永远不会向你收取任何费用。 + +**你只需支付:** +- ✅ **订阅提供商**(Claude Code $20/月、Codex $20-200/月)→ 在他们的网站上直接付款 +- ✅ **低价提供商**(GLM、MiniMax)→ 直接付款,9Router 只是路由你的请求 +- ❌ **9Router 本身** → **永不收费,永远不会** + +9Router 是一个本地代理/路由器。它没有你的信用卡,不能发送发票,也没有计费系统。它是完全免费的软件。 + +
+ +
+🆓 免费提供商真的是无限量的吗? + +**是的!** 当前的免费提供商(Kiro、OpenCode Free、Vertex)是真正的免费,**无隐藏费用**。 + +这些是各公司提供的免费服务: +- **Kiro AI**:通过 AWS Builder ID / Google / GitHub OAuth 免费无限量使用 Claude 4.5 + GLM-5 + MiniMax +- **OpenCode Free**:无认证直连代理,模型从 `opencode.ai/zen/v1/models` 自动获取 +- **Vertex AI**:新 Google Cloud 账户可获得 $300 免费额度(90 天) + +9Router 只是路由你的请求到它们 — 没有"陷阱"或未来的计费。它们是真正的免费服务,9Router 让它们易于使用并支持切换。 + +**已停止的免费等级(不再推荐):** +- ❌ **iFlow**:曾是免费无限量,现在改为付费(2026) +- ❌ **Qwen Code**:阿里巴巴于 2026-04-15 停止免费 OAuth 等级 +- ❌ **Gemini CLI**:仍可用,但与非 CLI 工具(Claude、Codex、Cursor...)一起使用可能会导致账户被封 — 仅在你坚持使用 Gemini CLI 本身时才使用 + +
+ +
+💰 如何最小化我的实际 AI 成本? + +**免费优先策略:** + +1. **从 100% 免费组合开始:** + ``` + 1. gc/gemini-3-flash (Google 每月 180K 免费) + 2. if/kimi-k2-thinking (iFlow 无限量免费) + 3. qw/qwen3-coder-plus (Qwen 无限量免费) + ``` + **成本:$0/月** + +2. **仅在需要时添加低价备份:** + ``` + 4. glm/glm-4.7 ($0.6/1M tokens) + ``` + **额外成本:只为实际使用的部分付费** + +3. **最后使用订阅提供商:** + - 仅当你已有订阅时 + - 9Router 通过配额追踪帮助最大化其价值 + +**结果:** 大多数用户可以仅使用免费等级以 $0/月运行! + +
+ +
+📈 如果我的使用量突然激增怎么办? + +9Router 的智能切换可以防止意外费用: + +**场景:** 你正在进行编码冲刺,用尽了配额 + +**没有 9Router:** +- ❌ 达到速率限制 → 工作停止 → 沮丧 +- ❌ 或者:不慎累积大量 API 账单 + +**有 9Router:** +- ✅ 订阅达到限制 → 自动切换到低价等级 +- ✅ 低价等级变得昂贵 → 自动切换到免费等级 +- ✅ 编程永不停歇 → 可预测的成本 + +**你掌控一切:** 在控制面板中设置每个提供商的支出限制,9Router 会遵守它们。 + +
+ +--- + +## 📖 设置指南 + +
+🔐 订阅提供商(充分利用价值) + +### Claude Code (Pro/Max) + +```bash +控制面板 → 提供商 → 连接 Claude Code +→ OAuth 登录 → 自动 token 刷新 +→ 5小时 + 每周配额追踪 + +模型: + cc/claude-opus-4-7 + cc/claude-opus-4-6 + cc/claude-sonnet-4-6 + cc/claude-haiku-4-5-20251001 +``` + +**专业提示:** 复杂任务使用 Opus,追求速度使用 Sonnet。9Router 按模型追踪配额! + +### OpenAI Codex (Plus/Pro) + +```bash +控制面板 → 提供商 → 连接 Codex +→ OAuth 登录(端口 1455) +→ 5小时 + 每周重置 + +模型: + cx/gpt-5.5 + cx/gpt-5.4 + cx/gpt-5.3-codex + cx/gpt-5.2-codex +``` + +### GitHub Copilot + +```bash +控制面板 → 提供商 → 连接 GitHub +→ 通过 GitHub 进行 OAuth +→ 每月重置(每月 1 日) + +模型: + gh/gpt-5.4 + gh/claude-opus-4.7 + gh/claude-sonnet-4.6 + gh/gemini-3.1-pro-preview + gh/grok-code-fast-1 +``` + +### Cursor IDE + +```bash +控制面板 → 提供商 → 连接 Cursor +→ OAuth 登录 +→ 每月订阅 + +模型: + cu/claude-4.6-opus-max + cu/claude-4.5-sonnet-thinking + cu/gpt-5.3-codex +``` + +
+ +
+💰 低价提供商(备份) + +### GLM-5.1 / GLM-4.7(每日重置,$0.6/1M) + +1. 注册:[Zhipu AI](https://open.bigmodel.cn/) +2. 从编程计划获取 API key +3. 控制面板 → 添加 API Key: + - 提供商:`glm` + - API Key:`your-key` + +**使用:** `glm/glm-5.1`、`glm/glm-5`、`glm/glm-4.7` + +**专业提示:** 编程计划提供 3 倍配额,成本仅为 1/7!每日 10:00 AM 重置。 + +### MiniMax M2.7(5小时重置,$0.20/1M) + +1. 注册:[MiniMax](https://www.minimax.io/) +2. 获取 API key +3. 控制面板 → 添加 API Key + +**使用:** `minimax/MiniMax-M2.7`、`minimax/MiniMax-M2.5` + +**专业提示:** 长上下文(1M tokens)的最便宜选项! + +### Kimi K2.5($9/月固定) + +1. 订阅:[Moonshot AI](https://platform.moonshot.ai/) +2. 获取 API key +3. 控制面板 → 添加 API Key + +**使用:** `kimi/kimi-k2.5`、`kimi/kimi-k2.5-thinking` + +**专业提示:** 每月 $9 固定费用获得 10M tokens = 实际成本 $0.90/1M! + +
+ +
+🆓 免费提供商(推荐) + +### Kiro AI(Claude 4.5 + GLM-5 + MiniMax 免费) + +```bash +控制面板 → 连接 Kiro +→ AWS Builder ID、AWS IAM Identity Center、Google 或 GitHub +→ 无限量使用 + +模型: + kr/claude-sonnet-4.5 + kr/claude-haiku-4.5 + kr/glm-5 + kr/MiniMax-M2.5 + kr/qwen3-coder-next + kr/deepseek-3.2 +``` + +**专业提示:** Claude 最佳免费选项。无需 API key,无需付款,完全无限量。 + +### OpenCode Free(无需认证,自动获取模型) + +```bash +控制面板 → 连接 OpenCode Free +→ 无需登录(直连代理) +→ 模型从 opencode.ai/zen/v1/models 自动获取 +``` + +**专业提示:** 最快的设置。连接后即可开始编码。 + +### Vertex AI(新 GCP 账户 $300 免费额度) + +```bash +控制面板 → 连接 Vertex AI +→ 上传 Google Cloud 服务账户 JSON +→ 在你的 GCP 项目中启用 Vertex AI API + +模型: + vertex/gemini-3.1-pro-preview + vertex/gemini-3-flash-preview + vertex/gemini-2.5-flash + +Vertex 合作伙伴(通过 Vertex 提供 Anthropic / DeepSeek / GLM / Qwen): + vertex-partner/glm-5-maas + vertex-partner/deepseek-v3.2-maas + vertex-partner/qwen3-next-80b-a3b-thinking-maas +``` + +**专业提示:** 新 Google Cloud 账户可获得 90 天内 $300 免费额度。足够日常编码使用。 + +
+ +
+🎨 创建组合 + +### 示例 1:充分利用订阅 → 低价备份 + +``` +控制面板 → 组合 → 创建新组合 + +名称:premium-coding +模型: + 1. cc/claude-opus-4-7 (订阅主用) + 2. glm/glm-5.1 (低价备份,$0.6/1M) + 3. minimax/MiniMax-M2.7 (最便宜的备选,$0.20/1M) + +在 CLI 中使用:premium-coding + +月度成本示例(100M tokens): + 80M 通过 Claude(订阅):$0 额外费用 + 15M 通过 GLM:$9 + 5M 通过 MiniMax:$1 + 总计:$10 + 你的订阅费用 +``` + +### 示例 2:仅免费(零成本) + +``` +名称:free-combo +模型: + 1. kr/claude-sonnet-4.5 (Claude 4.5 免费无限量) + 2. kr/glm-5 (通过 Kiro 免费使用 GLM-5) + 3. vertex/gemini-3.1-pro-preview ($300 免费额度) + +成本:通过 RTK 永久 $0(+ 节省 20-40% tokens)! +``` + +
+ +
+🔧 CLI 集成 + +### Cursor IDE + +``` +设置 → 模型 → 高级: + OpenAI API Base URL:http://localhost:20128/v1 + OpenAI API Key:[来自 9router 控制面板] + Model:cc/claude-opus-4-7 +``` + +或使用组合:`premium-coding` + +### Claude Code + +编辑 `~/.claude/config.json`: + +```json +{ + "anthropic_api_base": "http://localhost:20128/v1", + "anthropic_api_key": "your-9router-api-key" +} +``` + +### Codex CLI + +```bash +export OPENAI_BASE_URL="http://localhost:20128" +export OPENAI_API_KEY="your-9router-api-key" + +codex "your prompt" +``` + +### OpenClaw + +**选项 1 — 控制面板(推荐):** + +``` +控制面板 → CLI 工具 → OpenClaw → 选择模型 → 应用 +``` + +**选项 2 — 手动:** 编辑 `~/.openclaw/openclaw.json`: + +```json +{ + "agents": { + "defaults": { + "model": { + "primary": "9router/kr/claude-sonnet-4.5" + } + } + }, + "models": { + "providers": { + "9router": { + "baseUrl": "http://127.0.0.1:20128/v1", + "apiKey": "sk_9router", + "api": "openai-completions", + "models": [ + { + "id": "kr/claude-sonnet-4.5", + "name": "Claude Sonnet 4.5 (Kiro Free)" + } + ] + } + } + } +} +``` + +> **注意:** OpenClaw 仅适用于本地 9Router。使用 `127.0.0.1` 而不是 `localhost` 以避免 IPv6 解析问题。 + +### Cline / Continue / RooCode + +``` +Provider:OpenAI 兼容 +Base URL:http://localhost:20128/v1 +API Key:[来自控制面板] +Model:cc/claude-opus-4-7 +``` + +
+ +
+🚀 部署 + +### VPS 部署 + +```bash +# 克隆并安装 +git clone https://github.com/decolua/9router.git +cd 9router +npm install +npm run build + +# 配置 +export JWT_SECRET="your-secure-secret-change-this" +export INITIAL_PASSWORD="your-password" +export DATA_DIR="/var/lib/9router" +export PORT="20128" +export HOSTNAME="0.0.0.0" +export NODE_ENV="production" +export NEXT_PUBLIC_BASE_URL="http://localhost:20128" +export NEXT_PUBLIC_CLOUD_URL="https://9router.com" +export API_KEY_SECRET="endpoint-proxy-api-key-secret" +export MACHINE_ID_SALT="endpoint-proxy-salt" + +# 启动 +npm run start + +# 或使用 PM2 +npm install -g pm2 +pm2 start npm --name 9router -- start +pm2 save +pm2 startup +``` + +### Docker + +```bash +# 构建镜像(从仓库根目录) +docker build -t 9router . + +# 运行容器(当前设置使用的命令) +docker run -d \ + --name 9router \ + -p 20128:20128 \ + --env-file /root/dev/9router/.env \ + -v 9router-data:/app/data \ + -v 9router-usage:/root/.9router \ + 9router +``` + +便携命令(如果你已经在仓库根目录): + +```bash +docker run -d \ + --name 9router \ + -p 20128:20128 \ + --env-file ./.env \ + -v 9router-data:/app/data \ + -v 9router-usage:/root/.9router \ + 9router +``` + +容器默认值: +- `PORT=20128` +- `HOSTNAME=0.0.0.0` + +常用命令: + +```bash +docker logs -f 9router +docker restart 9router +docker stop 9router && docker rm 9router +``` + +### 环境变量 + +| 变量 | 默认值 | 描述 | +|----------|---------|-------------| +| `JWT_SECRET` | 自动生成(`~/.9router/jwt-secret`) | 用于控制面板 auth cookie 的 JWT 签名密钥(设置可在多实例间共享) | +| `INITIAL_PASSWORD` | `123456` | 当没有保存的哈希时首次登录的密码 | +| `DATA_DIR` | `~/.9router` | 主应用数据库位置(`db.json`) | +| `PORT` | 框架默认值 | 服务端口(示例中为 `20128`) | +| `HOSTNAME` | 框架默认值 | 绑定主机(Docker 默认为 `0.0.0.0`) | +| `NODE_ENV` | 运行时默认值 | 设置 `production` 用于部署 | +| `BASE_URL` | `http://localhost:20128` | 云同步任务使用的服务端内部基础 URL | +| `CLOUD_URL` | `https://9router.com` | 服务端云同步端点基础 URL | +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | 向后兼容/公开基础 URL(服务端运行时优先使用 `BASE_URL`) | +| `NEXT_PUBLIC_CLOUD_URL` | `https://9router.com` | 向后兼容/公开云 URL(服务端运行时优先使用 `CLOUD_URL`) | +| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | 生成 API key 的 HMAC 密钥 | +| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | 稳定机器 ID 哈希的盐值 | +| `ENABLE_REQUEST_LOGS` | `false` | 在 `logs/` 下启用请求/响应日志 | +| `AUTH_COOKIE_SECURE` | `false` | 强制 `Secure` auth cookie(在 HTTPS 反向代理后面设置为 `true`) | +| `REQUIRE_API_KEY` | `false` | 在 `/v1/*` 路由上强制使用 Bearer API key(面向互联网部署时推荐) | +| `HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY` | 空 | 用于上游提供商调用的可选出站代理 | + +注意: +- 也支持小写代理变量:`http_proxy`、`https_proxy`、`all_proxy`、`no_proxy`。 +- `.env` 不会打包到 Docker 镜像中(`.dockerignore`);使用 `--env-file` 或 `-e` 注入运行时配置。 +- 在 Windows 上,`APPDATA` 可用于本地存储路径解析。 +- `INSTANCE_NAME` 出现在较旧的文档/环境变量模板中,但当前运行时未使用。 + +### 运行时文件和存储 + +- 主应用状态:`${DATA_DIR}/db.json`(提供商、组合、别名、密钥、设置),由 `src/lib/localDb.js` 管理。 +- 使用历史和日志:`${DATA_DIR}/usage.json` 和 `${DATA_DIR}/log.txt`,由 `src/lib/usageDb.js` 管理。 +- 可选的请求/翻译器日志:`ENABLE_REQUEST_LOGS=true` 时位于 `/logs/...`。 +- `${DATA_DIR}` 和 `~/.9router` 在 Docker 容器中解析到同一位置 — 符号链接 `/root/.9router -> /app/data` 在构建时创建。 + +
+ +--- + +## 📊 可用模型 + +
+查看所有可用模型 + +**Claude Code(`cc/`)** - Pro/Max: +- `cc/claude-opus-4-7` +- `cc/claude-opus-4-6` +- `cc/claude-sonnet-4-6` +- `cc/claude-sonnet-4-5-20250929` +- `cc/claude-haiku-4-5-20251001` + +**Codex(`cx/`)** - Plus/Pro: +- `cx/gpt-5.5` +- `cx/gpt-5.4` +- `cx/gpt-5.3-codex` +- `cx/gpt-5.2-codex` +- `cx/gpt-5.1-codex-max` + +**GitHub Copilot(`gh/`)**: +- `gh/gpt-5.4` +- `gh/claude-opus-4.7` +- `gh/claude-sonnet-4.6` +- `gh/gemini-3.1-pro-preview` +- `gh/grok-code-fast-1` + +**Cursor(`cu/`)** - 订阅: +- `cu/claude-4.6-opus-max` +- `cu/claude-4.5-sonnet-thinking` +- `cu/gpt-5.3-codex` +- `cu/kimi-k2.5` + +**GLM(`glm/`)** - $0.6/1M: +- `glm/glm-5.1` +- `glm/glm-5` +- `glm/glm-4.7` + +**MiniMax(`minimax/`)** - $0.2/1M: +- `minimax/MiniMax-M2.7` +- `minimax/MiniMax-M2.5` + +**Kimi(`kimi/`)** - $9/月固定: +- `kimi/kimi-k2.5` +- `kimi/kimi-k2.5-thinking` + +**Kiro(`kr/`)** - 免费无限量: +- `kr/claude-sonnet-4.5` +- `kr/claude-haiku-4.5` +- `kr/glm-5` +- `kr/MiniMax-M2.5` +- `kr/qwen3-coder-next` +- `kr/deepseek-3.2` + +**OpenCode Free(`oc/`)** - 免费无需认证: +- 从 `opencode.ai/zen/v1/models` 自动获取 + +**Vertex AI(`vertex/`)** - $300 免费额度: +- `vertex/gemini-3.1-pro-preview` +- `vertex/gemini-3-flash-preview` +- `vertex/gemini-2.5-flash` +- `vertex-partner/glm-5-maas` +- `vertex-partner/deepseek-v3.2-maas` + +
+ +--- + +## 🐛 故障排除 + +**"语言模型未提供消息"** +- 提供商配额耗尽 → 检查控制面板配额追踪器 +- 解决方案:使用组合切换或切换到更便宜的等级 + +**速率限制** +- 订阅配额用完 → 切换到 GLM/MiniMax +- 添加组合:`cc/claude-opus-4-7 → glm/glm-5.1 → kr/claude-sonnet-4.5` + +**OAuth token 已过期** +- 9Router 自动刷新 +- 如果问题持续:控制面板 → 提供商 → 重新连接 + +**高成本** +- 在控制面板 → 端点设置中启用 RTK(默认开启,节省 20-40% tokens) +- 在控制面板中检查使用统计 +- 将主模型切换到 GLM/MiniMax +- 对于非关键任务使用免费等级(Kiro、OpenCode Free、Vertex) + +**控制面板在错误端口打开** +- 设置 `PORT=20128` 和 `NEXT_PUBLIC_BASE_URL=http://localhost:20128` + +**首次登录不工作** +- 检查 `.env` 中的 `INITIAL_PASSWORD` +- 如果未设置,回退密码是 `123456` + +**`logs/` 下没有请求日志** +- 设置 `ENABLE_REQUEST_LOGS=true` + +--- + +## 🛠️ 技术栈 + +- **运行时**:Node.js 20+ +- **框架**:Next.js 16 +- **UI**:React 19 + Tailwind CSS 4 +- **数据库**:LowDB(基于 JSON 文件) +- **流式传输**:Server-Sent Events (SSE) +- **认证**:OAuth 2.0 (PKCE) + JWT + API Keys + +--- + +## 📝 API 参考 + +### 聊天补全 + +```bash +POST http://localhost:20128/v1/chat/completions +Authorization: Bearer your-api-key +Content-Type: application/json + +{ + "model": "cc/claude-opus-4-6", + "messages": [ + {"role": "user", "content": "Write a function to..."} + ], + "stream": true +} +``` + +### 列出模型 + +```bash +GET http://localhost:20128/v1/models +Authorization: Bearer your-api-key + +→ 以 OpenAI 格式返回所有模型和组合 +``` + +## 📧 支持 + +- **网站**:[9router.com](https://9router.com) +- **GitHub**:[github.com/decolua/9router](https://github.com/decolua/9router) +- **问题**:[github.com/decolua/9router/issues](https://github.com/decolua/9router/issues) + +--- + +## 👥 贡献者 + +感谢所有帮助改进 9Router 的贡献者! + +[![Contributors](https://contrib.rocks/image?repo=decolua/9router&max=150&columns=15&anon=1&v=20260309)](https://github.com/decolua/9router/graphs/contributors) + +--- + +## 📊 Star 图表 + +[![Star Chart](https://starchart.cc/decolua/9router.svg?variant=adaptive)](https://starchart.cc/decolua/9router) + + + +## 🔀 分支 + +**[OmniRoute](https://github.com/diegosouzapw/OmniRoute)** — 9Router 的全功能 TypeScript 分支。增加了 36+ 提供商、4 层自动切换、多模态 API(图像、嵌入、音频、TTS)、断路器、语义缓存、LLM 评估和精美的控制面板。368+ 单元测试。可通过 npm 和 Docker 使用。 + +--- + +## 🙏 致谢 + +站在巨人的肩膀上构建: + +- **CLIProxyAPI** — 启发了这个 JavaScript 移植的原始 Go 实现。 +- **[RTK](https://github.com/rtk-ai/rtk)** ![Stars](https://img.shields.io/github/stars/rtk-ai/rtk?style=flat&color=yellow) — Rust token 节省器。9Router 将其压缩管道移植到 JS → 每次请求 **减少 20-40% 输入 tokens**。 +- **[Caveman](https://github.com/JuliusBrussee/caveman)** ![Stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow) by **[@JuliusBrussee](https://github.com/JuliusBrussee)** — 病毒式传播的 *"为什么用很多 token 当少的 token 就能搞定"*。9Router 适配其提示词 → **减少 65% 输出 tokens**。 + +非常感谢这些作者 — 没有他们的工作,9Router 的 token 节省功能就不会存在。在 GitHub 上给他们加星! + +--- + +## 📄 许可证 + +MIT 许可证 — 详见 [LICENSE](LICENSE)。 + +--- + +
+ 用 ❤️ 为 24/7 编程的开发者构建 +
\ No newline at end of file diff --git a/captain-definition b/captain-definition new file mode 100644 index 0000000..0e14f82 --- /dev/null +++ b/captain-definition @@ -0,0 +1,4 @@ +{ + "schemaVersion": 2, + "dockerfilePath": "./Dockerfile" +} diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..55fd8c4 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,2 @@ +app/* +node_modules/* diff --git a/cli/.npmignore b/cli/.npmignore new file mode 100644 index 0000000..231c936 --- /dev/null +++ b/cli/.npmignore @@ -0,0 +1,9 @@ +# Ignore everything except what's in package.json "files" +* +!cli.js +!hooks/ +!app/ +!package.json +!README.md +!LICENSE + diff --git a/cli/LICENSE b/cli/LICENSE new file mode 100644 index 0000000..ddd5dd4 --- /dev/null +++ b/cli/LICENSE @@ -0,0 +1,42 @@ +MIT License + +Copyright (c) 2026 9Router Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + + + + + + + + + + + + + + + + + + + + diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..050d996 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,125 @@ +# 9Router - FREE AI Router & Token Saver + +**Never stop coding. Save 20-40% tokens with RTK + auto-fallback to FREE & cheap AI models.** + +**Connect All AI Code Tools (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) to 40+ AI Providers & 100+ Models.** + +[![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router) +[![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router) +[![Docker Pulls](https://img.shields.io/docker/pulls/decolua/9router.svg?logo=docker&label=Docker%20pulls)](https://hub.docker.com/r/decolua/9router) +[![GHCR](https://img.shields.io/badge/GHCR-decolua%2F9router-blue?logo=github)](https://github.com/decolua/9router/pkgs/container/9router) +[![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) + +decolua%2F9router | Trendshift + +[🌐 Website](https://9router.com) • [📖 Full Docs](https://github.com/decolua/9router) + +--- + +## 🤔 Why 9Router? + +**Stop wasting money, tokens and hitting limits:** + +- ❌ Subscription quota expires unused every month +- ❌ Rate limits stop you mid-coding +- ❌ Tool outputs (git diff, grep, ls...) burn tokens fast +- ❌ Expensive APIs ($20-50/month per provider) + +**9Router solves this:** + +- ✅ **RTK Token Saver** - Auto-compress tool_result, save 20-40% tokens +- ✅ **Maximize subscriptions** - Track quota, use every bit before reset +- ✅ **Auto fallback** - Subscription → Cheap → Free, zero downtime +- ✅ **Multi-account** - Round-robin between accounts per provider +- ✅ **Universal** - Works with any OpenAI/Claude-compatible CLI + +--- + +## ⚡ Quick Start + +**Option 1 — npm (recommended for desktop):** + +```bash +npm install -g 9router +9router + +# Or run directly with npx +npx 9router +``` + +**Option 2 — Docker (server/VPS):** + +```bash +docker run -d --name 9router -p 20128:20128 \ + -v "$HOME/.9router:/app/data" -e DATA_DIR=/app/data \ + decolua/9router:latest +``` + +Published images: [Docker Hub](https://hub.docker.com/r/decolua/9router) • [GHCR](https://github.com/decolua/9router/pkgs/container/9router) (multi-platform amd64/arm64). + +🎉 Dashboard opens at `http://localhost:20128` + +**2. Connect a FREE provider (no signup needed):** + +Dashboard → Providers → Connect **Kiro AI** (free Claude unlimited) or **OpenCode Free** (no auth) → Done! + +**3. Use in your CLI tool:** + +``` +Claude Code/Codex/OpenClaw/Cursor/Cline Settings: + Endpoint: http://localhost:20128/v1 + API Key: [copy from dashboard] + Model: kr/claude-sonnet-4.5 +``` + +That's it! Start coding with FREE AI models. + +--- + +## 🚀 CLI Options + +```bash +9router # Start with default settings +9router --port 8080 # Custom port +9router --no-browser # Don't open browser +9router --skip-update # Skip auto-update check +9router --help # Show all options +``` + +**Dashboard**: `http://localhost:20128/dashboard` + +--- + +## 🛠️ Supported CLI Tools + +Claude-Code • OpenClaw • Codex • OpenCode • Cursor • Antigravity • Cline • Continue • Droid • Roo • Copilot • Kilo Code • Gemini CLI • Qwen Code • iFlow • Crush • Crusher • Aider + +Any tool supporting OpenAI/Claude-compatible API works. + +--- + +## 💾 Data Location + +- **macOS/Linux**: `~/.9router/db/data.sqlite` +- **Windows**: `%APPDATA%/9router/db/data.sqlite` +- **Docker**: `/app/data/db/data.sqlite` (mount `$HOME/.9router` to persist) + +--- + +## 📚 Documentation + +Full docs, advanced setup, video tutorials & development guide: + +- **GitHub**: https://github.com/decolua/9router +- **Full README**: https://github.com/decolua/9router/blob/main/app/README.md +- **Website**: https://9router.com + +--- + +## 🙏 Acknowledgments + +- **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** - Original Go implementation + +## 📄 License + +MIT License - see [LICENSE](LICENSE) for details. diff --git a/cli/cli.js b/cli/cli.js new file mode 100755 index 0000000..6a0fc06 --- /dev/null +++ b/cli/cli.js @@ -0,0 +1,852 @@ +#!/usr/bin/env node + +const { spawn, exec, execSync } = require("child_process"); +const path = require("path"); +const fs = require("fs"); +const https = require("https"); +const net = require("net"); +const os = require("os"); + +// Poll until the server accepts TCP connections on port, or timeout — avoids blind fixed waits. +function waitServerReady(port, { timeoutMs = 15000, intervalMs = 150 } = {}) { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve) => { + const tryConnect = () => { + const socket = net.connect({ host: "127.0.0.1", port }, () => { + socket.destroy(); + resolve(true); + }); + socket.on("error", () => { + socket.destroy(); + if (Date.now() >= deadline) return resolve(false); + setTimeout(tryConnect, intervalMs); + }); + }; + tryConnect(); + }); +} + +// Native spinner - no external dependency +function createSpinner(text) { + const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + let i = 0; + let interval = null; + let currentText = text; + return { + start() { + if (process.stdout.isTTY) { + process.stdout.write(`\r${frames[0]} ${currentText}`); + interval = setInterval(() => { + process.stdout.write(`\r${frames[i++ % frames.length]} ${currentText}`); + }, 80); + } + return this; + }, + stop() { + if (interval) { + clearInterval(interval); + interval = null; + } + if (process.stdout.isTTY) { + process.stdout.write("\r\x1b[K"); + } + }, + succeed(msg) { + this.stop(); + console.log(`✅ ${msg}`); + }, + fail(msg) { + this.stop(); + console.log(`❌ ${msg}`); + } + }; +} + +const pkg = require("./package.json"); +const { ensureSqliteRuntime, buildEnvWithRuntime } = require("./hooks/sqliteRuntime"); +const { ensureTrayRuntime } = require("./hooks/trayRuntime"); +const args = process.argv.slice(2); + +// Self-heal SQLite runtime deps (sql.js + better-sqlite3) into ~/.9router/runtime +// so the server can resolve them via NODE_PATH. Best-effort — sql.js is required, +// better-sqlite3 is optional. Logs to stderr only on failure. +try { ensureSqliteRuntime({ silent: true }); } catch {} + +// Self-heal tray runtime (systray for macOS/Linux only). Windows skipped. +try { ensureTrayRuntime({ silent: true }); } catch {} + +// Configuration constants +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"; + +// First non-internal IPv4 — the address remote peers actually reach when bound to 0.0.0.0. +function getLanIp() { + for (const ifaces of Object.values(os.networkInterfaces())) { + for (const i of ifaces || []) { + if (i.family === "IPv4" && !i.internal) return i.address; + } + } + return null; +} + +// Local URL stays "localhost"; warn separately when bound to all interfaces (network-exposed). +function getDisplayHost() { + return host === DEFAULT_HOST ? "localhost" : host; +} +const MAX_PORT_ATTEMPTS = 10; +// Identifiers for killAllAppProcesses - only kill 9router specifically +const PROCESS_IDENTIFIERS = [ + '9router' // Only package name - avoid killing other apps +]; + +// Parse arguments +let port = DEFAULT_PORT; +let host = DEFAULT_HOST; +let noBrowser = false; +let skipUpdate = false; +let showLog = false; +let trayMode = false; + +for (let i = 0; i < args.length; i++) { + if (args[i] === "--port" || args[i] === "-p") { + port = parseInt(args[i + 1], 10) || DEFAULT_PORT; + i++; + } else if (args[i] === "--host" || args[i] === "-H") { + host = args[i + 1] || DEFAULT_HOST; + i++; + } else if (args[i] === "--no-browser" || args[i] === "-n") { + noBrowser = true; + } else if (args[i] === "--log" || args[i] === "-l") { + showLog = true; + } else if (args[i] === "--skip-update") { + skipUpdate = true; + } else if (args[i] === "--tray" || args[i] === "-t") { + trayMode = true; + process.env.TRAY_MODE = "1"; + } 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}) + -n, --no-browser Don't open browser automatically + -l, --log Show server logs (default: hidden) + -t, --tray Run in system tray mode (background) + --skip-update Skip auto-update check + -h, --help Show this help message + -v, --version Show version +`); + process.exit(0); + } else if (args[i] === "--version" || args[i] === "-v") { + console.log(pkg.version); + process.exit(0); + } +} + +// Auto-relaunch after update: detached process has no TTY → fallback to tray +if (skipUpdate && !trayMode && !process.stdin.isTTY) { + trayMode = true; + process.env.TRAY_MODE = "1"; +} + +// Always use Node.js runtime with absolute path +const RUNTIME = process.execPath; + +// Compare semver versions: returns 1 if a > b, -1 if a < b, 0 if equal +function compareVersions(a, b) { + const partsA = a.split(".").map(Number); + const partsB = b.split(".").map(Number); + for (let i = 0; i < 3; i++) { + if (partsA[i] > partsB[i]) return 1; + if (partsA[i] < partsB[i]) return -1; + } + return 0; +} + +// Get app data dir (matches app/src/lib/dataDir.js convention) +function getAppDataDir() { + return process.platform === "win32" + ? path.join(process.env.APPDATA || "", "9router") + : path.join(os.homedir(), ".9router"); +} + +// Kill PID from file (best-effort, removes file after) +function killByPidFile(pidFile) { + try { + if (!fs.existsSync(pidFile)) return; + const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10); + if (!pid) return; + try { + if (process.platform === "win32") { + execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); + } else { + process.kill(pid, "SIGKILL"); + } + } catch { } + try { fs.unlinkSync(pidFile); } catch { } + } catch { } +} + +// Kill tunnel processes (cloudflared/tailscale) by their PID files +function killTunnelByPidFile() { + const tunnelDir = path.join(getAppDataDir(), "tunnel"); + killByPidFile(path.join(tunnelDir, "cloudflared.pid")); + killByPidFile(path.join(tunnelDir, "tailscale.pid")); +} + +// Kill cloudflared whose --url targets this app's port (covers stale PID file case) +function killCloudflaredByAppPort(appPort) { + if (!appPort) return []; + const portMatchers = [`localhost:${appPort}`, `127.0.0.1:${appPort}`]; + const pids = []; + try { + if (process.platform === "win32") { + const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"cloudflared.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`; + const output = execSync(psCmd, { encoding: "utf8", windowsHide: true, timeout: 5000 }); + const lines = output.split("\n").slice(1).filter(l => l.trim()); + lines.forEach(line => { + if (portMatchers.some(m => line.includes(m))) { + const match = line.match(/^"(\d+)"/); + if (match && match[1]) pids.push(match[1]); + } + }); + } else { + const output = execSync("ps -eo pid,command 2>/dev/null", { encoding: "utf8", timeout: 5000 }); + output.split("\n").forEach(line => { + if (line.includes("cloudflared") && portMatchers.some(m => line.includes(m))) { + const parts = line.trim().split(/\s+/); + const pid = parts[0]; + if (pid && !isNaN(pid)) pids.push(pid); + } + }); + } + } catch { } + return pids; +} + +// Kill all 9router processes +function killAllAppProcesses(appPort) { + return new Promise((resolve) => { + try { + // Background: MITM + tunnel/cloudflared run on separate ports/processes — + // killing them doesn't free the app port, so don't block the critical path. + // Server-side MITM manager has stale-lock recovery and starts deferred (~3s). + setImmediate(() => { + try { killProxyByPidFile(); } catch {} + try { killTunnelByPidFile(); } catch {} + try { killCloudflaredByAppPort(appPort); } catch {} + }); + + const platform = process.platform; + let pids = []; + + if (platform === "win32") { + // Windows: use WMI to get full CommandLine (tasklist /V doesn't include it) + try { + const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"node.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`; + const output = execSync(psCmd, { + encoding: "utf8", + windowsHide: true, + timeout: 5000 + }); + const lines = output.split("\n").slice(1).filter(l => l.trim()); + lines.forEach(line => { + // 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 isAppProcess = + (cmd.includes("node") && cmd.includes("9router") && (cmd.includes("cli.js") || cmd.includes("\\9router") || cmd.includes("/9router"))) + || cmd.includes("next-server"); + if (isAppProcess) { + const match = line.match(/^"(\d+)"/); + if (match && match[1] && match[1] !== process.pid.toString()) { + pids.push(match[1]); + } + } + }); + } catch (e) { + // No processes found or error - continue + } + } else { + // macOS/Linux: use ps to find all matching processes + try { + const output = execSync('ps aux 2>/dev/null', { + encoding: 'utf8', + timeout: 5000 + }); + const lines = output.split('\n'); + + lines.forEach(line => { + // 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 isAppProcess = + (cmd.includes("node") && cmd.includes("9router") && (cmd.includes("cli.js") || cmd.includes("/9router"))) + || cmd.includes("next-server"); + if (isAppProcess) { + const parts = line.trim().split(/\s+/); + const pid = parts[1]; + if (pid && !isNaN(pid) && pid !== process.pid.toString()) { + pids.push(pid); + } + } + }); + } catch (e) { + // No processes found or error - continue + } + } + + // Kill all found processes + if (pids.length > 0) { + pids.forEach(pid => { + try { + if (platform === "win32") { + execSync(`taskkill /F /PID ${pid} 2>nul`, { stdio: 'ignore', shell: true, windowsHide: true, timeout: 3000 }); + } else { + execSync(`kill -9 ${pid} 2>/dev/null`, { stdio: 'ignore', timeout: 3000 }); + } + } catch (err) { + // Process already dead or can't kill - continue + } + }); + + // Wait for processes to fully terminate + setTimeout(() => resolve(), 1000); + } else { + resolve(); + } + } catch (err) { + // Silent fail - continue anyway + resolve(); + } + }); +} + +// Sleep helper using SharedArrayBuffer wait (sync, no busy-loop) +function sleepSync(ms) { + try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* ignore */ } +} + +// Wait until process dies or timeout reached +function waitForExit(pid, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { process.kill(pid, 0); } catch { return true; } + sleepSync(100); + } + return false; +} + +// Kill MIT server by PID file (runs privileged, needs special handling) +// Sends SIGTERM first so MIT can clean up host entries before dying. +function killProxyByPidFile() { + try { + const pidFile = path.join(getAppDataDir(), "mitm", ".mitm.pid"); + if (!fs.existsSync(pidFile)) return; + const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10); + if (!pid) return; + + if (process.platform === "win32") { + // Graceful first (lets server cleanup hosts), then force + try { execSync(`taskkill /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 2000 }); } catch { } + if (!waitForExit(pid, 1500)) { + try { execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { } + } + // Last-resort: PowerShell Stop-Process (sometimes succeeds where taskkill fails on admin processes) + if (!waitForExit(pid, 500)) { + try { execSync(`powershell -NonInteractive -WindowStyle Hidden -Command "Stop-Process -Id ${pid} -Force"`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { } + } + } else { + // SIGTERM via cached sudo token first + try { execSync(`sudo -n kill -TERM ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); } + catch { try { process.kill(pid, "SIGTERM"); } catch { } } + if (!waitForExit(pid, 1500)) { + try { execSync(`sudo -n kill -9 ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); } + catch { try { process.kill(pid, "SIGKILL"); } catch { } } + } + } + try { fs.unlinkSync(pidFile); } catch { } + } catch { } +} + +// Kill any process on specific port +function killProcessOnPort(port) { + return new Promise((resolve) => { + try { + const platform = process.platform; + let pid; + + if (platform === "win32") { + try { + const output = execSync(`netstat -ano | findstr :${port}`, { + encoding: 'utf8', + shell: true, + windowsHide: true, + timeout: 5000 + }).trim(); + const lines = output.split('\n').filter(l => l.includes('LISTENING')); + if (lines.length > 0) { + pid = lines[0].trim().split(/\s+/).pop(); + execSync(`taskkill /F /PID ${pid} 2>nul`, { stdio: 'ignore', shell: true, windowsHide: true, timeout: 3000 }); + } + } catch (e) { + // Port is free or error + } + } else { + // macOS/Linux + try { + const pidOutput = execSync(`lsof -ti:${port}`, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'] + }).trim(); + if (pidOutput) { + pid = pidOutput.split('\n')[0]; + execSync(`kill -9 ${pid} 2>/dev/null`, { stdio: 'ignore', timeout: 3000 }); + } + } catch (e) { + // Port is free or error + } + } + + // Wait for port to be released + setTimeout(() => resolve(), 500); + } catch (err) { + // Silent fail - continue anyway + resolve(); + } + }); +} + + +// Detect if running in restricted environment (Codespaces, Docker) +function isRestrictedEnvironment() { + // Check for Codespaces + if (process.env.CODESPACES === "true" || process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN) { + return "GitHub Codespaces"; + } + + // Check for Docker + if (fs.existsSync("/.dockerenv") || (fs.existsSync("/proc/1/cgroup") && fs.readFileSync("/proc/1/cgroup", "utf8").includes("docker"))) { + return "Docker"; + } + + return null; +} + +// Check if new version available, return latest version or null +function checkForUpdate() { + return new Promise((resolve) => { + if (skipUpdate) { + resolve(null); + return; + } + + const spinner = createSpinner("Checking for updates...").start(); + let resolved = false; + + const safetyTimeout = setTimeout(() => { + if (!resolved) { + resolved = true; + spinner.stop(); + resolve(null); + } + }, 8000); + + const done = (version) => { + if (resolved) return; + resolved = true; + clearTimeout(safetyTimeout); + spinner.stop(); + resolve(version); + }; + + const req = https.get(`https://registry.npmjs.org/${pkg.name}/latest`, { timeout: 3000 }, (res) => { + let data = ""; + res.on("data", chunk => data += chunk); + res.on("end", () => { + try { + const latest = JSON.parse(data); + if (latest.version && compareVersions(latest.version, pkg.version) > 0) { + done(latest.version); + } else { + done(null); + } + } catch (e) { + done(null); + } + }); + }); + + req.on("error", () => done(null)); + req.on("timeout", () => { req.destroy(); done(null); }); + }); +} + +// Open browser +function openBrowser(url) { + const platform = process.platform; + let cmd; + + if (platform === "darwin") { + cmd = `open "${url}"`; + } else if (platform === "win32") { + cmd = `start "" "${url}"`; + } else { + cmd = `xdg-open "${url}"`; + } + + exec(cmd, { windowsHide: true }, (err) => { + if (err) { + console.log(`Open browser manually: ${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 customServerPath = path.join(standaloneDir, "custom-server.js"); +const serverPath = fs.existsSync(customServerPath) + ? customServerPath + : path.join(standaloneDir, "server.js"); + +if (!fs.existsSync(serverPath)) { + console.error("Error: Standalone build not found."); + console.error("Please run 'npm run build:cli' first."); + process.exit(1); +} + +// Start server immediately; run update check in parallel (not on the critical path). +const updatePromise = checkForUpdate(); +killAllAppProcesses(port) + .then(() => killProcessOnPort(port)) + .then(() => startServer(updatePromise)); + +// 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[32m${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 + +function startServer(updatePromise) { + // Accept either a Promise (parallel update check) or a resolved value. + const latestVersionPromise = Promise.resolve(updatePromise); + 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) { + 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`); + } + + let restartCount = 0; + let serverStartTime = Date.now(); + + const CRASH_LOG_LINES = 50; + let crashLog = []; + + function spawnServer() { + serverStartTime = Date.now(); + crashLog = []; + const child = spawn(RUNTIME, ["--max-old-space-size=6144", serverPath], { + cwd: standaloneDir, + stdio: showLog ? "inherit" : ["ignore", "ignore", "pipe"], + detached: true, + windowsHide: true, + env: { + ...buildEnvWithRuntime(process.env), + PORT: port.toString(), + HOSTNAME: host + } + }); + if (!showLog && child.stderr) { + child.stderr.on("data", (data) => { + const lines = data.toString().split("\n").filter(Boolean); + crashLog.push(...lines); + if (crashLog.length > CRASH_LOG_LINES) crashLog = crashLog.slice(-CRASH_LOG_LINES); + }); + } + return child; + } + + let server = spawnServer(); + + // Cleanup function - force kill server process + let isCleaningUp = false; + function cleanup() { + if (isCleaningUp) return; + isCleaningUp = true; + try { + // Kill tray if running + try { + const { killTray } = require("./src/cli/tray/tray"); + killTray(); + } catch (e) { } + // Kill MIT server (privileged process) via PID file + killProxyByPidFile(); + // Kill cloudflared/tailscale via PID file (only this app's tunnel) + killTunnelByPidFile(); + // Kill server process directly + if (server.pid) { + process.kill(server.pid, "SIGKILL"); + } + // Also try to kill process group + process.kill(-server.pid, "SIGKILL"); + } catch (e) { } + } + + // Suppress all errors during shutdown (systray lib throws JSON parse errors) + let isShuttingDown = false; + process.on("uncaughtException", (err) => { + if (isShuttingDown) return; + console.error("Error:", err.message); + }); + + // Handle all exit scenarios + process.on("SIGINT", () => { + if (isShuttingDown) return; + isShuttingDown = true; + console.log("\nExiting..."); + cleanup(); + setTimeout(() => process.exit(0), 100); + }); + process.on("SIGTERM", () => { + if (isShuttingDown) return; + isShuttingDown = true; + cleanup(); + setTimeout(() => process.exit(0), 100); + }); + process.on("SIGHUP", () => { + if (isShuttingDown) return; + isShuttingDown = true; + cleanup(); + setTimeout(() => process.exit(0), 100); + }); + + // Initialize tray icon (runs alongside TUI) + const initTrayIcon = () => { + try { + const { initTray } = require("./src/cli/tray/tray"); + initTray({ + port, + onQuit: () => { + isShuttingDown = true; + console.log("\n👋 Shutting down from tray..."); + cleanup(); + setTimeout(() => process.exit(0), 100); + }, + onOpenDashboard: () => openBrowser(url) + }); + } catch (err) { + // Tray not available - continue without it + } + }; + + // Tray-only mode: no TUI, just tray icon + if (trayMode) { + // Ignore SIGHUP so macOS terminal close doesn't kill the background tray process + process.removeAllListeners("SIGHUP"); + process.on("SIGHUP", () => {}); + + console.log(`\n🚀 ${pkg.name} v${pkg.version}`); + console.log(`Server: http://${displayHost}:${port}`); + + waitServerReady(port).then(() => { + initTrayIcon(); + console.log("\n💡 Router is now running in system tray. Close this terminal if you want."); + console.log(" Right-click tray icon to open dashboard or quit.\n"); + }); + + return; + } + + // Wait for server to be ready, then show interface menu loop + tray + waitServerReady(port).then(async () => { + // Resolve parallel update check (already running); don't block server start on it. + const latestVersion = await latestVersionPromise; + // Start tray icon alongside TUI + initTrayIcon(); + + try { + while (true) { + const choice = await showInterfaceMenu(latestVersion); + + if (choice === "update") { + isShuttingDown = true; + 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(` \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") { + 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 + const { startTerminalUI } = require("./src/cli/terminalUI"); + await startTerminalUI(port); + // Loop continues, show menu again + } else if (choice === "hide") { + const { clearScreen } = require("./src/cli/utils/display"); + clearScreen(); + + // Enable auto startup on OS boot + try { + const { enableAutoStart } = require("./src/cli/tray/autostart"); + enableAutoStart(__filename); + } 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. + 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. + 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()], { + 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 + cleanup(); + process.exit(0); + } else if (choice === "exit") { + isShuttingDown = true; + console.log("\nExiting..."); + cleanup(); + setTimeout(() => process.exit(0), 100); + } + } + } catch (err) { + console.error("Error:", err.message); + cleanup(); + process.exit(1); + } + }); + + function attachServerEvents() { + server.on("error", (err) => { + console.error("Failed to start server:", err.message); + if (!isShuttingDown) tryRestart(); + else { cleanup(); process.exit(1); } + }); + + server.on("close", (code) => { + if (isShuttingDown || code === 0) { + process.exit(code || 0); + return; + } + tryRestart(code); + }); + } + + function tryRestart(code) { + const aliveMs = Date.now() - serverStartTime; + // Reset counter if last run was stable + if (aliveMs >= RESTART_RESET_MS) restartCount = 0; + + if (restartCount >= MAX_RESTARTS) { + 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")); + if (fs.existsSync(dbPath)) { + const db = JSON.parse(fs.readFileSync(dbPath, "utf-8")); + if (db.settings) db.settings.mitmEnabled = false; + fs.writeFileSync(dbPath, JSON.stringify(db, null, 2)); + } + } catch { /* best effort */ } + restartCount = 0; + server = spawnServer(); + attachServerEvents(); + return; + } + + restartCount++; + const delay = Math.min(1000 * restartCount, 10000); + console.error(`\n⚠️ Server exited (code=${code ?? "unknown"}). Restarting in ${delay / 1000}s... (${restartCount}/${MAX_RESTARTS})`); + if (crashLog.length) { + console.error("\n--- Server crash log ---"); + crashLog.forEach(l => console.error(l)); + console.error("--- End crash log ---\n"); + } + + setTimeout(() => { + server = spawnServer(); + attachServerEvents(); + }, delay); + } + + attachServerEvents(); +} diff --git a/cli/hooks/postinstall.js b/cli/hooks/postinstall.js new file mode 100644 index 0000000..3a59332 --- /dev/null +++ b/cli/hooks/postinstall.js @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +// Postinstall: warm-up SQLite deps into ~/.9router/runtime so the first +// `9router` start doesn't need network. Failure here is non-fatal — +// cli.js will retry at runtime if anything is missing. +const { ensureSqliteRuntime } = require("./sqliteRuntime"); +const { ensureTrayRuntime } = require("./trayRuntime"); + +try { + ensureSqliteRuntime({ silent: false }); + console.log("[9router] runtime SQLite deps ready"); +} catch (e) { + console.warn(`[9router] runtime warm-up skipped: ${e.message}`); +} + +try { + ensureTrayRuntime({ silent: false }); +} catch (e) { + console.warn(`[9router] tray runtime skipped: ${e.message}`); +} + +process.exit(0); diff --git a/cli/hooks/sqliteRuntime.js b/cli/hooks/sqliteRuntime.js new file mode 100644 index 0000000..feca2f5 --- /dev/null +++ b/cli/hooks/sqliteRuntime.js @@ -0,0 +1,156 @@ +// Ensure better-sqlite3 is installed in USER_DATA_DIR/runtime/node_modules +// (user-writable, avoids Windows EBUSY locks during npm i -g updates). +// sql.js is bundled in bin/app already; node:sqlite / bun:sqlite are built-in. +const { execSync, spawnSync } = require("child_process"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const BETTER_SQLITE3_VERSION = "12.6.2"; +const SQL_JS_VERSION = "1.14.1"; + +function getDataDir() { + if (process.env.DATA_DIR) return process.env.DATA_DIR; + return process.platform === "win32" + ? path.join(process.env.APPDATA || os.homedir(), "9router") + : path.join(os.homedir(), ".9router"); +} + +function getRuntimeDir() { + return path.join(getDataDir(), "runtime"); +} + +function getRuntimeNodeModules() { + return path.join(getRuntimeDir(), "node_modules"); +} + +function ensureRuntimeDir() { + const dir = getRuntimeDir(); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + // Minimal package.json so npm treats it as a project root + const pkgPath = path.join(dir, "package.json"); + if (!fs.existsSync(pkgPath)) { + fs.writeFileSync(pkgPath, JSON.stringify({ + name: "9router-runtime", + version: "1.0.0", + private: true, + description: "User-writable runtime deps for 9router (better-sqlite3 native binary)", + }, null, 2)); + } + return dir; +} + +function hasModule(name) { + return fs.existsSync(path.join(getRuntimeNodeModules(), name, "package.json")); +} + +function isBetterSqliteBinaryValid() { + const binary = path.join(getRuntimeNodeModules(), "better-sqlite3", "build", "Release", "better_sqlite3.node"); + if (!fs.existsSync(binary)) return false; + try { + const fd = fs.openSync(binary, "r"); + const buf = Buffer.alloc(4); + fs.readSync(fd, buf, 0, 4, 0); + fs.closeSync(fd); + const magic = buf.toString("hex"); + if (process.platform === "linux") return magic.startsWith("7f454c46"); + if (process.platform === "darwin") return magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); + if (process.platform === "win32") return magic.startsWith("4d5a"); + return true; + } catch { return false; } +} + +// Extract a short, user-friendly reason from npm stderr. +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 (check folder permissions)"; + if (/ENOSPC|no space/i.test(text)) return "Not enough disk space"; + if (/node-gyp|gyp ERR|python|MSBuild|Visual Studio|Xcode/i.test(text)) return "Missing build tools (Xcode CLT / Python / VS Build Tools)"; + if (/ETARGET|version.*not found/i.test(text)) return "Package version not found on registry"; + const m = text.match(/npm ERR! (.+)/); + if (m) return m[1].slice(0, 200); + const lastLine = text.trim().split(/\r?\n/).filter(Boolean).pop(); + return lastLine ? lastLine.slice(0, 200) : "Unknown error"; +} + +function runNpmInstall({ cwd, pkgs, extraArgs = [], timeout = 180000 }) { + const args = ["install", ...pkgs, "--no-audit", "--no-fund", "--prefer-online", ...extraArgs]; + const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"; + const res = spawnSync(npmCmd, args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + timeout, + shell: process.platform === "win32", + encoding: "utf8", + }); + return { ok: res.status === 0, code: res.status, stderr: res.stderr || "", stdout: res.stdout || "" }; +} + +function npmInstall(pkgs, opts = {}) { + const cwd = ensureRuntimeDir(); + const extra = opts.optional ? ["--no-save"] : []; + if (!opts.silent) console.log("⏳ Installing SQLite engine (first run)..."); + const res = runNpmInstall({ cwd, pkgs, extraArgs: extra, timeout: opts.timeout || 180000 }); + if (!res.ok && !opts.silent) { + const reason = summarizeNpmError(res.stderr); + console.warn("⚠️ SQLite engine install failed — using fallback"); + console.warn(` Reason: ${reason}`); + console.warn(` Retry: cd "${cwd}" && npm install ${pkgs.join(" ")}`); + } + return res.ok; +} + +// Public: ensure better-sqlite3 native module is installed in user-writable +// runtime dir. sql.js may be bundled in bin/app, but npm publish strips .wasm +// from nested node_modules — verify and reinstall if missing. node:sqlite is +// built-in. This is purely a *speed optimization* — app works without +// better-sqlite3 via fallbacks. +function isSqlJsWasmValid() { + const bundledWasm = path.join(__dirname, "..", "app", "node_modules", "sql.js", "dist", "sql-wasm.wasm"); + if (fs.existsSync(bundledWasm)) return true; + const runtimeWasm = path.join(getRuntimeNodeModules(), "sql.js", "dist", "sql-wasm.wasm"); + return fs.existsSync(runtimeWasm); +} + +function ensureSqliteRuntime({ silent = false } = {}) { + ensureRuntimeDir(); + + let sqlJsOk = isSqlJsWasmValid(); + if (!sqlJsOk) { + sqlJsOk = npmInstall([`sql.js@${SQL_JS_VERSION}`], { silent }); + if (sqlJsOk) sqlJsOk = isSqlJsWasmValid(); + } + + const needBetterSqlite = !hasModule("better-sqlite3") || !isBetterSqliteBinaryValid(); + if (!needBetterSqlite) { + if (!silent) console.log("✅ SQLite engine ready"); + return { betterSqlite: true, sqlJs: sqlJsOk }; + } + + const ok = npmInstall([`better-sqlite3@${BETTER_SQLITE3_VERSION}`], { optional: true, silent }); + return { + betterSqlite: ok && hasModule("better-sqlite3") && isBetterSqliteBinaryValid(), + sqlJs: sqlJsOk, + }; +} + +// Inject runtime + bundled node_modules into NODE_PATH so child Node processes +// resolve sql.js (bundled in bin/app/node_modules) and better-sqlite3 (runtime). +function buildEnvWithRuntime(baseEnv = process.env) { + const runtimeNm = getRuntimeNodeModules(); + const bundledNm = path.join(__dirname, "..", "app", "node_modules"); + const existing = baseEnv.NODE_PATH || ""; + const NODE_PATH = [runtimeNm, bundledNm, existing].filter(Boolean).join(path.delimiter); + return { ...baseEnv, NODE_PATH }; +} + +module.exports = { + ensureSqliteRuntime, + buildEnvWithRuntime, + getRuntimeDir, + getRuntimeNodeModules, + runNpmInstall, + summarizeNpmError, +}; diff --git a/cli/hooks/trayRuntime.js b/cli/hooks/trayRuntime.js new file mode 100644 index 0000000..dafb215 --- /dev/null +++ b/cli/hooks/trayRuntime.js @@ -0,0 +1,107 @@ +// Lazy install systray2 for macOS/Linux into USER_DATA_DIR/runtime/node_modules. +// Windows uses PowerShell NotifyIcon (no binary) → no systray needed. +// This keeps the published npm tarball free of unsigned Go binaries that +// trigger antivirus false positives (e.g. Kaspersky flagging tray_windows.exe). +// +// We use the maintained `systray2` fork. The original `systray@1.0.5` package +// bundles a 2017 x86_64 Go binary whose Mach-O headers are rejected by modern +// dyld (macOS 14+), so the tray silently fails to register on Apple Silicon. +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); +const { getRuntimeDir, getRuntimeNodeModules, runNpmInstall, summarizeNpmError } = require("./sqliteRuntime"); + +const SYSTRAY_PKG = "systray2"; +const SYSTRAY_VERSION = "2.1.4"; +const LEGACY_SYSTRAY_PKG = "systray"; + +function hasSystray() { + return fs.existsSync(path.join(getRuntimeNodeModules(), SYSTRAY_PKG, "package.json")); +} + +// Remove the legacy `systray` package from all known locations. +// On Windows it was an AV false-positive risk; on macOS/Linux its bundled +// binary is broken on modern OS versions. +function cleanupLegacySystray({ silent = false } = {}) { + // 1) Runtime dir: ~/.9router/runtime/node_modules/systray (or %APPDATA% on Win) + // 2) npm global nested: /node_modules/9router/node_modules/systray + // __dirname here = /hooks → up 1 = pkg root + const targets = [ + path.join(getRuntimeNodeModules(), LEGACY_SYSTRAY_PKG), + path.join(__dirname, "..", "node_modules", LEGACY_SYSTRAY_PKG) + ]; + for (const dir of targets) { + if (fs.existsSync(dir)) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + if (!silent) console.log(`[9router][runtime] removed legacy systray: ${dir}`); + } catch (e) { + if (!silent) console.warn(`[9router][runtime] failed to remove ${dir}: ${e.message}`); + } + } + } +} + +// systray2's npm tarball sometimes ships the bundled Go binary without the +// executable bit set on macOS, causing spawn() to fail with EACCES. Set +x +// best-effort so the tray actually starts. +function chmodSystrayBin({ silent = false } = {}) { + if (process.platform === "win32") return; + const binName = process.platform === "darwin" ? "tray_darwin_release" : "tray_linux_release"; + const binPath = path.join(getRuntimeNodeModules(), SYSTRAY_PKG, "traybin", binName); + if (!fs.existsSync(binPath)) return; + try { + fs.chmodSync(binPath, 0o755); + } catch (e) { + if (!silent) console.warn(`[9router][runtime] chmod tray bin failed: ${e.message}`); + } +} + +function ensureRuntimeDir() { + const dir = getRuntimeDir(); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const pkgPath = path.join(dir, "package.json"); + if (!fs.existsSync(pkgPath)) { + fs.writeFileSync(pkgPath, JSON.stringify({ + name: "9router-runtime", + version: "1.0.0", + private: true + }, null, 2)); + } + return dir; +} + +function npmInstall(pkgs, { silent = false } = {}) { + const cwd = ensureRuntimeDir(); + if (!silent) console.log("⏳ Installing system tray (first run)..."); + const res = runNpmInstall({ cwd, pkgs, extraArgs: ["--no-save"], timeout: 120000 }); + if (!res.ok && !silent) { + const reason = summarizeNpmError(res.stderr); + console.warn("⚠️ System tray install failed — tray disabled"); + console.warn(` Reason: ${reason}`); + console.warn(` Retry: cd "${cwd}" && npm install ${pkgs.join(" ")}`); + } + return res.ok; +} + +// Public: ensure systray2 is installed on macOS/Linux only. +// Windows skips entirely (uses PowerShell tray). +function ensureTrayRuntime({ silent = false } = {}) { + // Always evict the legacy `systray` package — its binary is broken on + // modern macOS and an AV false-positive on Windows. + cleanupLegacySystray({ silent }); + + if (process.platform === "win32") { + return { systray: false, skipped: true }; + } + if (hasSystray()) { + chmodSystrayBin({ silent }); + if (!silent) console.log("✅ System tray ready"); + return { systray: true }; + } + const ok = npmInstall([`${SYSTRAY_PKG}@${SYSTRAY_VERSION}`], { silent }); + if (ok) chmodSystrayBin({ silent }); + return { systray: ok && hasSystray() }; +} + +module.exports = { ensureTrayRuntime }; diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..e72fd18 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,48 @@ +{ + "name": "9router", + "version": "0.5.30", + "description": "9Router CLI - Start and manage 9Router server", + "bin": { + "9router": "./cli.js" + }, + "files": [ + "cli.js", + "src", + "hooks", + "app", + "README.md", + "LICENSE" + ], + "scripts": { + "dev": "nodemon -I --watch cli.js --watch src --watch hooks --ext js,json cli.js", + "build": "node scripts/build-cli.js", + "pack:cli": "npm run build && npm pack --pack-destination ../..", + "publish:cli": "npm run build && npm publish", + "postinstall": "node hooks/postinstall.js", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "enquirer": "^2.4.1", + "node-forge": "^1.3.3", + "node-machine-id": "^1.1.12", + "react": "19.2.1", + "react-dom": "19.2.1" + }, + "comment_sqlite": "sql.js + better-sqlite3 are NOT bundled here. They are installed into ~/.9router/runtime/node_modules by hooks/postinstall.js (and re-checked at runtime by cli.js). This avoids Windows EBUSY errors when updating the global CLI, since native .node files no longer live under the locked install dir.", + "comment_systray": "systray2 is NOT bundled here. It is lazy-installed into ~/.9router/runtime/node_modules by hooks/postinstall.js on macOS/Linux only. Windows uses PowerShell NotifyIcon (zero binary). This avoids shipping unsigned Go binaries that trigger antivirus false positives (Kaspersky). We use the systray2 fork because the legacy systray@1.0.5 ships a 2017 x86_64 binary that fails on modern macOS dyld.", + "engines": { + "node": ">=18.0.0" + }, + "keywords": [ + "9router", + "cli", + "proxy", + "ai", + "api" + ], + "license": "MIT", + "devDependencies": { + "esbuild": "^0.25.12", + "nodemon": "^3.1.14" + } +} diff --git a/cli/scripts/build-cli.js b/cli/scripts/build-cli.js new file mode 100644 index 0000000..a1b28c5 --- /dev/null +++ b/cli/scripts/build-cli.js @@ -0,0 +1,281 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const path = require("path"); +const { execSync } = require("child_process"); + +const cliDir = path.resolve(__dirname, ".."); +const appDir = path.resolve(cliDir, ".."); +const rootDir = path.resolve(appDir, ".."); +const cliAppDir = process.env.NINEROUTER_CLI_APP_DIR || path.join(cliDir, "app"); +const buildHomeDir = path.join(cliDir, ".build-home"); +const buildDistDirName = ".next-cli-build"; +const buildDistDir = path.join(appDir, buildDistDirName); + +// Exclude patterns for files/folders we don't want to copy +const EXCLUDE_PATTERNS = [ + "@img", // Sharp image processing (not needed with unoptimized images) + "sharp", // Sharp core lib (not needed with unoptimized images) + "detect-libc", // Sharp dependency + ".env", // Environment files + ".env.local", + ".env.*.local", + "*.log", // Log files + "tmp", // Temp files + ".DS_Store", // macOS files +]; + +function shouldExclude(name) { + return EXCLUDE_PATTERNS.some(pattern => { + if (pattern.includes("*")) { + const regex = new RegExp("^" + pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$"); + return regex.test(name); + } + return name === pattern; + }); +} + +function copyRecursive(src, dest) { + if (!fs.existsSync(src)) { + console.warn(`Warning: Source ${src} does not exist`); + return; + } + + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest, { recursive: true }); + } + + const entries = fs.readdirSync(src, { withFileTypes: true }); + for (const entry of entries) { + if (shouldExclude(entry.name)) { + continue; + } + + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + // Skip broken symlinks (common in workspace setups) + try { + fs.accessSync(srcPath); + } catch { + continue; + } + + if (entry.isDirectory()) { + copyRecursive(srcPath, destPath); + } else if (entry.isSymbolicLink()) { + // Resolve and copy target (avoid linking outside bundle) + try { + const real = fs.realpathSync(srcPath); + if (fs.statSync(real).isDirectory()) { + copyRecursive(real, destPath); + } else { + fs.copyFileSync(real, destPath); + } + } catch {} + } else { + try { + fs.copyFileSync(srcPath, destPath); + } catch {} + } + } +} + +console.log("📦 Building 9Router CLI package with Next.js...\n"); + +fs.mkdirSync(buildHomeDir, { recursive: true }); +fs.mkdirSync(path.join(buildHomeDir, "AppData", "Roaming"), { recursive: true }); +fs.mkdirSync(path.join(buildHomeDir, "AppData", "Local"), { recursive: true }); + +// Step 0: Sync version from app/cli/package.json to app/package.json +console.log("0️⃣ Syncing version to app/package.json..."); +const cliPkg = JSON.parse(fs.readFileSync(path.join(cliDir, "package.json"), "utf8")); +const appPkgPath = path.join(appDir, "package.json"); +const appPkg = JSON.parse(fs.readFileSync(appPkgPath, "utf8")); +if (appPkg.version !== cliPkg.version) { + appPkg.version = cliPkg.version; + fs.writeFileSync(appPkgPath, JSON.stringify(appPkg, null, 2) + "\n"); + console.log(`✅ Version synced: ${cliPkg.version}\n`); +} else { + console.log(`✅ Version already synced: ${cliPkg.version}\n`); +} + +// Step 1: Build app with Next.js (workspace tracing root → traced node_modules in standalone). +console.log("1️⃣ Building Next.js app..."); +try { + execSync("npm run build", { + stdio: "inherit", + cwd: appDir, + env: { + ...process.env, + HOME: buildHomeDir, + USERPROFILE: buildHomeDir, + APPDATA: path.join(buildHomeDir, "AppData", "Roaming"), + LOCALAPPDATA: path.join(buildHomeDir, "AppData", "Local"), + NEXT_DIST_DIR: buildDistDirName, + NEXT_TRACING_ROOT_MODE: "workspace", + } + }); + console.log("✅ Next.js build completed\n"); +} catch (error) { + console.error("❌ Next.js build failed"); + process.exit(1); +} + +// Step 2: Clean old app/cli/app if exists +console.log("2️⃣ Cleaning old app/cli/app..."); +if (fs.existsSync(cliAppDir)) { + fs.rmSync(cliAppDir, { recursive: true, force: true }); +} +console.log("✅ Cleaned\n"); + +// Step 3: Copy Next.js standalone build to app/cli/app. +// Newer Next.js standalone output writes server.js/package.json plus .next/, src/, and +// node_modules/ directly under .next/standalone. Older builds may still use a nested app/. +console.log("3️⃣ Copying Next.js standalone build to app/cli/app..."); +const standaloneRoot = path.join(appDir, ".next", "standalone"); +const standaloneRootResolved = path.join(buildDistDir, "standalone"); +let standaloneRootToUse = fs.existsSync(standaloneRootResolved) ? standaloneRootResolved : standaloneRoot; +// Next.js 16 nests standalone output under the project name when NEXT_TRACING_ROOT_MODE=workspace +// e.g. .next-cli-build/standalone/9router/server.js +const pkgName = path.basename(appDir); +const nestedRoot = path.join(standaloneRootToUse, pkgName); +if (fs.existsSync(path.join(nestedRoot, "server.js")) && !fs.existsSync(path.join(standaloneRootToUse, "server.js"))) { + console.log(`ℹ️ Detected nested standalone output: ${pkgName}/`); + standaloneRootToUse = nestedRoot; +} +const standaloneApp = fs.existsSync(path.join(standaloneRootToUse, "server.js")) + ? standaloneRootToUse + : path.join(standaloneRootToUse, "app"); +if (!fs.existsSync(standaloneApp)) { + console.error("❌ Next.js standalone build not found under .next/standalone"); + console.error("Expected either .next/standalone/server.js or .next/standalone/app/"); + process.exit(1); +} +copyRecursive(standaloneApp, cliAppDir); + +// Older nested-app layout stores traced node_modules at standalone root. +const standaloneNodeModules = path.join(standaloneRootToUse, "node_modules"); +if (standaloneApp !== standaloneRootToUse && fs.existsSync(standaloneNodeModules)) { + copyRecursive(standaloneNodeModules, path.join(cliAppDir, "node_modules")); +} +console.log("✅ Copied standalone build\n"); + +// Step 3a: Copy custom server (injects real socket IP, strips spoofable XFF). +const customServerSrc = path.join(appDir, "custom-server.js"); +if (fs.existsSync(customServerSrc)) { + fs.copyFileSync(customServerSrc, path.join(cliAppDir, "custom-server.js")); + console.log("✅ Copied custom-server.js\n"); +} else { + console.warn("⚠️ custom-server.js not found — server will run without real-IP injection\n"); +} + +// Step 3b: Ensure sql.js (pure JS fallback) bundled in app/cli/app/node_modules. +// Strip better-sqlite3 (native) — it lives in ~/.9router/runtime to avoid +// Windows EBUSY during global CLI updates. node:sqlite (Node ≥22.5) is also +// available as a no-install middle tier. +console.log("3️⃣ b Configuring SQLite drivers..."); +function ensureModuleInBundle(pkg) { + const dest = path.join(cliAppDir, "node_modules", pkg); + if (fs.existsSync(dest)) { + console.log(`✅ ${pkg} already bundled`); + return; + } + const candidates = [ + path.join(appDir, "node_modules", pkg), + path.join(rootDir, "node_modules", pkg), + ]; + const src = candidates.find((p) => fs.existsSync(p)); + if (!src) { + console.warn(`⚠️ ${pkg} not found locally — bundle will rely on node:sqlite or runtime install`); + return; + } + fs.mkdirSync(path.dirname(dest), { recursive: true }); + copyRecursive(src, dest); + console.log(`✅ Bundled ${pkg}`); +} +ensureModuleInBundle("sql.js"); +const betterDir = path.join(cliAppDir, "node_modules", "better-sqlite3"); +if (fs.existsSync(betterDir)) { + fs.rmSync(betterDir, { recursive: true, force: true }); + console.log("✅ Stripped better-sqlite3 (lives in ~/.9router/runtime)"); +} +console.log(""); + +// Step 4: Copy static files +console.log("4️⃣ Copying static files..."); +const staticSrc = path.join(appDir, ".next", "static"); +const staticSrcResolved = path.join(buildDistDir, "static"); +const staticDest = path.join(cliAppDir, buildDistDirName, "static"); +if (fs.existsSync(staticSrcResolved) || fs.existsSync(staticSrc)) { + copyRecursive(fs.existsSync(staticSrcResolved) ? staticSrcResolved : staticSrc, staticDest); + console.log("✅ Copied static files\n"); +} else { + console.log("⏭️ No static files found\n"); +} + +// Step 5: Copy public folder if exists +console.log("5️⃣ Copying public folder..."); +const publicSrc = path.join(appDir, "public"); +const publicDest = path.join(cliAppDir, "public"); +if (fs.existsSync(publicSrc)) { + copyRecursive(publicSrc, publicDest); + console.log("✅ Copied public folder\n"); +} else { + console.log("⏭️ No public folder found\n"); +} + +// Step 6: Copy vendor-chunks (required for production) +console.log("6️⃣ Copying vendor-chunks..."); +const vendorChunksSrc = path.join(appDir, ".next", "server", "vendor-chunks"); +const vendorChunksSrcResolved = path.join(buildDistDir, "server", "vendor-chunks"); +const vendorChunksDest = path.join(cliAppDir, buildDistDirName, "server", "vendor-chunks"); +if (fs.existsSync(vendorChunksSrcResolved) || fs.existsSync(vendorChunksSrc)) { + copyRecursive(fs.existsSync(vendorChunksSrcResolved) ? vendorChunksSrcResolved : vendorChunksSrc, vendorChunksDest); + console.log("✅ Copied vendor-chunks\n"); +} else { + console.log("⏭️ No vendor-chunks found\n"); +} + +// Step 7: Copy MITM server files (not bundled by Next.js standalone) +console.log("7️⃣ Copying MITM server files..."); +const mitmSrc = path.join(appDir, "src", "mitm"); +const mitmDest = path.join(cliAppDir, "src", "mitm"); +if (fs.existsSync(mitmSrc)) { + copyRecursive(mitmSrc, mitmDest); + console.log("✅ Copied MITM files\n"); +} else { + console.log("⏭️ No MITM files found\n"); +} + +// Step 7b: Copy standalone updater (headless Node process for install progress) +console.log("7️⃣ b Copying updater files..."); +const updaterSrc = path.join(appDir, "src", "lib", "updater"); +const updaterDest = path.join(cliAppDir, "src", "lib", "updater"); +if (fs.existsSync(updaterSrc)) { + copyRecursive(updaterSrc, updaterDest); + console.log("✅ Copied updater files\n"); +} else { + console.log("⏭️ No updater files found\n"); +} + +// Step 8: Build MITM server (config driven - see app/cli/scripts/buildMitm.js) +console.log("8️⃣ Building MITM server..."); +try { + execSync("node scripts/buildMitm.js", { stdio: "inherit", cwd: cliDir }); + console.log("✅ MITM server build completed\n"); +} catch (error) { + console.error("❌ MITM build failed"); + process.exit(1); +} + +console.log("✨ CLI package build completed!"); +console.log(`📁 Output: ${cliAppDir}`); + +try { + const { execSync: exec } = require("child_process"); + const size = exec(`du -sh "${cliAppDir}"`, { encoding: "utf8" }).trim(); + console.log(`📊 Package size: ${size.split("\t")[0]}`); +} catch (e) { + // Silent fail on size check +} diff --git a/cli/scripts/buildMitm.js b/cli/scripts/buildMitm.js new file mode 100644 index 0000000..e47f593 --- /dev/null +++ b/cli/scripts/buildMitm.js @@ -0,0 +1,71 @@ +const esbuild = require("esbuild"); +const fs = require("fs"); +const path = require("path"); + +// ── Build config ───────────────────────────────────────── +const BUILD_CONFIG = { + bundle: true, + minify: true, + cleanPlainFiles: true, +}; +// ───────────────────────────────────────────────────────── + +const cliDir = path.resolve(__dirname, ".."); +const appDir = path.resolve(cliDir, ".."); +const cliAppDir = process.env.NINEROUTER_CLI_APP_DIR || path.join(cliDir, "app"); +const cliMitmDir = path.join(cliAppDir, "src", "mitm"); +// Bundle everything — no externals. This keeps MITM runtime self-contained so +// it can be copied to DATA_DIR/runtime/ and spawned from there (escapes +// node_modules file locks that block `npm i -g 9router@latest` on Windows). +const EXTERNALS = []; +const ENTRIES = ["server.js"]; + +async function buildEntry(entry) { + const mitmSrc = path.join(appDir, "src", "mitm"); + const output = path.join(cliMitmDir, entry); + + const buildPlugin = { + name: "build-plugin", + setup(build) { + // Stub .git file scanned by esbuild + build.onResolve({ filter: /\.git/ }, args => ({ path: args.path, namespace: "git-stub" })); + build.onLoad({ filter: /.*/, namespace: "git-stub" }, () => ({ contents: "module.exports={}", loader: "js" })); + }, + }; + + const steps = []; + + if (BUILD_CONFIG.bundle) { + await esbuild.build({ + entryPoints: [path.join(mitmSrc, entry)], + bundle: true, + minify: BUILD_CONFIG.minify, + platform: "node", + target: "node18", + external: EXTERNALS, + plugins: [buildPlugin], + outfile: output, + }); + steps.push("bundled"); + if (BUILD_CONFIG.minify) steps.push("minified"); + } + + console.log(`✅ ${steps.join(" + ")} → ${output}`); +} + +async function run() { + const flags = Object.entries(BUILD_CONFIG).filter(([, v]) => v).map(([k]) => k).join(", "); + console.log(`⚙️ Config: ${flags}`); + + for (const entry of ENTRIES) await buildEntry(entry); + + if (BUILD_CONFIG.cleanPlainFiles) { + const keep = new Set(ENTRIES); + for (const name of fs.readdirSync(cliMitmDir)) { + if (!keep.has(name)) fs.rmSync(path.join(cliMitmDir, name), { recursive: true, force: true }); + } + console.log("✅ Removed plain MITM files from CLI bundle"); + } +} + +run().catch((e) => { console.error(e); process.exit(1); }); diff --git a/cli/src/cli/api/client.js b/cli/src/cli/api/client.js new file mode 100644 index 0000000..257fcd2 --- /dev/null +++ b/cli/src/cli/api/client.js @@ -0,0 +1,556 @@ +const http = require("http"); +const https = require("https"); +const crypto = require("crypto"); +const fs = require("node:fs"); +const path = require("node:path"); +const os = require("node:os"); +const { machineIdSync } = require("node-machine-id"); + +// Default configuration +const DEFAULT_CONFIG = { + host: "localhost", + port: 20128, + protocol: "http:", +}; + +const CLI_TOKEN_HEADER = "x-9r-cli-token"; +const CLI_TOKEN_SALT = "9r-cli-auth"; +const APP_NAME = "9router"; + +function getDataDir() { + if (process.env.DATA_DIR) return process.env.DATA_DIR; + if (process.platform === "win32") { + return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), APP_NAME); + } + return path.join(os.homedir(), `.${APP_NAME}`); +} + +const MACHINE_ID_FILE = path.join(getDataDir(), "machine-id"); +const AUTH_DIR = path.join(getDataDir(), "auth"); +const CLI_SECRET_FILE = path.join(AUTH_DIR, "cli-secret"); + +let config = { ...DEFAULT_CONFIG }; +let cachedCliToken = null; +let cachedCliSecret = null; + +// Read raw machineId from shared file (written by server) → guarantees token match +function loadRawMachineId() { + try { + const raw = fs.readFileSync(MACHINE_ID_FILE, "utf8").trim(); + if (raw) return raw; + } catch {} + try { return machineIdSync(); } catch { return ""; } +} + +// Random secret shared with server via file → token unpredictable from machineId alone. +function loadCliSecret() { + if (cachedCliSecret) return cachedCliSecret; + try { + cachedCliSecret = fs.readFileSync(CLI_SECRET_FILE, "utf8").trim(); + if (cachedCliSecret) return cachedCliSecret; + } catch {} + cachedCliSecret = crypto.randomBytes(32).toString("hex"); + try { + fs.mkdirSync(AUTH_DIR, { recursive: true }); + fs.writeFileSync(CLI_SECRET_FILE, cachedCliSecret, { mode: 0o600 }); + } catch {} + return cachedCliSecret; +} + +function getCliToken() { + if (cachedCliToken !== null) return cachedCliToken; + const raw = loadRawMachineId(); + const secret = loadCliSecret(); + cachedCliToken = raw ? crypto.createHash("sha256").update(raw + CLI_TOKEN_SALT + secret).digest("hex").substring(0, 16) : ""; + return cachedCliToken; +} + +/** + * Configure API client + * @param {Object} options - Configuration options + * @param {string} options.host - API host + * @param {number} options.port - API port + * @param {string} options.protocol - Protocol (http: or https:) + */ +function configure(options = {}) { + config = { ...config, ...options }; +} + +/** + * Make HTTP request to API + * @param {string} method - HTTP method + * @param {string} path - API path + * @param {Object} body - Request body (optional) + * @returns {Promise} Response with { success, data/error } + */ +function makeRequest(method, path, body = null) { + return new Promise((resolve) => { + const httpModule = config.protocol === "https:" ? https : http; + + const options = { + hostname: config.host, + port: config.port, + path: path, + method: method, + headers: { + "Content-Type": "application/json", + [CLI_TOKEN_HEADER]: getCliToken(), + }, + }; + + // Add Content-Length for POST/PUT requests + if (body && (method === "POST" || method === "PUT" || method === "PATCH")) { + const bodyString = JSON.stringify(body); + options.headers["Content-Length"] = Buffer.byteLength(bodyString); + } + + const req = httpModule.request(options, (res) => { + let data = ""; + + res.on("data", (chunk) => { + data += chunk; + }); + + res.on("end", () => { + try { + const parsed = data ? JSON.parse(data) : {}; + + // Check if response indicates error + if (res.statusCode >= 400 || parsed.error) { + resolve({ + success: false, + error: parsed.error || `HTTP ${res.statusCode}`, + statusCode: res.statusCode, + }); + } else { + resolve({ + success: true, + data: parsed, + statusCode: res.statusCode, + }); + } + } catch (err) { + resolve({ + success: false, + error: `Failed to parse response: ${err.message}`, + }); + } + }); + }); + + req.on("error", (err) => { + resolve({ + success: false, + error: `Network error: ${err.message}`, + }); + }); + + req.on("timeout", () => { + req.destroy(); + resolve({ + success: false, + error: "Request timeout", + }); + }); + + // Set timeout (30 seconds) + req.setTimeout(30000); + + // Write body if present + if (body && (method === "POST" || method === "PUT" || method === "PATCH")) { + req.write(JSON.stringify(body)); + } + + req.end(); + }); +} + +// ============================================================================ +// PROVIDERS API +// ============================================================================ + +/** + * Get all providers + * @returns {Promise} { success, data: { connections } } + */ +async function getProviders() { + return makeRequest("GET", "/api/providers"); +} + +/** + * Get provider by ID + * @param {string} id - Provider ID + * @returns {Promise} { success, data: { connection } } + */ +async function getProviderById(id) { + return makeRequest("GET", `/api/providers/${id}`); +} + +/** + * Test provider connection + * @param {string} id - Provider ID + * @returns {Promise} { success, data: { valid, error } } + */ +async function testProvider(id) { + return makeRequest("POST", `/api/providers/${id}/test`); +} + +/** + * Delete provider + * @param {string} id - Provider ID + * @returns {Promise} { success, data: { message } } + */ +async function deleteProvider(id) { + return makeRequest("DELETE", `/api/providers/${id}`); +} + +/** + * Get provider models + * @param {string} id - Provider ID + * @returns {Promise} { success, data: { provider, connectionId, models } } + */ +async function getProviderModels(id) { + return makeRequest("GET", `/api/providers/${id}/models`); +} + +// ============================================================================ +// OAUTH API +// ============================================================================ + +/** + * Get OAuth authorization URL + * @param {string} provider - Provider ID + * @returns {Promise} { success, data: { authUrl, codeVerifier, state, redirectUri } } + */ +async function getOAuthAuthUrl(provider) { + // Codex requires fixed port 1455 and path /auth/callback + const redirectUri = provider === "codex" + ? "http://localhost:1455/auth/callback" + : "http://localhost:20128/callback"; + return makeRequest("GET", `/api/oauth/${provider}/authorize?redirect_uri=${encodeURIComponent(redirectUri)}`); +} + +/** + * Exchange OAuth authorization code for token + * @param {string} provider - Provider ID + * @param {Object} data - { code, redirectUri, codeVerifier, state } + * @returns {Promise} { success, data } + */ +async function exchangeOAuthCode(provider, data) { + return makeRequest("POST", `/api/oauth/${provider}/exchange`, data); +} + +/** + * Get OAuth device code + * @param {string} provider - Provider ID + * @returns {Promise} { success, data: { device_code, user_code, verification_uri, verification_uri_complete, codeVerifier, extraData } } + */ +async function getOAuthDeviceCode(provider) { + return makeRequest("GET", `/api/oauth/${provider}/device-code`); +} + +/** + * Poll OAuth token using device code + * @param {string} provider - Provider ID + * @param {Object} data - { deviceCode, codeVerifier, extraData } + * @returns {Promise} { success, data: { pending } } + */ +async function pollOAuthToken(provider, data) { + return makeRequest("POST", `/api/oauth/${provider}/poll`, data); +} + +/** + * Create API key provider connection + * @param {Object} data - { provider, name, apiKey } + * @returns {Promise} { success, data } + */ +async function createApiKeyProvider(data) { + return makeRequest("POST", "/api/providers", data); +} + +/** + * Update provider connection + * @param {string} id - Connection ID + * @param {Object} data - { name, priority, defaultModel, isActive } + * @returns {Promise} { success, data: { connection } } + */ +async function updateConnection(id, data) { + return makeRequest("PUT", `/api/providers/${id}`, data); +} + +// ============================================================================ +// API KEYS API +// ============================================================================ + +/** + * Get all API keys + * @returns {Promise} { success, data: { keys } } + */ +async function getApiKeys() { + return makeRequest("GET", "/api/keys"); +} + +/** + * Create new API key + * @param {string} name - Key name + * @returns {Promise} { success, data: { key, name, id, machineId } } + */ +async function createApiKey(name) { + return makeRequest("POST", "/api/keys", { name }); +} + +/** + * Delete API key + * @param {string} id - Key ID + * @returns {Promise} { success, data: { success } } + */ +async function deleteApiKey(id) { + return makeRequest("DELETE", `/api/keys/${id}`); +} + +// ============================================================================ +// COMBOS API +// ============================================================================ + +/** + * Get all combos + * @returns {Promise} { success, data: { combos } } + */ +async function getCombos() { + return makeRequest("GET", "/api/combos"); +} + +/** + * Get combo by ID + * @param {string} id - Combo ID + * @returns {Promise} { success, data: combo } + */ +async function getComboById(id) { + return makeRequest("GET", `/api/combos/${id}`); +} + +/** + * Create new combo + * @param {Object} data - Combo data { name, models } + * @returns {Promise} { success, data: combo } + */ +async function createCombo(data) { + return makeRequest("POST", "/api/combos", data); +} + +/** + * Update combo + * @param {string} id - Combo ID + * @param {Object} data - Update data { name?, models? } + * @returns {Promise} { success, data: combo } + */ +async function updateCombo(id, data) { + return makeRequest("PUT", `/api/combos/${id}`, data); +} + +/** + * Delete combo + * @param {string} id - Combo ID + * @returns {Promise} { success, data: { success } } + */ +async function deleteCombo(id) { + return makeRequest("DELETE", `/api/combos/${id}`); +} + +// ============================================================================ +// CLI TOOLS API +// ============================================================================ + +/** + * Get CLI tool settings + * @param {string} tool - Tool name: claude | codex | droid | openclaw + * @returns {Promise} { success, data: { installed, has9Router, ... } } + */ +async function getCliToolSettings(tool) { + return makeRequest("GET", `/api/cli-tools/${tool}-settings`); +} + +/** + * Apply CLI tool settings (POST) + * @param {string} tool - Tool name: claude | codex | droid | openclaw + * @param {Object} body - Payload depends on tool + * @returns {Promise} { success, data } + */ +async function applyCliToolSettings(tool, body) { + return makeRequest("POST", `/api/cli-tools/${tool}-settings`, body); +} + +/** + * Reset CLI tool settings (DELETE) + * @param {string} tool - Tool name: claude | codex | droid | openclaw + * @returns {Promise} { success, data } + */ +async function resetCliToolSettings(tool) { + return makeRequest("DELETE", `/api/cli-tools/${tool}-settings`); +} + +// ============================================================================ +// SETTINGS API +// ============================================================================ + +/** + * Get settings + * @returns {Promise} { success, data: settings } + */ +async function getSettings() { + return makeRequest("GET", "/api/settings"); +} + +/** + * Update settings + * @param {Object} data - Settings data + * @returns {Promise} { success, data: settings } + */ +async function updateSettings(data) { + return makeRequest("PATCH", "/api/settings", data); +} + +/** + * Reset dashboard password to default (clears stored hash server-side) + * @returns {Promise} { success } + */ +async function resetPassword() { + return makeRequest("POST", "/api/auth/reset-password"); +} + +// ============================================================================ +// MODELS API +// ============================================================================ + +/** + * Get all models (internal API) + * @returns {Promise} { success, data: { models } } + */ +async function getModels() { + return makeRequest("GET", "/api/models"); +} + +/** + * Get available models from active providers + combos (OpenAI compatible) + * @returns {Promise} { success, data: { object, data: [...models] } } + */ +async function getAvailableModels() { + return makeRequest("GET", "/v1/models"); +} + +// ============================================================================ +// PROVIDER NODES API (custom providers) +// ============================================================================ + +async function getProviderNodes() { + return makeRequest("GET", "/api/provider-nodes"); +} + +async function createProviderNode(data) { + return makeRequest("POST", "/api/provider-nodes", data); +} + +async function updateProviderNode(id, data) { + return makeRequest("PUT", `/api/provider-nodes/${id}`, data); +} + +async function deleteProviderNode(id) { + return makeRequest("DELETE", `/api/provider-nodes/${id}`); +} + +async function validateProviderNode(data) { + return makeRequest("POST", "/api/provider-nodes/validate", data); +} + +// ============================================================================ +// TUNNEL API +// ============================================================================ + +/** + * Get tunnel status + * @returns {Promise} { success, data: { enabled, tunnelUrl, shortId, running } } + */ +async function getTunnelStatus() { + return makeRequest("GET", "/api/tunnel/status"); +} + +/** + * Enable tunnel + * @returns {Promise} { success, data: { tunnelUrl, shortId } } + */ +async function enableTunnel() { + return makeRequest("POST", "/api/tunnel/enable"); +} + +/** + * Disable tunnel + * @returns {Promise} { success, data: { success } } + */ +async function disableTunnel() { + return makeRequest("POST", "/api/tunnel/disable"); +} + +// ============================================================================ +// EXPORTS +// ============================================================================ + +module.exports = { + configure, + + // Providers + getProviders, + getProviderById, + testProvider, + deleteProvider, + getProviderModels, + + // Connection aliases + testConnection: testProvider, + deleteConnection: deleteProvider, + updateConnection, + + // OAuth + getOAuthAuthUrl, + exchangeOAuthCode, + getOAuthDeviceCode, + pollOAuthToken, + createApiKeyProvider, + + // API Keys + getApiKeys, + createApiKey, + deleteApiKey, + + // Combos + getCombos, + getComboById, + createCombo, + updateCombo, + deleteCombo, + + // CLI Tools + getCliToolSettings, + applyCliToolSettings, + resetCliToolSettings, + + // Settings + getSettings, + updateSettings, + resetPassword, + + // Tunnel + getTunnelStatus, + enableTunnel, + disableTunnel, + + // Models + getModels, + getAvailableModels, + + // Provider Nodes (custom providers) + getProviderNodes, + createProviderNode, + updateProviderNode, + deleteProviderNode, + validateProviderNode, +}; diff --git a/cli/src/cli/menus/apiKeys.js b/cli/src/cli/menus/apiKeys.js new file mode 100644 index 0000000..ecd4e68 --- /dev/null +++ b/cli/src/cli/menus/apiKeys.js @@ -0,0 +1,233 @@ +const api = require("../api/client"); +const { prompt, confirm, pause } = require("../utils/input"); +const { clearScreen, showStatus, showHeader } = require("../utils/display"); +const { maskKey, formatDate, getRelativeTime } = require("../utils/format"); +const { showMenuWithBack } = require("../utils/menuHelper"); +const { copyToClipboard } = require("../utils/clipboard"); +const { getEndpoint } = require("../utils/endpoint"); + +/** + * Display API keys list with formatted output + * @param {Array} keys - Array of API key objects + * @param {number} port - Server port + */ +function displayApiKeys(keys, port) { + console.log("┌─────────────────────────────────────────────────────────┐"); + console.log("│ 🔑 API Keys Management │"); + console.log("├─────────────────────────────────────────────────────────┤"); + // Note: This function is legacy, endpoint shown in menu header instead + console.log("│ │"); + + if (keys.length === 0) { + console.log("│ No API keys found. │"); + } else { + console.log(`│ Your API Keys (${keys.length}):${" ".repeat(42 - String(keys.length).length)}│`); + + keys.forEach((key, index) => { + console.log("│ │"); + console.log(`│ ${index + 1}. ${key.name}${" ".repeat(52 - String(index + 1).length - key.name.length)}│`); + + const maskedKey = maskKey(key.key); + console.log(`│ Key: ${maskedKey}${" ".repeat(47 - maskedKey.length)}│`); + + const created = formatDate(key.createdAt); + console.log(`│ Created: ${created}${" ".repeat(43 - created.length)}│`); + + if (key.lastUsedAt) { + const lastUsed = getRelativeTime(key.lastUsedAt); + console.log(`│ Last used: ${lastUsed}${" ".repeat(41 - lastUsed.length)}│`); + } else { + console.log("│ Last used: Never │"); + } + }); + } + + console.log("│ │"); + console.log("│ Actions: │"); + console.log("│ 1. Create New API Key │"); + console.log("│ 2. View Full Key (by number) │"); + console.log("│ 3. Copy Key to Clipboard (by number) │"); + console.log("│ 4. Delete Key (by number) │"); + console.log("│ 0. ← Back to Main Menu │"); + console.log("└─────────────────────────────────────────────────────────┘"); +} + +/** + * Handle creating new API key + * @returns {Promise} Success status + */ +async function handleCreateKey() { + console.log("\n📝 Create New API Key"); + console.log("─".repeat(30)); + + const name = await prompt("Enter key name: "); + + if (!name) { + showStatus("Key name cannot be empty", "error"); + await pause(); + return false; + } + + const result = await api.createApiKey(name); + + if (!result.success) { + showStatus(`Failed to create key: ${result.error}`, "error"); + await pause(); + return false; + } + + console.log("\n✅ API Key created successfully!"); + console.log("\n⚠️ IMPORTANT: Save this key now. You won't be able to see it again!"); + console.log(`\nKey: ${result.data.key}`); + console.log(`Name: ${result.data.name}`); + console.log(`ID: ${result.data.id}`); + + const shouldCopy = await confirm("\nCopy key to clipboard?"); + if (shouldCopy) { + if (copyToClipboard(result.data.key)) { + showStatus("Key copied to clipboard!", "success"); + } else { + showStatus("Failed to copy to clipboard", "error"); + } + } + + await pause(); + return true; +} + +/** + * Handle viewing full API key + * @param {Object} key - API key object + */ +async function handleViewFullKey(key) { + console.log("\n🔍 Full API Key"); + console.log("─".repeat(30)); + console.log(`Name: ${key.name}`); + console.log(`Key: ${key.key}`); + console.log(`ID: ${key.id}`); + console.log(`Created: ${formatDate(key.createdAt)}`); + + if (key.lastUsedAt) { + console.log(`Last used: ${getRelativeTime(key.lastUsedAt)}`); + } else { + console.log("Last used: Never"); + } + + await pause(); +} + +/** + * Handle copying API key to clipboard + * @param {Object} key - API key object + */ +async function handleCopyKey(key) { + if (copyToClipboard(key.key)) { + showStatus(`Key "${key.name}" copied to clipboard!`, "success"); + } else { + showStatus("Failed to copy to clipboard", "error"); + } + await pause(); +} + +/** + * Handle deleting API key + * @param {Object} key - API key object + * @returns {Promise} Success status + */ +async function handleDeleteKey(key) { + console.log(`\n⚠️ Delete API Key: ${key.name}`); + console.log("─".repeat(30)); + console.log(`Key: ${maskKey(key.key)}`); + console.log(`Created: ${formatDate(key.createdAt)}`); + + const confirmed = await confirm("\nAre you sure you want to delete this key?"); + + if (!confirmed) { + showStatus("Deletion cancelled", "info"); + await pause(); + return false; + } + + const result = await api.deleteApiKey(key.id); + + if (!result.success) { + showStatus(`Failed to delete key: ${result.error}`, "error"); + await pause(); + return false; + } + + showStatus("API key deleted successfully", "success"); + await pause(); + return true; +} + +/** + * Show actions for a specific key + * @param {Object} key - API key object + * @param {number} port - Server port + * @param {Array} breadcrumb - Breadcrumb path + */ +async function showKeyActions(key, port, breadcrumb = []) { + const { endpoint } = await getEndpoint(port); + await showMenuWithBack({ + title: `🔑 ${key.name}`, + breadcrumb: [...breadcrumb, key.name], + headerContent: `Name: ${key.name}\nKey: ${key.key}\nEndpoint: ${endpoint}`, + items: [ + { + label: "Copy to Clipboard", + action: async () => { + await handleCopyKey(key); + return true; + } + }, + { + label: "Delete Key", + action: async () => { + await handleDeleteKey(key); + return false; // Exit after delete + } + } + ] + }); +} + +/** + * Main API Keys menu + * @param {number} port - Server port number + * @param {Array} breadcrumb - Breadcrumb path + */ +async function showApiKeysMenu(port, breadcrumb = []) { + const { showListMenu } = require("../utils/menuHelper"); + + const { endpoint } = await getEndpoint(port); + await showListMenu({ + title: "🔑 API Keys Management", + breadcrumb, + headerContent: `Endpoint: ${endpoint}`, + fetchItems: async () => { + const result = await api.getApiKeys(); + if (!result.success) { + clearScreen(); + showStatus(`Failed to fetch API keys: ${result.error}`, "error"); + await pause(); + return null; + } + return { items: result.data.keys || [] }; + }, + formatItem: (key) => `${key.name} (${maskKey(key.key)})`, + onSelect: async (key) => { + await showKeyActions(key, port, breadcrumb); + }, + createAction: { + label: "Create New API Key", + action: async () => { + await handleCreateKey(); + } + } + }); +} + +module.exports = { + showApiKeysMenu +}; diff --git a/cli/src/cli/menus/cliTools.js b/cli/src/cli/menus/cliTools.js new file mode 100644 index 0000000..3a84a07 --- /dev/null +++ b/cli/src/cli/menus/cliTools.js @@ -0,0 +1,618 @@ +const api = require("../api/client"); +const { pause, confirm } = require("../utils/input"); +const { showStatus } = require("../utils/display"); +const { selectModelFromList } = require("../utils/modelSelector"); +const { showMenuWithBack } = require("../utils/menuHelper"); +const { getEndpoint } = require("../utils/endpoint"); + +const COLORS = { + reset: "\x1b[0m", + green: "\x1b[32m", + red: "\x1b[31m", + dim: "\x1b[2m", + cyan: "\x1b[36m" +}; + +// Claude model types with defaults (matching Web UI) +const CLAUDE_MODEL_TYPES = [ + { id: "sonnet", name: "Sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL", defaultValue: "cc/claude-sonnet-4-5-20250929" }, + { id: "opus", name: "Opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-4-5-20251101" }, + { id: "haiku", name: "Haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL", defaultValue: "cc/claude-haiku-4-5-20251001" }, +]; + +// ─── Shared helpers ─────────────────────────────────────────────────────────── + +/** + * Get first available API key from server + * @returns {Promise} + */ +async function getFirstApiKey() { + const result = await api.getApiKeys(); + const keys = result.success ? (result.data.keys || []) : []; + return keys.length > 0 ? keys[0].key : null; +} + +// ─── Claude Code ────────────────────────────────────────────────────────────── + +/** + * Build header showing current Claude config status + * @returns {Promise} + */ +async function buildClaudeHeader() { + const result = await api.getCliToolSettings("claude"); + if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`; + + const settings = result.data.settings; + const currentUrl = settings?.env?.ANTHROPIC_BASE_URL; + const currentKey = settings?.env?.ANTHROPIC_AUTH_TOKEN; + const lines = []; + + if (currentUrl) { + lines.push(`Status: ${COLORS.green}✓ Configured${COLORS.reset}`); + lines.push(`Endpoint: ${COLORS.cyan}${currentUrl}${COLORS.reset}`); + if (currentKey) { + lines.push(`API Key: ${COLORS.dim}${currentKey.substring(0, 10)}...${COLORS.reset}`); + } + } else { + lines.push(`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`); + lines.push(`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`); + } + + return lines.join("\n"); +} + +/** + * Get current Claude model from settings + * @param {string} envKey + * @returns {Promise} + */ +async function getClaudeModel(envKey) { + const result = await api.getCliToolSettings("claude"); + return result.success ? (result.data.settings?.env?.[envKey] || "Not set") : "Not set"; +} + +/** + * Quick setup for Claude Code — sets endpoint, key, and all default models + * @param {number} port + */ +async function claudeQuickSetup(port) { + const { endpoint } = await getEndpoint(port); + const apiKey = await getFirstApiKey(); + + if (!apiKey) { + showStatus("No API keys found. Create one in API Keys menu first.", "error"); + await pause(); + return; + } + + const env = { ANTHROPIC_BASE_URL: endpoint, ANTHROPIC_AUTH_TOKEN: apiKey, API_TIMEOUT_MS: "600000" }; + CLAUDE_MODEL_TYPES.forEach(t => { env[t.envKey] = t.defaultValue; }); + + const result = await api.applyCliToolSettings("claude", { env }); + showStatus(result.success ? "Quick Setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Select and save a specific Claude model type + * @param {Object} modelType + * @param {number} port + */ +async function claudeSelectModel(modelType, port) { + const current = await getClaudeModel(modelType.envKey); + const selected = await selectModelFromList(`Select ${modelType.name} Model`, current, { excludeCombos: true }); + if (!selected) return; + + const env = { [modelType.envKey]: selected }; + + // Also set base URL if not configured yet + const settingsResult = await api.getCliToolSettings("claude"); + if (!settingsResult.data?.settings?.env?.ANTHROPIC_BASE_URL) { + const { endpoint } = await getEndpoint(port); + const apiKey = await getFirstApiKey(); + env.ANTHROPIC_BASE_URL = endpoint; + env.API_TIMEOUT_MS = "600000"; + if (apiKey) env.ANTHROPIC_AUTH_TOKEN = apiKey; + } + + const result = await api.applyCliToolSettings("claude", { env }); + showStatus(result.success ? `${modelType.name} → ${selected} saved!` : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Reset Claude Code settings + */ +async function claudeReset() { + const result = await api.resetCliToolSettings("claude"); + showStatus(result.success ? "Settings reset successfully!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Claude Code submenu + * @param {number} port + * @param {Array} breadcrumb + */ +async function showClaudeCodeMenu(port, breadcrumb = []) { + await showMenuWithBack({ + title: "🔧 Claude Code Settings", + breadcrumb, + headerContent: buildClaudeHeader, + refresh: async () => ({ + sonnet: await getClaudeModel("ANTHROPIC_DEFAULT_SONNET_MODEL"), + opus: await getClaudeModel("ANTHROPIC_DEFAULT_OPUS_MODEL"), + haiku: await getClaudeModel("ANTHROPIC_DEFAULT_HAIKU_MODEL"), + }), + items: [ + { + label: "⚡ Quick Setup (recommended)", + action: async () => { await claudeQuickSetup(port); return true; } + }, + { + label: (d) => `Sonnet → ${d.sonnet}`, + action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[0], port); return true; } + }, + { + label: (d) => `Opus → ${d.opus}`, + action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[1], port); return true; } + }, + { + label: (d) => `Haiku → ${d.haiku}`, + action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[2], port); return true; } + }, + { + label: "Reset to Default", + action: async () => { await claudeReset(); return true; } + } + ] + }); +} + +// ─── Codex CLI ──────────────────────────────────────────────────────────────── + +/** + * Build header showing current Codex config status + * @returns {Promise} + */ +async function buildCodexHeader() { + const result = await api.getCliToolSettings("codex"); + if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`; + + const { installed, has9Router, config } = result.data; + if (!installed) return `Status: ${COLORS.red}✗ Codex CLI not installed${COLORS.reset}`; + + if (!has9Router) { + return [ + `Status: ${COLORS.red}✗ Not configured${COLORS.reset}`, + `${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}` + ].join("\n"); + } + + // Parse base_url and model from raw TOML string + const baseUrlMatch = config && config.match(/base_url\s*=\s*"([^"]+)"/); + const modelMatch = config && config.match(/^model\s*=\s*"([^"]+)"/m); + const baseUrl = baseUrlMatch ? baseUrlMatch[1] : ""; + const model = modelMatch ? modelMatch[1] : ""; + + const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`]; + if (baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${baseUrl}${COLORS.reset}`); + if (model) lines.push(`Model: ${COLORS.dim}${model}${COLORS.reset}`); + return lines.join("\n"); +} + +/** + * Quick setup for Codex CLI + * @param {number} port + */ +async function codexQuickSetup(port) { + const { endpoint } = await getEndpoint(port); + const apiKey = await getFirstApiKey(); + + if (!apiKey) { + showStatus("No API keys found. Create one in API Keys menu first.", "error"); + await pause(); + return; + } + + // Get model selection + const model = await selectModelFromList("Select Codex Model", "cx/claude-sonnet-4-5-20250929", { excludeCombos: true }); + if (!model) return; + + const result = await api.applyCliToolSettings("codex", { baseUrl: endpoint, apiKey, model }); + showStatus(result.success ? "Codex setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Reset Codex CLI settings + */ +async function codexReset() { + const result = await api.resetCliToolSettings("codex"); + showStatus(result.success ? "Codex settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Codex CLI submenu + * @param {number} port + * @param {Array} breadcrumb + */ +async function showCodexMenu(port, breadcrumb = []) { + await showMenuWithBack({ + title: "🤖 Codex CLI Settings", + breadcrumb, + headerContent: buildCodexHeader, + refresh: async () => ({}), + items: [ + { + label: "⚡ Quick Setup", + action: async () => { await codexQuickSetup(port); return true; } + }, + { + label: "Reset to Default", + action: async () => { await codexReset(); return true; } + } + ] + }); +} + +// ─── Factory Droid ──────────────────────────────────────────────────────────── + +/** + * Build header showing current Droid config status + * @returns {Promise} + */ +async function buildDroidHeader() { + const result = await api.getCliToolSettings("droid"); + if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`; + + const { installed, has9Router, settings } = result.data; + if (!installed) return `Status: ${COLORS.red}✗ Factory Droid not installed${COLORS.reset}`; + + if (!has9Router) { + return [ + `Status: ${COLORS.red}✗ Not configured${COLORS.reset}`, + `${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}` + ].join("\n"); + } + + // Extract 9Router custom model config + const custom = settings?.customModels?.find(m => m.id === "custom:9Router-0"); + const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`]; + if (custom?.baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${custom.baseUrl}${COLORS.reset}`); + if (custom?.model) lines.push(`Model: ${COLORS.dim}${custom.model}${COLORS.reset}`); + return lines.join("\n"); +} + +/** + * Quick setup for Factory Droid + * @param {number} port + */ +async function droidQuickSetup(port) { + const { endpoint } = await getEndpoint(port); + const apiKey = await getFirstApiKey(); + + if (!apiKey) { + showStatus("No API keys found. Create one in API Keys menu first.", "error"); + await pause(); + return; + } + + const model = await selectModelFromList("Select Droid Model", "cc/claude-sonnet-4-5-20250929", { excludeCombos: true }); + if (!model) return; + + const result = await api.applyCliToolSettings("droid", { baseUrl: endpoint, apiKey, model }); + showStatus(result.success ? "Factory Droid setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Reset Factory Droid settings + */ +async function droidReset() { + const result = await api.resetCliToolSettings("droid"); + showStatus(result.success ? "Factory Droid settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Factory Droid submenu + * @param {number} port + * @param {Array} breadcrumb + */ +async function showDroidMenu(port, breadcrumb = []) { + await showMenuWithBack({ + title: "🤖 Factory Droid Settings", + breadcrumb, + headerContent: buildDroidHeader, + refresh: async () => ({}), + items: [ + { + label: "⚡ Quick Setup", + action: async () => { await droidQuickSetup(port); return true; } + }, + { + label: "Reset to Default", + action: async () => { await droidReset(); return true; } + } + ] + }); +} + +// ─── Open Claw ──────────────────────────────────────────────────────────────── + +/** + * Build header showing current OpenClaw config status + * @returns {Promise} + */ +async function buildOpenClawHeader() { + const result = await api.getCliToolSettings("openclaw"); + if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`; + + const { installed, has9Router, settings } = result.data; + if (!installed) return `Status: ${COLORS.red}✗ Open Claw not installed${COLORS.reset}`; + + if (!has9Router) { + return [ + `Status: ${COLORS.red}✗ Not configured${COLORS.reset}`, + `${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}` + ].join("\n"); + } + + // Extract 9Router provider config + const provider = settings?.models?.providers?.["9router"]; + const primary = settings?.agents?.defaults?.model?.primary || ""; + const model = primary.startsWith("9router/") ? primary.replace("9router/", "") : (provider?.models?.[0]?.id || ""); + const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`]; + if (provider?.baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${provider.baseUrl}${COLORS.reset}`); + if (model) lines.push(`Model: ${COLORS.dim}${model}${COLORS.reset}`); + return lines.join("\n"); +} + +/** + * Quick setup for Open Claw + * @param {number} port + */ +async function openClawQuickSetup(port) { + const { endpoint } = await getEndpoint(port); + const apiKey = await getFirstApiKey(); + + if (!apiKey) { + showStatus("No API keys found. Create one in API Keys menu first.", "error"); + await pause(); + return; + } + + const model = await selectModelFromList("Select OpenClaw Model", "cc/claude-sonnet-4-5-20250929", { excludeCombos: true }); + if (!model) return; + + const result = await api.applyCliToolSettings("openclaw", { baseUrl: endpoint, apiKey, model }); + showStatus(result.success ? "Open Claw setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Reset Open Claw settings + */ +async function openClawReset() { + const result = await api.resetCliToolSettings("openclaw"); + showStatus(result.success ? "Open Claw settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Open Claw submenu + * @param {number} port + * @param {Array} breadcrumb + */ +async function showOpenClawMenu(port, breadcrumb = []) { + await showMenuWithBack({ + title: "🦞 Open Claw Settings", + breadcrumb, + headerContent: buildOpenClawHeader, + refresh: async () => ({}), + items: [ + { + label: "⚡ Quick Setup", + action: async () => { await openClawQuickSetup(port); return true; } + }, + { + label: "Reset to Default", + action: async () => { await openClawReset(); return true; } + } + ] + }); +} + +// ─── OpenCode CLI ───────────────────────────────────────────────────────────── + +async function buildOpenCodeHeader() { + const result = await api.getCliToolSettings("opencode"); + if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`; + + const { installed, has9Router, opencode } = result.data; + if (!installed) return `Status: ${COLORS.red}✗ OpenCode CLI not installed${COLORS.reset}`; + + if (!has9Router) { + return [ + `Status: ${COLORS.red}✗ Not configured${COLORS.reset}`, + `${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}` + ].join("\n"); + } + + const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`]; + if (opencode?.baseURL) lines.push(`Endpoint: ${COLORS.cyan}${opencode.baseURL}${COLORS.reset}`); + if (opencode?.activeModel) lines.push(`Active: ${COLORS.dim}${opencode.activeModel}${COLORS.reset}`); + if (Array.isArray(opencode?.models) && opencode.models.length > 0) { + lines.push(`Models: ${COLORS.dim}${opencode.models.join(", ")}${COLORS.reset}`); + } + return lines.join("\n"); +} + +async function openCodeQuickSetup(port) { + const { endpoint } = await getEndpoint(port); + const apiKey = await getFirstApiKey(); + + if (!apiKey) { + showStatus("No API keys found. Create one in API Keys menu first.", "error"); + await pause(); + return; + } + + // Pick first model (also becomes active model by default) + const firstModel = await selectModelFromList("Select Active Model (OpenCode)", "", { excludeCombos: true }); + if (!firstModel) return; + + const models = [firstModel]; + + // Optionally add more models + while (true) { + const more = await confirm(`Add another model? (current: ${models.length})`); + if (!more) break; + const next = await selectModelFromList(`Add Model #${models.length + 1}`, models.join(", "), { excludeCombos: true }); + if (!next) break; + if (!models.includes(next)) models.push(next); + } + + // Optional subagent model + let subagentModel = firstModel; + const wantSubagent = await confirm(`Set a different subagent model? (default: ${firstModel})`); + if (wantSubagent) { + const picked = await selectModelFromList("Select Subagent Model", firstModel, { excludeCombos: true }); + if (picked) subagentModel = picked; + } + + const result = await api.applyCliToolSettings("opencode", { + baseUrl: endpoint, + apiKey, + models, + activeModel: firstModel, + subagentModel, + }); + showStatus(result.success ? "OpenCode setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +async function openCodeReset() { + const result = await api.resetCliToolSettings("opencode"); + showStatus(result.success ? "OpenCode settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +async function showOpenCodeMenu(port, breadcrumb = []) { + await showMenuWithBack({ + title: "💻 OpenCode CLI Settings", + breadcrumb, + headerContent: buildOpenCodeHeader, + refresh: async () => ({}), + items: [ + { label: "⚡ Quick Setup", action: async () => { await openCodeQuickSetup(port); return true; } }, + { label: "Reset to Default", action: async () => { await openCodeReset(); return true; } } + ] + }); +} + +// ─── Hermes Agent ───────────────────────────────────────────────────────────── + +async function buildHermesHeader() { + const result = await api.getCliToolSettings("hermes"); + if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`; + + const { installed, has9Router, settings } = result.data; + if (!installed) return `Status: ${COLORS.red}✗ Hermes Agent not installed${COLORS.reset}`; + + if (!has9Router) { + return [ + `Status: ${COLORS.red}✗ Not configured${COLORS.reset}`, + `${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}` + ].join("\n"); + } + + const model = settings?.model || {}; + const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`]; + if (model.base_url) lines.push(`Endpoint: ${COLORS.cyan}${model.base_url}${COLORS.reset}`); + if (model.default) lines.push(`Model: ${COLORS.dim}${model.default}${COLORS.reset}`); + return lines.join("\n"); +} + +async function hermesQuickSetup(port) { + const { endpoint } = await getEndpoint(port); + const apiKey = await getFirstApiKey(); + + if (!apiKey) { + showStatus("No API keys found. Create one in API Keys menu first.", "error"); + await pause(); + return; + } + + const model = await selectModelFromList("Select Hermes Model", "", { excludeCombos: true }); + if (!model) return; + + const result = await api.applyCliToolSettings("hermes", { baseUrl: endpoint, apiKey, model }); + showStatus(result.success ? "Hermes setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +async function hermesReset() { + const result = await api.resetCliToolSettings("hermes"); + showStatus(result.success ? "Hermes settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +async function showHermesMenu(port, breadcrumb = []) { + await showMenuWithBack({ + title: "⚡ Hermes Agent Settings", + breadcrumb, + headerContent: buildHermesHeader, + refresh: async () => ({}), + items: [ + { label: "⚡ Quick Setup", action: async () => { await hermesQuickSetup(port); return true; } }, + { label: "Reset to Default", action: async () => { await hermesReset(); return true; } } + ] + }); +} + +// ─── Main CLI Tools Menu ────────────────────────────────────────────────────── + +/** + * Main CLI Tools menu + * @param {number} port + * @param {Array} breadcrumb + */ +async function showCliToolsMenu(port, breadcrumb = []) { + const { endpoint } = await getEndpoint(port); + await showMenuWithBack({ + title: "🔧 CLI Tools", + breadcrumb, + headerContent: `Configure CLI tools to use 9Router\nEndpoint: ${endpoint}`, + items: [ + { + label: "Claude Code", + action: async () => { await showClaudeCodeMenu(port, [...breadcrumb, "Claude Code"]); return true; } + }, + { + label: "Codex CLI", + action: async () => { await showCodexMenu(port, [...breadcrumb, "Codex CLI"]); return true; } + }, + { + label: "Factory Droid", + action: async () => { await showDroidMenu(port, [...breadcrumb, "Factory Droid"]); return true; } + }, + { + label: "Open Claw", + action: async () => { await showOpenClawMenu(port, [...breadcrumb, "Open Claw"]); return true; } + }, + { + label: "OpenCode", + action: async () => { await showOpenCodeMenu(port, [...breadcrumb, "OpenCode"]); return true; } + }, + { + label: "Hermes", + action: async () => { await showHermesMenu(port, [...breadcrumb, "Hermes"]); return true; } + } + ] + }); +} + +module.exports = { showCliToolsMenu }; diff --git a/cli/src/cli/menus/combos.js b/cli/src/cli/menus/combos.js new file mode 100644 index 0000000..5a8b202 --- /dev/null +++ b/cli/src/cli/menus/combos.js @@ -0,0 +1,477 @@ +const api = require("../api/client"); +const { prompt, confirm, pause } = require("../utils/input"); +const { clearScreen, showStatus, showHeader } = require("../utils/display"); +const { formatDate } = require("../utils/format"); +const { selectModelFromList } = require("../utils/modelSelector"); +const { showMenuWithBack } = require("../utils/menuHelper"); + +/** + * Format model to string (handle both string and object) + */ +function formatModel(model) { + if (typeof model === "string") return model; + if (model && typeof model === "object") { + return model.id || model.name || `${model.provider}/${model.model}` || JSON.stringify(model); + } + return String(model); +} + +/** + * Show actions for a specific combo + * @param {Object} combo - Combo object + * @param {Array} breadcrumb - Breadcrumb path + */ +async function showComboActions(combo, breadcrumb = []) { + const modelsChain = Array.isArray(combo.models) + ? combo.models.map(formatModel).join(" → ") + : ""; + + await showMenuWithBack({ + title: `🔀 ${combo.name}`, + breadcrumb: [...breadcrumb, combo.name], + headerContent: `Name: ${combo.name}\nModels: ${modelsChain}`, + items: [ + { + label: "Edit Combo", + action: async () => { + await handleEditSingleCombo(combo); + return true; + } + }, + { + label: "Delete Combo", + action: async () => { + await handleDeleteSingleCombo(combo); + return false; // Exit after delete + } + } + ] + }); +} + +/** + * Handle editing a single combo + * @param {Object} combo - Combo to edit + */ +async function handleEditSingleCombo(combo) { + clearScreen(); + console.log(`\n✏️ Edit Combo: ${combo.name}\n`); + + const newName = await prompt(`New name (Enter to keep "${combo.name}"): `); + const name = newName || combo.name; + + console.log("\nCurrent models: " + (Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : "")); + console.log("\nSelect models for this combo (add one by one):"); + + const models = []; + let addMore = true; + + while (addMore) { + const currentChain = models.length > 0 ? models.join(" → ") : "None"; + const model = await selectModelFromList(`Add Model #${models.length + 1}`, `Chain: ${currentChain}`); + + if (model) { + models.push(model); + console.log(`\n✓ Added: ${model}`); + console.log(`Current chain: ${models.join(" → ")}\n`); + + const continueAdding = await confirm("Add another model?"); + addMore = continueAdding; + } else { + addMore = false; + } + } + + // Use new models if any were added, otherwise keep current + const finalModels = models.length > 0 ? models : combo.models; + + const result = await api.updateCombo(combo.id, { name, models: finalModels }); + + if (result.success) { + showStatus("Combo updated!", "success"); + } else { + showStatus(`Update failed: ${result.error}`, "error"); + } + await pause(); +} + +/** + * Handle deleting a single combo + * @param {Object} combo - Combo to delete + */ +async function handleDeleteSingleCombo(combo) { + const confirmed = await confirm(`Delete combo "${combo.name}"?`); + if (confirmed) { + const result = await api.deleteCombo(combo.id); + if (result.success) { + showStatus("Combo deleted!", "success"); + } else { + showStatus(`Delete failed: ${result.error}`, "error"); + } + await pause(); + } +} + +/** + * Main combos menu - list all combos and actions + * @param {Array} breadcrumb - Breadcrumb path + */ +async function showCombosMenu(breadcrumb = []) { + const { showListMenu } = require("../utils/menuHelper"); + + await showListMenu({ + title: "🔀 Combos Management", + breadcrumb, + fetchItems: async () => { + const result = await api.getCombos(); + if (!result.success) { + clearScreen(); + showStatus(`Failed to load combos: ${result.error}`, "error"); + await pause(); + return null; + } + return { items: result.data.combos || [] }; + }, + formatItem: (combo) => { + const modelsChain = Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : ""; + const maxLen = 35; + const displayModels = modelsChain.length > maxLen + ? modelsChain.substring(0, maxLen - 3) + "..." + : modelsChain; + return `${combo.name}: ${displayModels}`; + }, + onSelect: async (combo) => { + await showComboActions(combo, breadcrumb); + }, + createAction: { + label: "Create New Combo", + action: async () => { + await handleCreateCombo(); + } + } + }); +} + +/** + * Show combo detail with stats + */ +async function showComboDetail(comboId) { + clearScreen(); + + const result = await api.getComboById(comboId); + + if (!result.success) { + showStatus(`Failed to load combo: ${result.error}`, "error"); + await pause(); + return; + } + + const combo = result.data; + + console.log("┌─────────────────────────────────────────────────────────┐"); + console.log(`│ 🔀 Combo: ${combo.name.padEnd(46)} │`); + console.log("├─────────────────────────────────────────────────────────┤"); + console.log("│ │"); + console.log(`│ ID: ${combo.id.padEnd(51)} │`); + console.log(`│ Created: ${formatDate(combo.createdAt).padEnd(46)} │`); + console.log(`│ Updated: ${formatDate(combo.updatedAt).padEnd(46)} │`); + console.log("│ │"); + console.log("│ Model Chain: │"); + + // Models is array of strings like ["ag/claude-sonnet-4-5", "kr/claude-sonnet-4.5"] + const models = Array.isArray(combo.models) ? combo.models : []; + models.forEach((modelStr, index) => { + const arrow = index < models.length - 1 ? " →" : " "; + const displayText = `${index + 1}. ${modelStr}${arrow}`; + const padding = Math.max(0, 54 - displayText.length); + console.log(`│ ${displayText}${" ".repeat(padding)} │`); + }); + + console.log("│ │"); + console.log("└─────────────────────────────────────────────────────────┘"); + + await pause(); +} + +/** + * Format combo for menu display + */ +function formatComboLabel(combo) { + const modelsChain = Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : ""; + const maxLen = 40; + const displayModels = modelsChain.length > maxLen + ? modelsChain.substring(0, maxLen - 3) + "..." + : modelsChain; + return `${combo.name}: ${displayModels}`; +} + +/** + * Create new combo + */ +async function handleCreateCombo() { + clearScreen(); + + showStatus("Create New Combo", "info"); + console.log(); + + // Get combo name + const name = await prompt("Combo name: "); + if (!name) { + showStatus("Combo name is required", "error"); + await pause(); + return; + } + + // Fetch available models + showStatus("Loading available models...", "info"); + const modelsResult = await api.getModels(); + + if (!modelsResult.success) { + showStatus(`Failed to load models: ${modelsResult.error}`, "error"); + await pause(); + return; + } + + const availableModels = modelsResult.data.models || []; + + if (availableModels.length === 0) { + showStatus("No models available. Please add providers first.", "warning"); + await pause(); + return; + } + + // Select models for chain + const selectedModels = []; + + console.log(); + showStatus("Select models for the chain (minimum 2)", "info"); + + while (true) { + clearScreen(); + console.log(`Creating combo: ${name}`); + console.log(`Selected models (${selectedModels.length}):`); + + if (selectedModels.length > 0) { + selectedModels.forEach((m, i) => { + console.log(` ${i + 1}. ${m.provider}/${m.model}`); + }); + } else { + console.log(" (none)"); + } + + console.log(); + console.log("Available models:"); + availableModels.forEach((m, i) => { + console.log(` ${i + 1}. ${m.provider}/${m.model}`); + }); + + console.log(); + console.log("Actions:"); + console.log(" - Enter number to add model"); + console.log(" - Type 'done' to finish (min 2 models)"); + console.log(" - Type 'cancel' to abort"); + + const input = await prompt("\nAction: "); + + if (input.toLowerCase() === "cancel") { + showStatus("Cancelled", "warning"); + await pause(); + return; + } + + if (input.toLowerCase() === "done") { + if (selectedModels.length < 2) { + showStatus("Please select at least 2 models", "error"); + await pause(); + continue; + } + break; + } + + const num = parseInt(input, 10); + if (isNaN(num) || num < 1 || num > availableModels.length) { + showStatus("Invalid model number", "error"); + await pause(); + continue; + } + + selectedModels.push(availableModels[num - 1]); + } + + // Create combo + showStatus("Creating combo...", "info"); + + const createResult = await api.createCombo({ + name, + models: selectedModels + }); + + if (!createResult.success) { + showStatus(`Failed to create combo: ${createResult.error}`, "error"); + await pause(); + return; + } + + showStatus(`Combo "${name}" created successfully!`, "success"); + await pause(); +} + +/** + * Edit combo - select which combo to edit + */ +async function handleEditCombo(combos) { + if (combos.length === 0) { + showStatus("No combos available", "warning"); + await pause(); + return; + } + + let selectedCombo = null; + + await showMenuWithBack({ + title: "✏️ Select Combo to Edit", + items: combos.map(combo => ({ + label: formatComboLabel(combo), + action: async () => { + selectedCombo = combo; + return false; + } + })) + }); + + if (!selectedCombo) return; + await editSingleCombo(selectedCombo); +} + +/** + * Edit a single combo + */ +async function editSingleCombo(combo) { + clearScreen(); + showStatus(`Editing combo: ${combo.name}`, "info"); + console.log(); + + const newName = await prompt(`New name (current: ${combo.name}, press Enter to keep): `); + const editModels = await confirm("Edit model chain?"); + + let newModels = combo.models; + + if (editModels) { + newModels = []; + + while (true) { + clearScreen(); + console.log(`Editing combo: ${combo.name}`); + console.log(`Selected models (${newModels.length}):`); + + if (newModels.length > 0) { + newModels.forEach((m, i) => console.log(` ${i + 1}. ${m}`)); + } else { + console.log(" (none)"); + } + + console.log("\nType 'done' to finish (min 2 models) or 'cancel' to abort\n"); + + const model = await selectModelFromList("Add Model", ""); + + if (model === null) { + showStatus("Cancelled", "warning"); + await pause(); + return; + } + + if (model === "done") { + if (newModels.length < 2) { + showStatus("Please select at least 2 models", "error"); + await pause(); + continue; + } + break; + } + + newModels.push(model); + showStatus(`Added: ${model}`, "success"); + await pause(); + } + } + + const updateData = {}; + if (newName) updateData.name = newName; + if (editModels) updateData.models = newModels; + + if (Object.keys(updateData).length === 0) { + showStatus("No changes made", "warning"); + await pause(); + return; + } + + showStatus("Updating combo...", "info"); + + const updateResult = await api.updateCombo(combo.id, updateData); + + if (!updateResult.success) { + showStatus(`Failed to update combo: ${updateResult.error}`, "error"); + await pause(); + return; + } + + showStatus("Combo updated successfully!", "success"); + await pause(); +} + +/** + * Delete combo - select which combo to delete + */ +async function handleDeleteCombo(combos) { + if (combos.length === 0) { + showStatus("No combos available", "warning"); + await pause(); + return; + } + + let selectedCombo = null; + + await showMenuWithBack({ + title: "🗑️ Select Combo to Delete", + items: combos.map(combo => ({ + label: formatComboLabel(combo), + action: async () => { + selectedCombo = combo; + return false; + } + })) + }); + + if (!selectedCombo) return; + + clearScreen(); + showStatus(`Combo: ${selectedCombo.name}`, "warning"); + const modelsDisplay = Array.isArray(selectedCombo.models) + ? selectedCombo.models.map(formatModel).join(" → ") + : ""; + console.log(`Models: ${modelsDisplay}`); + console.log(); + + const confirmed = await confirm("Are you sure you want to delete this combo?"); + + if (!confirmed) { + showStatus("Cancelled", "info"); + await pause(); + return; + } + + showStatus("Deleting combo...", "info"); + + const deleteResult = await api.deleteCombo(selectedCombo.id); + + if (!deleteResult.success) { + showStatus(`Failed to delete combo: ${deleteResult.error}`, "error"); + await pause(); + return; + } + + showStatus("Combo deleted successfully!", "success"); + await pause(); +} + +module.exports = { showCombosMenu }; diff --git a/cli/src/cli/menus/providers.js b/cli/src/cli/menus/providers.js new file mode 100644 index 0000000..5701346 --- /dev/null +++ b/cli/src/cli/menus/providers.js @@ -0,0 +1,847 @@ +const api = require("../api/client"); +const { prompt, confirm, pause } = require("../utils/input"); +const { clearScreen, showStatus, showHeader } = require("../utils/display"); +const { formatDate, getRelativeTime } = require("../utils/format"); +const { showMenuWithBack } = require("../utils/menuHelper"); +const { copyToClipboard } = require("../utils/clipboard"); + +// ANSI colors for styling +const COLORS = { + reset: "\x1b[0m", + bold: "\x1b[1m", + cyan: "\x1b[36m", + dim: "\x1b[2m" +}; + +// Provider models - static config (synced from open-sse/config/providerModels.js) +const PROVIDER_MODELS = { + cc: [ + { id: "claude-opus-4-5-20251101" }, + { id: "claude-sonnet-4-5-20250929" }, + { id: "claude-haiku-4-5-20251001" }, + ], + cx: [ + { id: "gpt-5.2-codex" }, + { id: "gpt-5.2" }, + { id: "gpt-5.1-codex-max" }, + { id: "gpt-5.1-codex" }, + { id: "gpt-5.1-codex-mini" }, + { id: "gpt-5.1" }, + { id: "gpt-5-codex" }, + { id: "gpt-5-codex-mini" }, + ], + gc: [ + { id: "gemini-3-flash-preview" }, + { id: "gemini-3-pro-preview" }, + { id: "gemini-2.5-pro" }, + { id: "gemini-2.5-flash" }, + { id: "gemini-2.5-flash-lite" }, + ], + qw: [ + { id: "qwen3-coder-plus" }, + { id: "qwen3-coder-flash" }, + { id: "vision-model" }, + ], + if: [ + { id: "qwen3-coder-plus" }, + { id: "kimi-k2" }, + { id: "kimi-k2-thinking" }, + { id: "deepseek-r1" }, + { id: "deepseek-v3.2-chat" }, + { id: "deepseek-v3.2-reasoner" }, + { id: "minimax-m2" }, + { id: "glm-4.7" }, + ], + ag: [ + { id: "gemini-3-flash-agent" }, + { id: "gemini-3.5-flash-low" }, + { id: "gemini-3.5-flash-extra-low" }, + { id: "gemini-pro-agent" }, + { id: "gemini-3.1-pro-low" }, + { id: "claude-sonnet-4-6" }, + { id: "claude-opus-4-6-thinking" }, + { id: "gpt-oss-120b-medium" }, + { id: "gemini-3-flash" }, + ], + gh: [ + { id: "gpt-5" }, + { id: "gpt-5-mini" }, + { id: "gpt-5.1-codex" }, + { id: "gpt-5.1-codex-max" }, + { id: "gpt-4.1" }, + { id: "claude-4.5-sonnet" }, + { id: "claude-4.5-opus" }, + { id: "claude-4.5-haiku" }, + { id: "gemini-3-pro" }, + { id: "gemini-3-flash" }, + { id: "gemini-2.5-pro" }, + { id: "grok-code-fast-1" }, + ], + kr: [ + { id: "claude-sonnet-5" }, + { id: "claude-sonnet-4.5" }, + { id: "claude-haiku-4.5" }, + ], + openai: [ + { id: "gpt-4o" }, + { id: "gpt-4o-mini" }, + { id: "gpt-4-turbo" }, + { id: "o1" }, + { id: "o1-mini" }, + ], + anthropic: [ + { id: "claude-sonnet-4-20250514" }, + { id: "claude-opus-4-20250514" }, + { id: "claude-3-5-sonnet-20241022" }, + ], + gemini: [ + { id: "gemini-3-pro-preview" }, + { id: "gemini-2.5-pro" }, + { id: "gemini-2.5-flash" }, + { id: "gemini-2.5-flash-lite" }, + ], + openrouter: [ + { id: "auto" }, + ], + glm: [ + { id: "glm-4.7" }, + { id: "glm-4.6v" }, + ], + kimi: [ + { id: "kimi-latest" }, + ], + minimax: [ + { id: "MiniMax-M2.1" }, + ], +}; + +// Provider definitions +const OAUTH_PROVIDERS = { + claude: { id: "claude", alias: "cc", name: "Claude Code" }, + codex: { id: "codex", alias: "cx", name: "OpenAI Codex" }, + "gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI" }, + github: { id: "github", alias: "gh", name: "GitHub Copilot" }, + antigravity: { id: "antigravity", alias: "ag", name: "Antigravity" }, + iflow: { id: "iflow", alias: "if", name: "iFlow AI" }, + qwen: { id: "qwen", alias: "qw", name: "Qwen Code" }, + kiro: { id: "kiro", alias: "kr", name: "Kiro AI" }, +}; + +const APIKEY_PROVIDERS = { + openrouter: { id: "openrouter", name: "OpenRouter" }, + glm: { id: "glm", name: "GLM Coding" }, + minimax: { id: "minimax", name: "Minimax Coding" }, + kimi: { id: "kimi", name: "Kimi Coding" }, + openai: { id: "openai", name: "OpenAI" }, + anthropic: { id: "anthropic", name: "Anthropic" }, + gemini: { id: "gemini", name: "Gemini" }, +}; + +const ALL_PROVIDERS = { ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS }; + +/** + * Get auth type for provider + * @param {string} providerId - Provider ID + * @returns {string} "oauth" or "apikey" + */ +function getAuthType(providerId) { + return OAUTH_PROVIDERS[providerId] ? "oauth" : "apikey"; +} + +/** + * Count connections by provider + * @param {Array} connections - Array of connection objects + * @returns {Object} Map of providerId -> count + */ +function countConnectionsByProvider(connections) { + const counts = {}; + connections.forEach(conn => { + const providerId = conn.provider || conn.providerId; + counts[providerId] = (counts[providerId] || 0) + 1; + }); + return counts; +} + +/** + * Show main providers menu + * @param {Array} breadcrumb - Breadcrumb path + */ +async function showProvidersMenu(breadcrumb = []) { + // Build provider items list + const providerItems = []; + + Object.values(OAUTH_PROVIDERS).forEach(provider => { + providerItems.push({ + provider, + authType: "oauth", + label: (data) => { + const count = data.counts[provider.id] || 0; + return `${provider.name} (OAuth) - ${count} Connected`; + }, + action: async (data) => { + await showProviderDetail(provider.id, "oauth", data.connections, [...breadcrumb, provider.name]); + return true; + } + }); + }); + + Object.values(APIKEY_PROVIDERS).forEach(provider => { + providerItems.push({ + provider, + authType: "apikey", + label: (data) => { + const count = data.counts[provider.id] || 0; + return `${provider.name} (API) - ${count} Connected`; + }, + action: async (data) => { + await showProviderDetail(provider.id, "apikey", data.connections, [...breadcrumb, provider.name]); + return true; + } + }); + }); + + // Custom provider nodes section + providerItems.push({ + label: () => `${COLORS.dim}── Custom Providers ──${COLORS.reset}`, + action: async () => true, // separator, no-op + isSeparator: true, + }); + providerItems.push({ + label: (data) => { + const count = data.nodeCount || 0; + return `Custom Providers - ${count} Configured`; + }, + action: async () => { + await showCustomProvidersMenu([...breadcrumb, "Custom Providers"]); + return true; + } + }); + + await showMenuWithBack({ + title: "🔌 Providers Management", + breadcrumb, + refresh: async () => { + const [provRes, nodeRes] = await Promise.all([api.getProviders(), api.getProviderNodes()]); + if (!provRes.success) { + showStatus(`Failed to fetch providers: ${provRes.error}`, "error"); + await pause(); + return null; + } + const connections = provRes.data.connections || []; + const nodes = nodeRes.success ? (nodeRes.data.nodes || nodeRes.data || []) : []; + return { + connections, + counts: countConnectionsByProvider(connections), + nodeCount: nodes.length, + }; + }, + items: providerItems + }); +} + +/** + * Build provider header with alias and models + * @param {string} providerId - Provider ID + * @returns {string} + */ +function buildProviderHeader(providerId) { + const provider = ALL_PROVIDERS[providerId]; + const alias = provider.alias || providerId; + + const lines = []; + lines.push(`Alias: ${COLORS.cyan}${alias}${COLORS.reset}`); + + // Get models from static config + const models = PROVIDER_MODELS[alias] || []; + if (models.length > 0) { + const modelList = models + .slice(0, 5) + .map(m => `${alias}/${m.id}`) + .join(", "); + const more = models.length > 5 ? ` (+${models.length - 5} more)` : ""; + lines.push(`Models: ${COLORS.dim}${modelList}${more}${COLORS.reset}`); + } else { + lines.push(`Models: ${COLORS.dim}No models configured${COLORS.reset}`); + } + + return lines.join("\n"); +} + +/** + * Show provider detail with connections and actions + * @param {string} providerId - Provider ID + * @param {string} authType - "oauth" or "apikey" + * @param {Array} allConnections - All connections + * @param {Array} breadcrumb - Breadcrumb path + */ +async function showProviderDetail(providerId, authType, allConnections, breadcrumb = []) { + const provider = ALL_PROVIDERS[providerId]; + const { showListMenu } = require("../utils/menuHelper"); + + await showListMenu({ + title: `🔌 ${provider.name} (${authType.toUpperCase()})`, + breadcrumb, + backLabel: "← Back to Providers", + headerContent: buildProviderHeader(providerId), + fetchItems: async () => { + const response = await api.getProviders(); + if (response.success) { + allConnections.length = 0; + allConnections.push(...(response.data.connections || [])); + } + const providerConns = allConnections.filter(conn => + (conn.provider || conn.providerId) === providerId + ); + return { items: providerConns }; + }, + formatItem: (conn) => { + const status = conn.testStatus === "active" ? "✓" : conn.testStatus === "error" ? "✗" : "?"; + const name = conn.name || conn.email || conn.displayName || "Unnamed"; + return `${name} (${status})`; + }, + onSelect: async (conn) => { + await showConnectionActions(conn, providerId, breadcrumb); + }, + createAction: { + label: "Add New Connection", + action: async () => { + await handleAddConnection(providerId, authType); + } + } + }); +} + +/** + * Show actions for a specific connection + * @param {Object} connection - Connection object + * @param {string} providerId - Provider ID + * @param {Array} breadcrumb - Breadcrumb path + */ +async function showConnectionActions(connection, providerId, breadcrumb = []) { + const name = connection.name || connection.email || connection.displayName || "Unnamed"; + const status = connection.testStatus === "active" ? "✓ Active" : + connection.testStatus === "error" ? "✗ Error" : "? Unknown"; + + await showMenuWithBack({ + title: `🔌 ${name}`, + breadcrumb: [...breadcrumb, name], + headerContent: `Connection: ${name}\nStatus: ${status}`, + items: [ + { + label: "Rename Connection", + action: async () => { + const newName = await prompt(`New name (current: ${name}): `); + if (newName && newName.trim()) { + showStatus("Renaming connection...", "info"); + const result = await api.updateConnection(connection.id, { name: newName.trim() }); + if (result.success) { + showStatus("Connection renamed!", "success"); + connection.name = newName.trim(); + } else { + showStatus(`Rename failed: ${result.error}`, "error"); + } + await pause(); + } + return true; + } + }, + { + label: "Test Connection", + action: async () => { + showStatus("Testing connection...", "info"); + const result = await api.testConnection(connection.id); + if (result.success) { + showStatus("Connection is working!", "success"); + } else { + showStatus(`Test failed: ${result.error}`, "error"); + } + await pause(); + return true; + } + }, + { + label: "Delete Connection", + action: async () => { + const confirmed = await confirm(`Delete connection "${name}"?`); + if (confirmed) { + const result = await api.deleteConnection(connection.id); + if (result.success) { + showStatus("Connection deleted!", "success"); + } else { + showStatus(`Delete failed: ${result.error}`, "error"); + } + await pause(); + return false; // Exit menu after delete + } + return true; + } + } + ] + }); +} + +/** + * Handle adding new connection + * @param {string} providerId - Provider ID + * @param {string} authType - "oauth" or "apikey" + */ +// Providers that use Device Code Flow (terminal-based polling) +const DEVICE_CODE_PROVIDERS = ["github", "qwen", "kiro"]; + +/** + * Handle adding new connection - auto-detect flow type + * @param {string} providerId - Provider ID + * @param {string} authType - "oauth" or "apikey" + */ +async function handleAddConnection(providerId, authType) { + if (authType === "apikey") { + await handleAddApiKeyConnection(providerId); + } else { + // OAuth: auto-detect flow type based on provider + if (DEVICE_CODE_PROVIDERS.includes(providerId)) { + // Device Code Flow for GitHub, Qwen, Kiro + await handleAddDeviceCodeConnection(providerId); + } else { + // Authorization Code Flow for Claude, Codex, Gemini, etc. + await handleAddOAuthConnection(providerId); + } + } +} + +/** + * Handle adding API Key connection + * @param {string} providerId - Provider ID + */ +async function handleAddApiKeyConnection(providerId) { + clearScreen(); + const provider = ALL_PROVIDERS[providerId]; + console.log(`\n➕ Add ${provider.name} API Key Connection\n`); + + const name = await prompt("Connection Name: "); + if (!name) { + showStatus("Cancelled", "warning"); + await pause(); + return; + } + + const apiKey = await prompt("API Key: "); + if (!apiKey) { + showStatus("Cancelled", "warning"); + await pause(); + return; + } + + showStatus("Creating connection...", "info"); + + const result = await api.createApiKeyProvider({ + provider: providerId, + name, + apiKey + }); + + if (result.success) { + showStatus("✓ Connection created successfully!", "success"); + } else { + showStatus(`✗ Failed: ${result.error}`, "error"); + } + + await pause(); +} + +/** + * Handle adding OAuth Authorization Code connection + * User opens URL manually and pastes callback URL + * @param {string} providerId - Provider ID + */ +async function handleAddOAuthConnection(providerId) { + clearScreen(); + const provider = ALL_PROVIDERS[providerId]; + + // Step 1: Get auth URL + showStatus("Requesting authorization URL...", "info"); + const authResult = await api.getOAuthAuthUrl(providerId); + + if (!authResult.success) { + showStatus(`Failed: ${authResult.error}`, "error"); + await pause(); + return; + } + + const authData = authResult.data || authResult; + const authUrl = authData.authUrl; + const codeVerifier = authData.codeVerifier; + const state = authData.state; + const redirectUri = authData.redirectUri; + + if (!authUrl) { + showStatus("Failed: No auth URL received", "error"); + await pause(); + return; + } + + // Step 2: Show URL and instructions + clearScreen(); + showHeader("🔐 OAuth Login", `Providers > ${provider.name} > Add Connection`); + + console.log(` ${COLORS.bold}${COLORS.cyan}1.${COLORS.reset} Open this URL in your browser:`); + console.log(` ${COLORS.dim}${authUrl}${COLORS.reset}`); + if (copyToClipboard(authUrl)) { + console.log(` \x1b[32m✓ Link copied to clipboard!\x1b[0m`); + } + console.log(); + console.log(` ${COLORS.bold}${COLORS.cyan}2.${COLORS.reset} Complete authorization in browser`); + console.log(); + console.log(` ${COLORS.bold}${COLORS.cyan}3.${COLORS.reset} Copy the callback URL from address bar`); + console.log(` ${COLORS.dim}(looks like: http://localhost:20128/callback?code=...)${COLORS.reset}`); + console.log(); + + const callbackUrl = await prompt(" Paste callback URL: "); + if (!callbackUrl) { + showStatus("Cancelled", "warning"); + await pause(); + return; + } + + // Step 3: Parse callback URL and extract code + let code, urlState, error; + try { + const url = new URL(callbackUrl.trim()); + code = url.searchParams.get("code"); + urlState = url.searchParams.get("state"); + error = url.searchParams.get("error"); + + if (error) { + const errorDesc = url.searchParams.get("error_description") || error; + showStatus(`Authorization failed: ${errorDesc}`, "error"); + await pause(); + return; + } + + if (!code) { + showStatus("No authorization code found in URL", "error"); + await pause(); + return; + } + } catch (err) { + showStatus("Invalid URL format", "error"); + await pause(); + return; + } + + // Step 4: Exchange code for tokens + console.log(); + showStatus("Exchanging code for tokens...", "info"); + const exchangeResult = await api.exchangeOAuthCode(providerId, { + code, + redirectUri, + codeVerifier, + state: urlState || state + }); + + if (exchangeResult.success) { + showStatus("Connection created successfully!", "success"); + } else { + showStatus(`Failed: ${exchangeResult.error}`, "error"); + } + + await pause(); +} + +/** + * Handle adding OAuth Device Code connection + * @param {string} providerId - Provider ID + */ +async function handleAddDeviceCodeConnection(providerId) { + clearScreen(); + const provider = ALL_PROVIDERS[providerId]; + + // Step 1: Request device code + showStatus("Requesting device code...", "info"); + const deviceResult = await api.getOAuthDeviceCode(providerId); + + if (!deviceResult.success) { + showStatus(`Failed: ${deviceResult.error}`, "error"); + await pause(); + return; + } + + const deviceData = deviceResult.data || deviceResult; + const device_code = deviceData.device_code; + const user_code = deviceData.user_code; + const verification_uri = deviceData.verification_uri; + const verification_uri_complete = deviceData.verification_uri_complete; + const codeVerifier = deviceData.codeVerifier; + const extraData = deviceData.extraData || deviceData; + + if (!device_code) { + showStatus("Failed: No device code received", "error"); + await pause(); + return; + } + + // Step 2: Show instructions + clearScreen(); + const deviceUrl = verification_uri_complete || verification_uri; + showHeader("📱 Device Login", `Providers > ${provider.name} > Add Connection`); + + console.log(` ${COLORS.bold}${COLORS.cyan}1.${COLORS.reset} Open: ${COLORS.dim}${deviceUrl}${COLORS.reset}`); + if (copyToClipboard(deviceUrl)) { + console.log(` \x1b[32m✓ Link copied to clipboard!\x1b[0m`); + } + console.log(); + if (!verification_uri_complete && user_code) { + console.log(` ${COLORS.bold}${COLORS.cyan}2.${COLORS.reset} Enter code: ${COLORS.bold}${user_code}${COLORS.reset}`); + console.log(); + } + console.log(` ${COLORS.dim}Waiting for authorization...${COLORS.reset}`); + console.log(); + + // Step 3: Poll for token + const maxAttempts = 60; // 5 minutes (5s interval) + for (let i = 0; i < maxAttempts; i++) { + await new Promise(resolve => setTimeout(resolve, 5000)); + + const pollResult = await api.pollOAuthToken(providerId, { + deviceCode: device_code, + codeVerifier, + extraData + }); + + if (pollResult.success) { + showStatus("\nConnection created successfully!", "success"); + await pause(); + return; + } + + // Check if still pending (pending flag is at root level, not in data) + const isPending = pollResult.pending || pollResult.error === "authorization_pending" || pollResult.error === "slow_down"; + if (!isPending) { + showStatus(`\nFailed: ${pollResult.error || "Unknown error"}`, "error"); + await pause(); + return; + } + + process.stdout.write("."); + } + + showStatus("\nTimeout waiting for authorization", "error"); + await pause(); +} + +// ============================================================================ +// CUSTOM PROVIDERS (provider nodes) +// ============================================================================ + +const CUSTOM_NODE_TYPES = ["openai-compatible", "anthropic-compatible"]; +const OPENAI_API_TYPES = ["chat", "responses"]; + +/** + * Show custom providers section in main providers menu + * @param {Array} nodes - List of provider nodes + * @param {Array} connections - All connections + * @param {Array} breadcrumb + */ +async function showCustomProvidersMenu(breadcrumb = []) { + const { showListMenu } = require("../utils/menuHelper"); + + await showListMenu({ + title: "🔧 Custom Providers", + breadcrumb, + backLabel: "← Back to Providers", + fetchItems: async () => { + const res = await api.getProviderNodes(); + if (!res.success) return { items: [] }; + return { items: res.data.nodes || res.data || [] }; + }, + formatItem: (node) => `[${node.prefix}] ${node.name} (${node.type})`, + onSelect: async (node) => { + await showCustomNodeDetail(node, [...breadcrumb, node.name]); + }, + createAction: { + label: "➕ Add Custom Provider", + action: async () => { + await handleAddCustomNode(); + } + } + }); +} + +/** + * Show detail menu for a custom provider node + */ +async function showCustomNodeDetail(node, breadcrumb = []) { + await showMenuWithBack({ + title: `🔧 ${node.name}`, + breadcrumb, + headerContent: [ + `Type: ${node.type}`, + `Prefix: ${COLORS.cyan}${node.prefix}${COLORS.reset}`, + `Base URL: ${COLORS.dim}${node.baseUrl}${COLORS.reset}`, + ].join("\n"), + items: [ + { + label: "Connections", + action: async () => { + await showCustomNodeConnections(node, breadcrumb); + return true; + } + }, + { + label: "Edit Node", + action: async () => { + await handleEditCustomNode(node); + return true; + } + }, + { + label: "Delete Node", + action: async () => { + const confirmed = await confirm(`Delete "${node.name}" and all its connections?`); + if (confirmed) { + const res = await api.deleteProviderNode(node.id); + if (res.success) { + showStatus("Node deleted!", "success"); + } else { + showStatus(`Delete failed: ${res.error}`, "error"); + } + await pause(); + return false; + } + return true; + } + } + ] + }); +} + +/** + * Show connections for a custom provider node + */ +async function showCustomNodeConnections(node, breadcrumb = []) { + const { showListMenu } = require("../utils/menuHelper"); + + await showListMenu({ + title: `🔌 ${node.name} – Connections`, + breadcrumb, + backLabel: "← Back", + fetchItems: async () => { + const res = await api.getProviders(); + if (!res.success) return { items: [] }; + const all = res.data.connections || []; + const items = all.filter(c => c.provider === node.id); + return { items }; + }, + formatItem: (conn) => { + const status = conn.testStatus === "active" ? "✓" : conn.testStatus === "error" ? "✗" : "?"; + return `${conn.name || "Unnamed"} (${status})`; + }, + onSelect: async (conn) => { + await showConnectionActions(conn, node.id, breadcrumb); + }, + createAction: { + label: "Add API Key Connection", + action: async () => { + await handleAddCustomNodeConnection(node); + } + } + }); +} + +/** + * Add API key connection to a custom provider node + */ +async function handleAddCustomNodeConnection(node) { + clearScreen(); + console.log(`\n➕ Add Connection to ${node.name}\n`); + + const name = await prompt("Connection Name: "); + if (!name) { showStatus("Cancelled", "warning"); await pause(); return; } + + const apiKey = await prompt("API Key: "); + if (!apiKey) { showStatus("Cancelled", "warning"); await pause(); return; } + + showStatus("Creating connection...", "info"); + const res = await api.createApiKeyProvider({ provider: node.id, name, apiKey }); + + showStatus(res.success ? "✓ Connection created!" : `✗ Failed: ${res.error}`, res.success ? "success" : "error"); + await pause(); +} + +/** + * Handle adding a new custom provider node + */ +async function handleAddCustomNode() { + clearScreen(); + console.log("\n➕ Add Custom Provider\n"); + + // Step 1: Select type + const typeChoices = CUSTOM_NODE_TYPES.map((t, i) => ` ${i + 1}. ${t}`).join("\n"); + console.log(`Select type:\n${typeChoices}\n`); + const typeInput = await prompt("Type (1/2): "); + const typeIdx = parseInt(typeInput) - 1; + if (isNaN(typeIdx) || !CUSTOM_NODE_TYPES[typeIdx]) { + showStatus("Cancelled", "warning"); await pause(); return; + } + const type = CUSTOM_NODE_TYPES[typeIdx]; + + // Step 2: Inputs + const name = await prompt("Name: "); + if (!name) { showStatus("Cancelled", "warning"); await pause(); return; } + + const prefix = await prompt("Prefix (used in model IDs, e.g. myapi): "); + if (!prefix) { showStatus("Cancelled", "warning"); await pause(); return; } + + const baseUrl = await prompt("Base URL (e.g. https://api.example.com/v1): "); + if (!baseUrl) { showStatus("Cancelled", "warning"); await pause(); return; } + + // Step 3: API type (OpenAI only) + let apiType; + if (type === "openai-compatible") { + const apiTypeChoices = OPENAI_API_TYPES.map((t, i) => ` ${i + 1}. ${t}`).join("\n"); + console.log(`\nAPI Type:\n${apiTypeChoices}\n`); + const apiTypeInput = await prompt("API Type (1/2, default 1): "); + const apiTypeIdx = parseInt(apiTypeInput) - 1; + apiType = OPENAI_API_TYPES[apiTypeIdx] || "chat"; + } + + showStatus("Creating provider node...", "info"); + const body = { name, prefix, baseUrl, type, ...(apiType && { apiType }) }; + const res = await api.createProviderNode(body); + + showStatus(res.success ? "✓ Provider created!" : `✗ Failed: ${res.error}`, res.success ? "success" : "error"); + await pause(); +} + +/** + * Handle editing a custom provider node + */ +async function handleEditCustomNode(node) { + clearScreen(); + console.log(`\n✏️ Edit ${node.name}\n`); + console.log(`${COLORS.dim}Leave blank to keep current value${COLORS.reset}\n`); + + const name = await prompt(`Name (${node.name}): `); + const baseUrl = await prompt(`Base URL (${node.baseUrl}): `); + const prefix = await prompt(`Prefix (${node.prefix}): `); + + const updates = {}; + if (name && name.trim()) updates.name = name.trim(); + if (baseUrl && baseUrl.trim()) updates.baseUrl = baseUrl.trim(); + if (prefix && prefix.trim()) updates.prefix = prefix.trim(); + + if (!Object.keys(updates).length) { + showStatus("No changes", "warning"); await pause(); return; + } + + showStatus("Updating...", "info"); + const res = await api.updateProviderNode(node.id, updates); + if (res.success) { + Object.assign(node, updates); + showStatus("✓ Updated!", "success"); + } else { + showStatus(`✗ Failed: ${res.error}`, "error"); + } + await pause(); +} + +module.exports = { showProvidersMenu }; diff --git a/cli/src/cli/menus/settings.js b/cli/src/cli/menus/settings.js new file mode 100644 index 0000000..ce77933 --- /dev/null +++ b/cli/src/cli/menus/settings.js @@ -0,0 +1,204 @@ +const api = require("../api/client"); +const { confirm, pause } = require("../utils/input"); +const { showStatus } = require("../utils/display"); +const { showMenuWithBack } = require("../utils/menuHelper"); + +// ANSI colors +const COLORS = { + reset: "\x1b[0m", + green: "\x1b[32m", + red: "\x1b[31m", + yellow: "\x1b[33m", + dim: "\x1b[2m", + cyan: "\x1b[36m" +}; + +const DEFAULT_PASSWORD = "123456"; + +/** + * Show settings menu (tunnel + RTK + reset password) + * @param {Array} breadcrumb - Breadcrumb path + */ +async function showSettingsMenu(breadcrumb = []) { + await showMenuWithBack({ + title: "⚙️ Settings", + breadcrumb, + headerContent: async (data) => { + const lines = []; + + // Tunnel section + const tunnel = data?.tunnel || {}; + if (tunnel.enabled && tunnel.publicUrl) { + lines.push(` Endpoint: ${COLORS.green}${tunnel.publicUrl}/v1${COLORS.reset}`); + lines.push(` Tunnel: ${COLORS.green}ON${COLORS.reset} ${COLORS.dim}(${tunnel.shortId})${COLORS.reset}`); + } else { + lines.push(` Endpoint: http://localhost:20128/v1`); + lines.push(` Tunnel: ${COLORS.red}OFF${COLORS.reset} ${COLORS.dim}(local only)${COLORS.reset}`); + } + + // RTK section + const rtkOn = data?.settings?.rtkEnabled !== false; + lines.push(` RTK: ${rtkOn ? `${COLORS.green}ON${COLORS.reset}` : `${COLORS.red}OFF${COLORS.reset}`} ${COLORS.dim}(Token Saver)${COLORS.reset}`); + const headroomOn = data?.settings?.headroomEnabled === true; + lines.push(` Headroom: ${headroomOn ? `${COLORS.green}ON${COLORS.reset}` : `${COLORS.red}OFF${COLORS.reset}`} ${COLORS.dim}(${data?.settings?.headroomUrl || "http://localhost:8787"})${COLORS.reset}`); + + // Auth mode section + const authMode = data?.settings?.authMode || "password"; + const authColor = authMode === "password" ? COLORS.green : COLORS.yellow; + lines.push(` Auth: ${authColor}${authMode.toUpperCase()}${COLORS.reset} ${COLORS.dim}(login mode)${COLORS.reset}`); + + return lines.join("\n"); + }, + refresh: async () => { + const [tunnelRes, settingsRes] = await Promise.all([ + api.getTunnelStatus(), + api.getSettings() + ]); + return { + tunnel: tunnelRes.success ? (tunnelRes.data || {}) : {}, + settings: settingsRes.success ? (settingsRes.data || {}) : {} + }; + }, + items: [ + { + label: "Tunnel ON", + action: async () => { await enableTunnel(); return true; } + }, + { + label: "Tunnel OFF", + action: async () => { await disableTunnel(); return true; } + }, + { + label: (d) => { + const on = d?.settings?.rtkEnabled !== false; + return `Token Saver (RTK): ${on ? "ON" : "OFF"} → toggle`; + }, + action: async (d) => { await toggleRtk(d?.settings?.rtkEnabled !== false); return true; } + }, + { + label: (d) => { + const on = d?.settings?.headroomEnabled === true; + return `Token Saver (Headroom): ${on ? "ON" : "OFF"} → toggle`; + }, + action: async (d) => { await toggleHeadroom(d?.settings?.headroomEnabled === true); return true; } + }, + { + label: "🔑 Reset Password to Default", + action: async () => { await resetPassword(); return true; } + }, + { + label: (d) => { + const mode = d?.settings?.authMode || "password"; + return mode === "password" ? "🔓 Reset Auth Mode (already password)" : `🔓 Reset Auth Mode to Password (current: ${mode})`; + }, + action: async () => { await resetAuthMode(); return true; } + } + ] + }); +} + +/** + * Reset authMode to "password" via API. Used when OIDC is misconfigured + * and user is locked out of dashboard. CLI bypasses auth via x-9r-cli-token. + */ +async function resetAuthMode() { + const ok = await confirm("Reset auth mode to PASSWORD (disable OIDC)?"); + if (!ok) { + showStatus("Cancelled", "info"); + await pause(); + return; + } + + const result = await api.updateSettings({ authMode: "password" }); + if (result.success) { + showStatus("Auth mode reset to password. OIDC disabled.", "success"); + } else { + showStatus(`Failed: ${result.error}`, "error"); + } + await pause(); +} + +/** + * Enable tunnel via API + */ +async function enableTunnel() { + showStatus("Creating tunnel...", "info"); + const result = await api.enableTunnel(); + + if (result.success) { + const { publicUrl, shortId, alreadyRunning } = result.data || {}; + if (alreadyRunning) { + showStatus(`Tunnel already running: ${publicUrl}`, "success"); + } else { + showStatus(`Tunnel enabled: ${publicUrl} (${shortId})`, "success"); + } + } else { + showStatus(`Failed: ${result.error}`, "error"); + } + + await pause(); +} + +/** + * Disable tunnel via API + */ +async function disableTunnel() { + const result = await api.disableTunnel(); + + if (result.success) { + showStatus("Tunnel disabled", "success"); + } else { + showStatus(`Failed: ${result.error}`, "error"); + } + + await pause(); +} + +/** + * Toggle RTK (Token Saver) via API + * @param {boolean} currentlyOn + */ +async function toggleRtk(currentlyOn) { + const next = !currentlyOn; + const result = await api.updateSettings({ rtkEnabled: next }); + if (result.success) { + showStatus(`Token Saver ${next ? "enabled" : "disabled"}`, "success"); + } else { + showStatus(`Failed: ${result.error}`, "error"); + } + await pause(); +} + +async function toggleHeadroom(currentlyOn) { + const next = !currentlyOn; + const result = await api.updateSettings({ headroomEnabled: next }); + if (result.success) { + showStatus(`Headroom ${next ? "enabled" : "disabled"}`, "success"); + } else { + showStatus(`Failed: ${result.error}`, "error"); + } + await pause(); +} + +/** + * Reset dashboard password to default via server API (writes the live SQLite DB). + * After reset, user can log in with the default password "123456". + */ +async function resetPassword() { + const ok = await confirm(`Reset dashboard password to default "${DEFAULT_PASSWORD}"?`); + if (!ok) { + showStatus("Cancelled", "info"); + await pause(); + return; + } + + const result = await api.resetPassword(); + if (result.success) { + showStatus(`Password reset. Default: ${DEFAULT_PASSWORD}`, "success"); + } else { + showStatus(`Failed to reset password: ${result.error}`, "error"); + } + await pause(); +} + +module.exports = { showSettingsMenu }; diff --git a/cli/src/cli/terminalUI.js b/cli/src/cli/terminalUI.js new file mode 100644 index 0000000..fb28330 --- /dev/null +++ b/cli/src/cli/terminalUI.js @@ -0,0 +1,121 @@ +const api = require("./api/client"); +const { showMenuWithBack } = require("./utils/menuHelper"); +const { showProvidersMenu } = require("./menus/providers"); +const { showApiKeysMenu } = require("./menus/apiKeys"); +const { showCombosMenu } = require("./menus/combos"); +const { showSettingsMenu } = require("./menus/settings"); +const { showCliToolsMenu } = require("./menus/cliTools"); + +const COLORS = { + reset: "\x1b[0m", + green: "\x1b[32m", + red: "\x1b[31m", + dim: "\x1b[2m", + cyan: "\x1b[36m" +}; + +// Cached header (SWR): show last value instantly, refresh in background. +let cachedHeader = ""; +let fetchingHeader = false; + +function renderHeader(port, keys, tunnel) { + const tunnelEnabled = tunnel && tunnel.enabled === true; + const lines = []; + if (tunnelEnabled && tunnel.publicUrl) { + lines.push(`Endpoint: ${COLORS.green}${tunnel.publicUrl}/v1${COLORS.reset}`); + lines.push(`Tunnel: ${COLORS.green}ON${COLORS.reset} ${COLORS.dim}(${tunnel.shortId})${COLORS.reset}`); + } else { + lines.push(`Endpoint: http://localhost:${port}/v1`); + lines.push(`Tunnel: ${COLORS.red}OFF${COLORS.reset} ${COLORS.dim}(local only)${COLORS.reset}`); + } + if (!keys || keys.length === 0) { + lines.push(`Key: ${COLORS.dim}No API keys yet${COLORS.reset}`); + } else { + lines.push(`Key: ${COLORS.cyan}${keys[0].key}${COLORS.reset}`); + keys.slice(1).forEach(k => lines.push(` ${COLORS.cyan}${k.key}${COLORS.reset}`)); + } + return lines.join("\n"); +} + +async function refreshHeaderBg(port) { + if (fetchingHeader) return; + fetchingHeader = true; + try { + const [keysResult, tunnelResult] = await Promise.all([ + api.getApiKeys(), + api.getTunnelStatus() + ]); + const keys = keysResult.success ? (keysResult.data.keys || []) : []; + const tunnel = tunnelResult.success ? (tunnelResult.data || {}) : {}; + cachedHeader = renderHeader(port, keys, tunnel); + } finally { + fetchingHeader = false; + } +} + +function getHeader(port) { + // Kick off background refresh; return cache (or placeholder on first call). + refreshHeaderBg(port); + return cachedHeader || `Endpoint: http://localhost:${port}/v1\nTunnel: ${COLORS.dim}...${COLORS.reset}\nKey: ${COLORS.dim}...${COLORS.reset}`; +} + +/** + * Start Terminal UI + * @param {number} port - Server port number + */ +async function startTerminalUI(port) { + // Configure API client + api.configure({ port }); + + const basePath = ["9Router"]; + + // Prime header cache before first render + await refreshHeaderBg(port); + + // Main menu + await showMenuWithBack({ + title: "📡 9Router Terminal UI", + breadcrumb: basePath, + headerContent: () => getHeader(port), + items: [ + { + label: "Providers", + action: async () => { + await showProvidersMenu([...basePath, "Providers"]); + return true; // Continue + } + }, + { + label: "API Keys", + action: async () => { + await showApiKeysMenu(port, [...basePath, "API Keys"]); + return true; + } + }, + { + label: "Combos", + action: async () => { + await showCombosMenu([...basePath, "Combos"]); + return true; + } + }, + { + label: "CLI Tools", + action: async () => { + await showCliToolsMenu(port, [...basePath, "CLI Tools"]); + return true; + } + }, + { + label: "Settings", + action: async () => { + await showSettingsMenu([...basePath, "Settings"]); + return true; + } + } + ], + backLabel: "← Back to Interface Menu" + }); +} + +module.exports = { startTerminalUI }; diff --git a/cli/src/cli/tray/autostart.js b/cli/src/cli/tray/autostart.js new file mode 100644 index 0000000..4ab93cf --- /dev/null +++ b/cli/src/cli/tray/autostart.js @@ -0,0 +1,306 @@ +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { execSync } = require("child_process"); + +const APP_NAME = "9router"; +const APP_LABEL = "com.9router.autostart"; + +/** + * Resolve the absolute path to this package's cli.js. + * + * Order of preference: + * 1. Explicit `cliPath` argument — cleanest, used when called from running + * cli.js with `__filename`. + * 2. `process.argv[1]` if it's our cli.js — true when 9router is currently + * running and the tray menu fires this code path. + * 3. Compute relative to this file's own location. autostart.js lives at + * `/src/cli/tray/autostart.js`, so cli.js is three levels up. + * This works for any global install layout (nvm, Volta, asdf, Homebrew, + * /usr/local, etc.) without depending on `npm bin -g` (removed in npm 9) + * or a hardcoded `/usr/local/...` path. + * + * Returns null if no candidate exists — callers should not write an autostart + * entry pointing at a non-existent script. + */ +function getCliJsPath(cliPath) { + if (cliPath) { + const resolved = path.resolve(cliPath); + if (fs.existsSync(resolved)) return resolved; + } + if (process.argv[1]) { + const resolved = path.resolve(process.argv[1]); + if (path.basename(resolved) === "cli.js" && fs.existsSync(resolved)) { + return resolved; + } + } + const computed = path.resolve(__dirname, "..", "..", "..", "cli.js"); + if (fs.existsSync(computed)) return computed; + return null; +} + +/** + * Enable auto startup on OS boot + * @param {string} cliPath - Optional path to cli.js (defaults to auto-detect) + * @returns {boolean} success + */ +function enableAutoStart(cliPath) { + 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); + } catch (err) { + // Silent fail — autostart is optional + } + return false; +} + +/** + * Disable auto startup + * @returns {boolean} success + */ +function disableAutoStart() { + const platform = process.platform; + try { + if (platform === "darwin") return disableMacOS(); + if (platform === "win32") return disableWindows(); + if (platform === "linux") return disableLinux(); + } catch (err) {} + return false; +} + +/** + * Check if autostart is enabled. + * + * On macOS, both the plist file and the launchd registration must be present — + * otherwise the tray menu would lie about the state (showing "✓ Enabled" even + * when launchd has the agent in a failed state or hasn't loaded it). + */ +function isAutoStartEnabled() { + const platform = process.platform; + + try { + if (platform === "darwin") { + const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`); + if (!fs.existsSync(plistPath)) return false; + try { + execSync(`launchctl list ${APP_LABEL}`, { + stdio: ["ignore", "ignore", "ignore"], + timeout: 3000 + }); + return true; + } catch (e) { + return false; + } + } else if (platform === "win32") { + const startupPath = path.join(process.env.APPDATA || "", "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `${APP_NAME}.vbs`); + return fs.existsSync(startupPath); + } else if (platform === "linux") { + const desktopPath = path.join(os.homedir(), ".config", "autostart", `${APP_NAME}.desktop`); + return fs.existsSync(desktopPath); + } + } catch (e) {} + return false; +} + +// ============ macOS ============ + +/** + * Returns true when the current Node process IS the running instance that + * launchd is managing under our agent label. + * + * `launchctl unload ` (and `load`) for an Aqua user-domain agent sends + * SIGTERM to the running process. When the running 9router cli.js was itself + * spawned by the autostart launchd agent (i.e. user enabled autostart at + * some point, then rebooted, then clicked the tray icon's "Disable + * Auto-start" menu item), an unload would kill the very process executing + * the click handler — and the tray icon would disappear instead of the menu + * label flipping back to "Enable Auto-start". This helper lets the enable + * and disable paths sidestep that by skipping launchctl when we'd otherwise + * be killing ourselves. + */ +function isAgentSelfMacOS() { + try { + const output = execSync(`launchctl list ${APP_LABEL}`, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 3000 + }); + const match = output.match(/"PID"\s*=\s*(\d+)/); + return !!(match && parseInt(match[1], 10) === process.pid); + } catch (e) { + return false; + } +} + +function enableMacOS(cliPath) { + const launchAgentsDir = path.join(os.homedir(), "Library", "LaunchAgents"); + const plistPath = path.join(launchAgentsDir, `${APP_LABEL}.plist`); + + if (!fs.existsSync(launchAgentsDir)) { + fs.mkdirSync(launchAgentsDir, { recursive: true }); + } + + const nodePath = process.execPath; + const routerScript = getCliJsPath(cliPath); + // Don't write a broken plist that references a non-existent script. + if (!routerScript) return false; + + // Invoke node + cli.js directly with absolute paths — no shell wrapper. + // The previous design ran `zsh -l -c "..."` so a login shell would source + // nvm/.zshrc and set PATH; that's fragile (nvm.sh sourcing varies by user, + // some setups don't put node on PATH from a non-interactive login shell). + // 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 plistContent = ` + + + + Label + ${APP_LABEL} + ProgramArguments + + ${nodePath} + ${routerScript} + --tray + --skip-update + + EnvironmentVariables + + PATH + ${launchPath} + + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/9router.log + StandardErrorPath + /tmp/9router.error.log + +`; + + fs.writeFileSync(plistPath, plistContent); + + // If we're the running agent already, launchctl unload/load would send + // ourselves SIGTERM. Skip it — the plist file is updated on disk and + // launchd will pick it up at next login. isAutoStartEnabled() will still + // return true because launchctl already has the agent loaded. + if (isAgentSelfMacOS()) { + return true; + } + + // Register with launchd in the current session. Without this, the agent + // only takes effect on the next user login and the user has no signal that + // anything actually happened. `unload` first defends against re-enable + // replacing an existing plist. + try { + execSync(`launchctl unload "${plistPath}"`, { stdio: "ignore" }); + } catch (e) {} + try { + execSync(`launchctl load -w "${plistPath}"`, { stdio: "ignore" }); + } catch (e) { + // Even if load fails, the plist is on disk and will be picked up at next + // login; report success based on the file write. + } + return true; +} + +function disableMacOS() { + const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`); + + // Don't kill ourselves: when the current process is the running agent, + // `launchctl unload` would send SIGTERM and the user clicking + // "Disable Auto-start" from the tray menu would lose their tray icon + // instead of just flipping the menu label. Skip the unload — removing the + // plist file is enough to prevent the agent from starting on next login. + if (!isAgentSelfMacOS()) { + try { + execSync(`launchctl unload "${plistPath}"`, { stdio: "ignore" }); + } catch (e) {} + } + + if (fs.existsSync(plistPath)) { + fs.unlinkSync(plistPath); + } + return true; +} + +// ============ Windows ============ + +function enableWindows(cliPath) { + const startupDir = path.join(process.env.APPDATA || "", "Microsoft", "Windows", "Start Menu", "Programs", "Startup"); + const vbsPath = path.join(startupDir, `${APP_NAME}.vbs`); + + if (!fs.existsSync(startupDir)) return false; + + const nodePath = process.execPath; + const routerScript = getCliJsPath(cliPath); + if (!routerScript) return false; + + // 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 +`; + fs.writeFileSync(vbsPath, vbsContent); + return true; +} + +function disableWindows() { + const vbsPath = path.join(process.env.APPDATA || "", "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `${APP_NAME}.vbs`); + if (fs.existsSync(vbsPath)) { + fs.unlinkSync(vbsPath); + } + return true; +} + +// ============ Linux ============ + +function enableLinux(cliPath) { + const autostartDir = path.join(os.homedir(), ".config", "autostart"); + const desktopPath = path.join(autostartDir, `${APP_NAME}.desktop`); + + if (!fs.existsSync(autostartDir)) { + try { fs.mkdirSync(autostartDir, { recursive: true }); } + catch (e) { return false; } + } + + const nodePath = process.execPath; + const routerScript = getCliJsPath(cliPath); + if (!routerScript) return false; + + const desktopContent = `[Desktop Entry] +Type=Application +Name=9Router +Comment=9Router API Proxy +Exec=${nodePath} ${routerScript} --tray --skip-update +Hidden=false +NoDisplay=false +X-GNOME-Autostart-enabled=true +`; + fs.writeFileSync(desktopPath, desktopContent); + return true; +} + +function disableLinux() { + const desktopPath = path.join(os.homedir(), ".config", "autostart", `${APP_NAME}.desktop`); + if (fs.existsSync(desktopPath)) { + fs.unlinkSync(desktopPath); + } + return true; +} + +module.exports = { + enableAutoStart, + disableAutoStart, + isAutoStartEnabled +}; diff --git a/cli/src/cli/tray/icon.ico b/cli/src/cli/tray/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..9cf0e3d1a889f82a6ae42d765b712a9e85a8241e GIT binary patch literal 969 zcmV;)12+5s0096203aX$0096X0J8%C02TlM0EtjeM-2)Z3IG5A4M|8uQUCw|AOHXW zAP5Ek0047(dh`GQ00v@9M??Vs0RI60puMM)000AMNkl6dDiHQVDW12=xAVK_5#Rj|y%|V1vYJ^g?2E-yLQlx)CL1RIB&_?j0@nH3!5Citm zQ^BIpg`}a_)F@42lbR;mWV3m8v(LxF#-~5hkXb3&xjZ~G^YD6R<~!f7VXf)t?($45 z)hDA@70@oIlt2h+ZH_?UQ{g)s^xyrR(t*sVkp{Eg!a&O@d;kNme0)bEIWr{axF7NW z@O!=|S*A$$qo2=BnZn8!E^HxQ7z>mPqN%+$6HE1NIW8eXr^!_x@q~J}91r_UuO;A# z(%>M`ZUaMAiExCfYzc8Bu%*NSy#Iw8OZ2h5cn>#&S3z!eoR#0tqgB5sTmr9HJle94 z_`y%{YIkvG%co|z`Ry^5F8<(*3$VrEFHrOLcOkB9h9Er;KqOvI-J4%h`P3`U_*x9O zZo47g(~pUi0gxIx!Q=-`Tszjx>=%d7st?9ed+;PCR_%lfVV!WmtA8A?VGjWQmGh*2 z`iNlFM}bIR{E=JdP6J?LajKp<;Dq0G3pR^(8*d2DJ3BqLF53NTBjI?gb)R!$vo1J5 zesL}YqB}d+MSFB-$L;a@JdcHQEgSuRqqYpSmMofic|M7JvJmZLM8Iw%3unF~Qa2otxqt-hz!XTsk?# z%x4G4{(U)wmCvzo?ld!l?_nzw0IV+lHe}WdShjSn5I(+*$M$S6d6VJJ@tY)cbewK>e z&jE$ZH#gKzKDSJw>kZ=1zgAfN^ko*4!%nyaX6k4{o8lC%T3KTojqeX*W7X?|5Fqf= z{B`nW(pN7y;joqmTyO#YLygoDhOCE)8) z;1c8wgEs3e3<#22jSG?qe`&zBoWd_tq+5ZKTZx-U(A{>1Y*^MV^$;!JQTF-kOlY`oZaj0c3ux+00000NkvXXu0mjfrVY{V literal 0 HcmV?d00001 diff --git a/cli/src/cli/tray/icon.png b/cli/src/cli/tray/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..55bf5151f0fbd069f15bebcec89ea36f6a937c9e GIT binary patch literal 947 zcmV;k15EshP)!yi`1WRL@Mob_<{87aQya~-g zgivaPQnd!eA}CU%e?UQFL3+?e@S^cx^`H;~_Rv$oqR@q;q1eQ3->+e<>FDnAOf1zWqgNHsE~u112x)DOK;cv2I~(-h z{hiW*%&3tDv);l$%PD*S1F(F2MwdAzl~@ zlnkP&y*3j|^=&yWAw#FhRUh$$dbb=8`%JGT;EB@UAkl6ELsf}zgsN-_aU`&%!~wkj zg&RxsvAuW?H-lF}Zg!lN-_N5}zbIS+uUI_VvXA(|Pw{GZac9e?X1Mw7F_td=;EW5f z#o;ed^Y(Wku55-NJr6)6UQgYdUsCzhE6(^@47hH)A>PxEiIf458alz`2Tfc%*39e| zhtR4I#!`FmBqmnvgbQJvaKNj79Is&y0RENpq<;E{VAV%~NMHPsTjx##U}JHro;l!z z-*pQ%i*_4t2+un^J+>~|{c9uPc&v4wb7HeDI6!`JE(D@GJJ&^fbZ5uy@%lZ^xy8ER z5@e@G$#`dn)iNDwv{(#+9?VMZ8?7KDE4F9h`*0G+6 z7L&tHxCCbEXhNIf6s}rXV;ha{4`XB1>w*v<@YDQt@@3LjFF4__mIqvL0siDACi`C^ zGycmuJoCq|{P}TrQMd$V@}0sC6kE@|+9t{$X+;8mW*qr&tnn#ijl3R@nk_mrlz_y&i zFH@vjfs$K^n@G^zc7|+N)-LrBE#Fc7Rv^+W`54`KiS002ovPDHLkV1hY^(7ylx literal 0 HcmV?d00001 diff --git a/cli/src/cli/tray/tray.js b/cli/src/cli/tray/tray.js new file mode 100644 index 0000000..6658e94 --- /dev/null +++ b/cli/src/cli/tray/tray.js @@ -0,0 +1,322 @@ +const { exec } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +let trayInstance = null; +let isWinTray = false; + +/** + * Get icon base64 from file — used for systray (mac/linux) + */ +function getIconBase64() { + const isWin = process.platform === "win32"; + const iconFile = isWin ? "icon.ico" : "icon.png"; + try { + const iconPath = path.join(__dirname, iconFile); + if (fs.existsSync(iconPath)) { + return fs.readFileSync(iconPath).toString("base64"); + } + } catch (e) {} + // Fallback: minimal green dot icon (PNG) + return "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAALEwAACxMBAJqcGAAAAHpJREFUOE9jYBgFgwEwMjIy/Gdg+P8fyP4PxP8ZGBgEcBnGyMjIsICBgSEAhyH/gfgBUNN8XJoZsdkCVL8Ah+b/QPwbqvkBMvk/AwMDAzYX/GdgYAhAN+A/SICRWAMYGfFEJSMjzriEiwDR/xmIa2RkZCSqnZERb3QCAAo3KxzxbKe1AAAAAElFTkSuQmCC"; +} + +/** + * Check if system tray is supported on current OS + * Supported: macOS, Windows, Linux (with GUI) + */ +function isTraySupported() { + const platform = process.platform; + if (!["darwin", "win32", "linux"].includes(platform)) { + return false; + } + if (platform === "linux" && !process.env.DISPLAY) { + return false; + } + return true; +} + +/** + * Initialize system tray with menu + * @param {Object} options - { port, onQuit, onOpenDashboard } + * @returns {Object|null} tray instance or null if not supported/failed + */ +function initTray(options) { + if (!isTraySupported()) { + return null; + } + + // Windows uses PowerShell NotifyIcon (AV-safe), others use systray + if (process.platform === "win32") { + return initWindowsTray(options); + } + return initUnixTray(options); +} + +/** + * Build menu items array shared between platforms + */ +function buildMenuItems(port, autostartEnabled) { + return [ + { title: `9Router (Port ${port})`, tooltip: "Server is running", enabled: false }, + { title: "Open Dashboard", tooltip: "Open in browser", enabled: true }, + { + title: autostartEnabled ? "✓ Auto-start Enabled" : "Enable Auto-start", + tooltip: "Run on OS startup", + enabled: true + }, + { title: "Quit", tooltip: "Stop server and exit", enabled: true } + ]; +} + +// Menu item indexes +const MENU_INDEX = { STATUS: 0, DASHBOARD: 1, AUTOSTART: 2, QUIT: 3 }; + +/** + * Get current autostart state + */ +function getAutostartEnabled() { + try { + const { isAutoStartEnabled } = require("./autostart"); + return isAutoStartEnabled(); + } catch (e) { + return false; + } +} + +/** + * Handle menu item click (shared logic) + */ +function handleClick(index, options, onAutostartToggle) { + const { onQuit, onOpenDashboard, port } = options; + if (index === MENU_INDEX.DASHBOARD) { + if (onOpenDashboard) onOpenDashboard(); + else openBrowser(`http://localhost:${port}/dashboard`); + } else if (index === MENU_INDEX.AUTOSTART) { + const enabled = getAutostartEnabled(); + try { + const { enableAutoStart, disableAutoStart } = require("./autostart"); + if (enabled) disableAutoStart(); + else enableAutoStart(); + onAutostartToggle(!enabled); + } catch (e) {} + } else if (index === MENU_INDEX.QUIT) { + console.log("\n👋 Shutting down..."); + if (onQuit) onQuit(); + killTray(); + setTimeout(() => process.exit(0), 500); + } +} + +/** + * Windows tray via PowerShell NotifyIcon + */ +function initWindowsTray(options) { + const { port } = options; + try { + const { initWinTray } = require("./trayWin"); + const iconPath = path.join(__dirname, "icon.ico"); + const autostartEnabled = getAutostartEnabled(); + const items = buildMenuItems(port, autostartEnabled); + + trayInstance = initWinTray({ + iconPath, + tooltip: `9Router - Port ${port}`, + items, + onClick: (index) => { + handleClick(index, options, (newEnabled) => { + const newTitle = newEnabled ? "✓ Auto-start Enabled" : "Enable Auto-start"; + trayInstance.updateItem(MENU_INDEX.AUTOSTART, newTitle, true); + }); + } + }); + + isWinTray = true; + return trayInstance; + } catch (err) { + return null; + } +} + +/** + * macOS/Linux tray via systray binary + * + * Prefers `systray2` (active fork of `systray`, ships newer + * getlantern/systray-portable binaries that work on macOS 14+ and Apple + * Silicon under Rosetta). Falls back to legacy `systray@1.0.5` if systray2 + * is not available, though that binary's Mach-O headers are rejected by + * modern dyld and the icon will not appear. + */ +function resolveSystray() { + let runtimeDir = null; + try { + const { getRuntimeNodeModules } = require("../../../hooks/sqliteRuntime"); + runtimeDir = getRuntimeNodeModules(); + } catch (e) {} + + // 1) systray2 in runtime dir (where ensureTrayRuntime installs it) + if (runtimeDir) { + try { return { mod: require(path.join(runtimeDir, "systray2")).default, isV2: true }; } catch (e) {} + } + // 2) systray2 resolvable from the package's own node_modules / NODE_PATH + try { return { mod: require("systray2").default, isV2: true }; } catch (e) {} + // 3) Legacy systray fallback (unlikely to render on modern macOS) + try { return { mod: require("systray").default, isV2: false }; } catch (e) {} + if (runtimeDir) { + try { return { mod: require(path.join(runtimeDir, "systray")).default, isV2: false }; } catch (e) {} + } + return null; +} + +function chmodTrayBin(pkgName) { + // systray2's npm tarball occasionally lands without +x on the bundled Go + // binary (observed on macOS). spawn() then fails with EACCES. Best-effort + // chmod on every init avoids a hard-to-diagnose silent tray failure. + try { + const { getRuntimeNodeModules } = require("../../../hooks/sqliteRuntime"); + const binName = process.platform === "darwin" ? "tray_darwin_release" : "tray_linux_release"; + const candidates = [ + path.join(getRuntimeNodeModules(), pkgName, "traybin", binName), + path.join(__dirname, "..", "..", "..", "node_modules", pkgName, "traybin", binName) + ]; + for (const p of candidates) { + if (fs.existsSync(p)) fs.chmodSync(p, 0o755); + } + } catch (e) {} +} + +function initUnixTray(options) { + const { port } = options; + try { + const resolved = resolveSystray(); + if (!resolved) return null; + const { mod: SysTray, isV2 } = resolved; + + chmodTrayBin(isV2 ? "systray2" : "systray"); + + const autostartEnabled = getAutostartEnabled(); + const items = buildMenuItems(port, autostartEnabled); + + const menu = { + icon: getIconBase64(), + // The bundled icon.png is a full-color RGBA logo. Don't mark it as a + // template icon: macOS would then render it as a solid white square + // because template mode only uses the alpha channel. + isTemplateIcon: false, + title: "", + tooltip: `9Router - Port ${port}`, + items + }; + + trayInstance = new SysTray({ menu, debug: false, copyDir: true }); + isWinTray = false; + + trayInstance.onClick((action) => { + handleClick(action.seq_id, options, (newEnabled) => { + trayInstance.sendAction({ + type: "update-item", + item: { + title: newEnabled ? "✓ Auto-start Enabled" : "Enable Auto-start", + tooltip: "Run on OS startup", + enabled: true + }, + seq_id: MENU_INDEX.AUTOSTART + }); + }); + }); + + if (isV2) { + // systray2 exposes a ready() promise instead of onReady/onError. Surface + // failures (binary crash, EACCES, etc.) so users can see why the icon + // didn't appear instead of getting a misleading "running in tray" log. + trayInstance.ready().catch((err) => { + process.stderr.write(`[9router] tray failed to start: ${err && err.message ? err.message : err}\n`); + }); + } else { + trayInstance.onReady(() => {}); + trayInstance.onError(() => {}); + } + + return trayInstance; + } catch (err) { + process.stderr.write(`[9router] tray init error: ${err.message}\n`); + return null; + } +} + +/** + * Kill tray, wait Go binary fully exit (returns Promise). + * Critical for hide-to-tray: macOS must release NSStatusItem before bgProcess + * spawns a new tray, otherwise the new icon silently fails to register. + */ +function killTray() { + const instance = trayInstance; + const wasWin = isWinTray; + trayInstance = null; + if (!instance) return Promise.resolve(); + + if (wasWin) { + try { instance.kill(); } catch (e) {} + return Promise.resolve(); + } + + // Unix: get the Go tray child process handle. + let proc = null; + try { + proc = instance._process || (typeof instance.process === "function" ? instance.process() : null); + } catch (e) {} + + // Graceful shutdown: send {type:"exit"} via IPC so the Go binary can call + // systray.Quit() and release NSStatusItem. SIGKILL leaves a ghost icon on + // the macOS menubar until logout, causing duplicate icons after re-spawn. + const gracefulQuit = () => { try { instance.kill(true); } catch (e) {} }; + const closeIpc = () => { try { instance.kill(false); } catch (e) {} }; + + if (!proc || !proc.pid) { + gracefulQuit(); + closeIpc(); + return Promise.resolve(); + } + + return new Promise((resolve) => { + let done = false; + const finish = () => { if (done) return; done = true; closeIpc(); resolve(); }; + + proc.once("exit", finish); + gracefulQuit(); + + // Escalate: SIGTERM after 800ms, SIGKILL after 1600ms if still alive. + setTimeout(() => { try { process.kill(proc.pid, 0); proc.kill("SIGTERM"); } catch (e) {} }, 800); + setTimeout(() => { try { process.kill(proc.pid, 0); proc.kill("SIGKILL"); } catch (e) {} }, 1600); + + // Fallback poll in case "exit" never fires (detached child, pipe closed) + const deadline = Date.now() + 3000; + const poll = setInterval(() => { + try { process.kill(proc.pid, 0); } catch { clearInterval(poll); finish(); return; } + if (Date.now() > deadline) { clearInterval(poll); finish(); } + }, 50); + }); +} + +/** + * Open browser + */ +function openBrowser(url) { + const platform = process.platform; + let cmd; + + if (platform === "darwin") { + cmd = `open "${url}"`; + } else if (platform === "win32") { + cmd = `start "" "${url}"`; + } else { + cmd = `xdg-open "${url}"`; + } + + exec(cmd); +} + +module.exports = { + initTray, + killTray +}; diff --git a/cli/src/cli/tray/tray.ps1 b/cli/src/cli/tray/tray.ps1 new file mode 100644 index 0000000..bc55f39 --- /dev/null +++ b/cli/src/cli/tray/tray.ps1 @@ -0,0 +1,130 @@ +# 9Router tray icon for Windows using NotifyIcon +# IPC: stdin JSON commands, stdout JSON events +param([string]$IconPath, [string]$Tooltip) + +$ErrorActionPreference = "Stop" + +Add-Type @" +using System; +using System.Runtime.InteropServices; + +public static class WinDpiAwareness { + public static IntPtr PerMonitorAwareV2 { get { return new IntPtr(-4); } } + public static IntPtr PerMonitorAware { get { return new IntPtr(-3); } } + + [DllImport("user32.dll")] + public static extern bool SetProcessDpiAwarenessContext(IntPtr value); + + [DllImport("user32.dll")] + public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr value); + + [DllImport("shcore.dll")] + public static extern int SetProcessDpiAwareness(int value); + + [DllImport("user32.dll")] + public static extern bool SetProcessDPIAware(); +} +"@ + +function Enable-HighDpiAwareness { + $contexts = @( + [WinDpiAwareness]::PerMonitorAwareV2, + [WinDpiAwareness]::PerMonitorAware + ) + + foreach ($context in $contexts) { + try { + if ([WinDpiAwareness]::SetProcessDpiAwarenessContext($context)) { break } + } catch {} + } + + try { [WinDpiAwareness]::SetProcessDpiAwareness(2) | Out-Null } catch {} + try { [WinDpiAwareness]::SetProcessDPIAware() | Out-Null } catch {} + + foreach ($context in $contexts) { + try { + $previous = [WinDpiAwareness]::SetThreadDpiAwarenessContext($context) + if ($previous -ne [IntPtr]::Zero) { break } + } catch {} + } +} + +Enable-HighDpiAwareness + +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +[Console]::InputEncoding = [System.Text.Encoding]::UTF8 +$OutputEncoding = [System.Text.Encoding]::UTF8 + +[System.Windows.Forms.Application]::EnableVisualStyles() +[System.Windows.Forms.Application]::SetCompatibleTextRenderingDefault($false) + +$script:notifyIcon = New-Object System.Windows.Forms.NotifyIcon +$script:notifyIcon.Icon = New-Object System.Drawing.Icon($IconPath) +$script:notifyIcon.Text = $Tooltip +$script:notifyIcon.Visible = $true + +$script:menu = New-Object System.Windows.Forms.ContextMenuStrip +$script:notifyIcon.ContextMenuStrip = $script:menu +$script:items = @() + +function Write-Event($obj) { + $json = $obj | ConvertTo-Json -Compress + [Console]::Out.WriteLine($json) + [Console]::Out.Flush() +} + +function Add-MenuItem($index, $title, $enabled) { + $item = New-Object System.Windows.Forms.ToolStripMenuItem + $item.Text = $title + $item.Enabled = $enabled + $idx = $index + $item.Add_Click({ Write-Event @{ type = "click"; index = $idx } }.GetNewClosure()) + $script:menu.Items.Add($item) | Out-Null + $script:items += $item +} + +function Update-MenuItem($index, $title, $enabled) { + if ($index -lt $script:items.Count) { + $script:items[$index].Text = $title + $script:items[$index].Enabled = $enabled + } +} + +function Set-Tooltip($text) { + # NotifyIcon.Text max 63 chars + if ($text.Length -gt 63) { $text = $text.Substring(0, 63) } + $script:notifyIcon.Text = $text +} + +# Background reader thread polls stdin via timer on UI thread +$script:timer = New-Object System.Windows.Forms.Timer +$script:timer.Interval = 100 +$script:timer.Add_Tick({ + try { + while ([Console]::In.Peek() -ne -1) { + $line = [Console]::In.ReadLine() + if ([string]::IsNullOrWhiteSpace($line)) { continue } + $cmd = $line | ConvertFrom-Json + switch ($cmd.action) { + "add-item" { Add-MenuItem $cmd.index $cmd.title $cmd.enabled } + "update-item" { Update-MenuItem $cmd.index $cmd.title $cmd.enabled } + "set-tooltip" { Set-Tooltip $cmd.text } + "ready" { Write-Event @{ type = "ready" } } + "kill" { + $script:notifyIcon.Visible = $false + $script:notifyIcon.Dispose() + [System.Windows.Forms.Application]::Exit() + } + } + } + } catch { + Write-Event @{ type = "error"; message = $_.Exception.Message } + } +}) +$script:timer.Start() + +Write-Event @{ type = "started" } +[System.Windows.Forms.Application]::Run() diff --git a/cli/src/cli/tray/trayWin.js b/cli/src/cli/tray/trayWin.js new file mode 100644 index 0000000..8c11280 --- /dev/null +++ b/cli/src/cli/tray/trayWin.js @@ -0,0 +1,89 @@ +const { spawn } = require("child_process"); +const path = require("path"); +const readline = require("readline"); + +// PowerShell-based tray for Windows (AV-safe, zero binary deps) + +let psProcess = null; +let clickHandler = null; + +/** + * Send JSON command to PowerShell tray process via stdin + */ +function sendCommand(cmd) { + if (psProcess && psProcess.stdin.writable) { + psProcess.stdin.write(`${JSON.stringify(cmd)}\n`, "utf8"); + } +} + +/** + * Initialize Windows tray using PowerShell NotifyIcon + * @param {Object} options - { iconPath, tooltip, items, onClick } + * items: [{ title, enabled }] + * @returns {Object|null} controller with sendAction/kill + */ +function initWinTray(options) { + const { iconPath, tooltip, items, onClick } = options; + clickHandler = onClick; + + const scriptPath = path.join(__dirname, "tray.ps1"); + + try { + psProcess = spawn( + "powershell.exe", + [ + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-WindowStyle", "Hidden", + "-InputFormat", "Text", + "-OutputFormat", "Text", + "-File", scriptPath, + "-IconPath", iconPath, + "-Tooltip", tooltip + ], + { windowsHide: true, stdio: ["pipe", "pipe", "pipe"] } + ); + } catch (err) { + return null; + } + + const rl = readline.createInterface({ input: psProcess.stdout }); + rl.on("line", (line) => { + try { + const evt = JSON.parse(line); + if (evt.type === "click" && clickHandler) { + clickHandler(evt.index); + } + } catch (e) {} + }); + + psProcess.on("error", () => {}); + psProcess.stderr.on("data", () => {}); + + // Send initial menu items + items.forEach((item, index) => { + sendCommand({ action: "add-item", index, title: item.title, enabled: item.enabled }); + }); + + return { + updateItem(index, title, enabled) { + sendCommand({ action: "update-item", index, title, enabled }); + }, + setTooltip(text) { + sendCommand({ action: "set-tooltip", text }); + }, + kill() { + try { + sendCommand({ action: "kill" }); + } catch (e) {} + setTimeout(() => { + if (psProcess && !psProcess.killed) { + try { psProcess.kill(); } catch (e) {} + } + psProcess = null; + }, 300); + } + }; +} + +module.exports = { initWinTray }; diff --git a/cli/src/cli/utils/clipboard.js b/cli/src/cli/utils/clipboard.js new file mode 100644 index 0000000..c9cc5b5 --- /dev/null +++ b/cli/src/cli/utils/clipboard.js @@ -0,0 +1,30 @@ +const { execSync } = require("child_process"); + +/** + * Copy text to clipboard based on OS + * @param {string} text - Text to copy + * @returns {boolean} Success status + */ +function copyToClipboard(text) { + try { + const platform = process.platform; + + if (platform === "darwin") { + execSync("pbcopy", { input: text }); + } else if (platform === "win32") { + execSync("clip", { input: text }); + } else { + // Linux - try xclip first, then xsel + try { + execSync("xclip -selection clipboard", { input: text }); + } catch { + execSync("xsel --clipboard --input", { input: text }); + } + } + return true; + } catch (error) { + return false; + } +} + +module.exports = { copyToClipboard }; diff --git a/cli/src/cli/utils/display.js b/cli/src/cli/utils/display.js new file mode 100644 index 0000000..a68d830 --- /dev/null +++ b/cli/src/cli/utils/display.js @@ -0,0 +1,156 @@ +const { formatNumber } = require("./format"); + +// ANSI color codes +const COLORS = { + reset: "\x1b[0m", + success: "\x1b[32m", + error: "\x1b[31m", + warning: "\x1b[33m", + info: "\x1b[36m", + dim: "\x1b[2m", + bold: "\x1b[1m", + bright: "\x1b[1m", + cyan: "\x1b[36m" +}; + +// Box drawing characters +const BOX_CHARS = { + topLeft: "┌", + topRight: "┐", + bottomLeft: "└", + bottomRight: "┘", + horizontal: "─", + vertical: "│" +}; + +/** + * Draw a box with border around content + * @param {string} title - Box title + * @param {string} content - Content to display inside box + * @param {number} [width=60] - Box width + */ +function showBox(title, content, width = 60) { + const innerWidth = width - 4; + const lines = content.split("\n"); + + // Top border with title + const topBorder = BOX_CHARS.topLeft + BOX_CHARS.horizontal.repeat(2) + + ` ${title} ` + + BOX_CHARS.horizontal.repeat(Math.max(0, innerWidth - title.length - 3)) + + BOX_CHARS.topRight; + + console.log(topBorder); + + // Content lines + lines.forEach(line => { + const paddedLine = line.padEnd(innerWidth); + console.log(`${BOX_CHARS.vertical} ${paddedLine} ${BOX_CHARS.vertical}`); + }); + + // Bottom border + const bottomBorder = BOX_CHARS.bottomLeft + + BOX_CHARS.horizontal.repeat(innerWidth + 2) + + BOX_CHARS.bottomRight; + + console.log(bottomBorder); +} + +/** + * Display a menu with numbered items + * @param {string} title - Menu title + * @param {string[]} items - Array of menu items + * @param {string} [footer] - Optional footer text + */ +function showMenu(title, items, footer) { + console.log(`\n${COLORS.bold}${title}${COLORS.reset}`); + console.log(COLORS.dim + "─".repeat(title.length) + COLORS.reset); + + items.forEach((item, index) => { + console.log(` ${COLORS.info}${index + 1}.${COLORS.reset} ${item}`); + }); + + if (footer) { + console.log(`\n${COLORS.dim}${footer}${COLORS.reset}`); + } + console.log(); +} + +/** + * Display data in table format + * @param {string[]} headers - Array of column headers + * @param {Array>} rows - Array of row data + */ +function showTable(headers, rows) { + if (!headers.length || !rows.length) { + return; + } + + // Calculate column widths + const colWidths = headers.map((header, i) => { + const maxDataWidth = Math.max(...rows.map(row => String(row[i] || "").length)); + return Math.max(header.length, maxDataWidth); + }); + + // Print header + const headerRow = headers.map((h, i) => h.padEnd(colWidths[i])).join(" │ "); + console.log(COLORS.bold + headerRow + COLORS.reset); + + // Print separator + const separator = colWidths.map(w => "─".repeat(w)).join("─┼─"); + console.log(COLORS.dim + separator + COLORS.reset); + + // Print rows + rows.forEach(row => { + const rowStr = row.map((cell, i) => String(cell || "").padEnd(colWidths[i])).join(" │ "); + console.log(rowStr); + }); +} + +/** + * Show colored status message + * @param {string} message - Message to display + * @param {string} [type="info"] - Status type: success, error, warning, info + */ +function showStatus(message, type = "info") { + const symbols = { + success: "✓", + error: "✗", + warning: "⚠", + info: "ℹ" + }; + + const color = COLORS[type] || COLORS.info; + const symbol = symbols[type] || symbols.info; + + console.log(`${color}${symbol} ${message}${COLORS.reset}`); +} + +/** + * Clear the terminal screen + */ +function clearScreen() { + console.clear(); +} + +/** + * Show menu header with title and subtitle + * @param {string} title - Main title + * @param {string} subtitle - Optional subtitle + */ +function showHeader(title, subtitle) { + console.log(`\n${"=".repeat(60)}`); + console.log(` ${COLORS.bright}${COLORS.cyan}${title}${COLORS.reset}`); + if (subtitle) { + console.log(` ${COLORS.dim}${subtitle}${COLORS.reset}`); + } + console.log(`${"=".repeat(60)}\n`); +} + +module.exports = { + showBox, + showMenu, + showTable, + showStatus, + clearScreen, + showHeader +}; diff --git a/cli/src/cli/utils/endpoint.js b/cli/src/cli/utils/endpoint.js new file mode 100644 index 0000000..20226e6 --- /dev/null +++ b/cli/src/cli/utils/endpoint.js @@ -0,0 +1,32 @@ +const api = require("../api/client"); + +const COLORS = { + reset: "\x1b[0m", + green: "\x1b[32m" +}; + +/** + * Get endpoint URL based on tunnel status + * @param {number} port - Local server port + * @returns {Promise<{endpoint: string, tunnelEnabled: boolean}>} + */ +async function getEndpoint(port) { + const result = await api.getTunnelStatus(); + const tunnelEnabled = result.success && result.data?.enabled === true; + const publicUrl = result.success ? result.data?.publicUrl : ""; + + const endpoint = tunnelEnabled && publicUrl ? `${publicUrl}/v1` : `http://localhost:${port}/v1`; + return { endpoint, tunnelEnabled }; +} + +/** + * Get endpoint with color formatting + * @param {number} port - Local server port + * @returns {Promise} Colored endpoint string + */ +async function getEndpointColored(port) { + const { endpoint, tunnelEnabled } = await getEndpoint(port); + return tunnelEnabled ? `${COLORS.green}${endpoint}${COLORS.reset}` : endpoint; +} + +module.exports = { getEndpoint, getEndpointColored }; diff --git a/cli/src/cli/utils/format.js b/cli/src/cli/utils/format.js new file mode 100644 index 0000000..5bf2ea4 --- /dev/null +++ b/cli/src/cli/utils/format.js @@ -0,0 +1,125 @@ +/** + * Truncate text with ellipsis + * @param {string} text - Text to truncate + * @param {number} maxLength - Maximum length + * @returns {string} Truncated text + */ +function truncate(text, maxLength) { + if (!text || text.length <= maxLength) { + return text; + } + return text.substring(0, maxLength - 3) + "..."; +} + +/** + * Mask API key showing only first and last characters + * @param {string} key - API key to mask + * @returns {string} Masked key + */ +function maskKey(key) { + if (!key || key.length < 8) { + return "***"; + } + const firstChars = key.substring(0, 4); + const lastChars = key.substring(key.length - 4); + return `${firstChars}${"*".repeat(key.length - 8)}${lastChars}`; +} + +/** + * Format date to readable string + * @param {Date|string|number} date - Date to format + * @returns {string} Formatted date string + */ +function formatDate(date) { + const d = new Date(date); + if (isNaN(d.getTime())) { + return "Invalid Date"; + } + + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + const hours = String(d.getHours()).padStart(2, "0"); + const minutes = String(d.getMinutes()).padStart(2, "0"); + const seconds = String(d.getSeconds()).padStart(2, "0"); + + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +} + +/** + * Format number with commas + * @param {number} num - Number to format + * @returns {string} Formatted number + */ +function formatNumber(num) { + if (typeof num !== "number" || isNaN(num)) { + return "0"; + } + return num.toLocaleString("en-US"); +} + +/** + * Format bytes to human readable size + * @param {number} bytes - Bytes to format + * @returns {string} Formatted size string + */ +function formatBytes(bytes) { + if (typeof bytes !== "number" || isNaN(bytes) || bytes < 0) { + return "0 B"; + } + + const units = ["B", "KB", "MB", "GB", "TB"]; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(2)} ${units[unitIndex]}`; +} + +/** + * Get relative time string + * @param {Date|string|number} date - Date to compare + * @returns {string} Relative time string + */ +function getRelativeTime(date) { + const d = new Date(date); + if (isNaN(d.getTime())) { + return "Invalid Date"; + } + + const now = new Date(); + const diffMs = now - d; + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHour = Math.floor(diffMin / 60); + const diffDay = Math.floor(diffHour / 24); + const diffMonth = Math.floor(diffDay / 30); + const diffYear = Math.floor(diffDay / 365); + + if (diffSec < 60) { + return "just now"; + } else if (diffMin < 60) { + return `${diffMin} minute${diffMin > 1 ? "s" : ""} ago`; + } else if (diffHour < 24) { + return `${diffHour} hour${diffHour > 1 ? "s" : ""} ago`; + } else if (diffDay < 30) { + return `${diffDay} day${diffDay > 1 ? "s" : ""} ago`; + } else if (diffMonth < 12) { + return `${diffMonth} month${diffMonth > 1 ? "s" : ""} ago`; + } else { + return `${diffYear} year${diffYear > 1 ? "s" : ""} ago`; + } +} + +module.exports = { + truncate, + maskKey, + formatDate, + formatNumber, + formatBytes, + getRelativeTime +}; diff --git a/cli/src/cli/utils/input.js b/cli/src/cli/utils/input.js new file mode 100644 index 0000000..5761a2b --- /dev/null +++ b/cli/src/cli/utils/input.js @@ -0,0 +1,156 @@ +const readline = require("readline"); + +const COLORS = { + reset: "\x1b[0m", + bright: "\x1b[1m", + dim: "\x1b[2m", + underline: "\x1b[4m", + reverse: "\x1b[7m", + cyan: "\x1b[36m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + white: "\x1b[37m", + bgGreen: "\x1b[42m", + bgBlue: "\x1b[44m", + black: "\x1b[30m", + terracotta: "\x1b[38;2;217;119;87m", + bgTerracotta: "\x1b[48;2;217;119;87m" +}; + +// Prime stdin once globally. Toggling raw mode between menus adds latency on +// macOS, so we keep raw mode on for the whole TUI session. +let rawPrimed = false; +function primeRawOnce() { + if (rawPrimed || !process.stdin.isTTY) return; + try { + readline.emitKeypressEvents(process.stdin); + process.stdin.setRawMode(true); + process.stdin.setEncoding("utf8"); + process.stdin.resume(); + rawPrimed = true; + } catch {} +} + +function suspendRawFor(fn) { + // Temporarily drop raw mode so readline.question can buffer line input. + const wasPrimed = rawPrimed; + if (wasPrimed && process.stdin.isTTY) { + try { process.stdin.setRawMode(false); } catch {} + } + return fn().finally(() => { + if (wasPrimed && process.stdin.isTTY) { + try { process.stdin.setRawMode(true); } catch {} + process.stdin.resume(); + } + }); +} + +async function prompt(question) { + return suspendRawFor(() => new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question(question, (answer) => { + rl.close(); + resolve((answer || "").trim()); + }); + })); +} + +async function select(question, options) { + console.log(question); + options.forEach((opt, i) => console.log(` ${i + 1}. ${opt}`)); + while (true) { + const answer = await prompt("\nSelect option (number): "); + const num = parseInt(answer, 10); + if (!isNaN(num) && num >= 1 && num <= options.length) return num - 1; + console.log(`Invalid selection. Please enter a number between 1 and ${options.length}`); + } +} + +async function confirm(question) { + while (true) { + const answer = await prompt(`${question} (y/n): `); + const lower = answer.toLowerCase(); + if (lower === "y" || lower === "yes") return true; + if (lower === "n" || lower === "no") return false; + console.log("Please answer 'y' or 'n'"); + } +} + +async function pause(message = "Press Enter to continue...") { + return suspendRawFor(() => new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question(message, () => { rl.close(); resolve(); }); + })); +} + +/** + * Interactive arrow-key menu. Renders ★/☆ icons; selected line uses reverse+bright + * (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 = []) { + return new Promise((resolve) => { + let selectedIndex = defaultIndex; + let isActive = true; + + primeRawOnce(); + if (!process.stdin.isTTY) { resolve(-1); return; } + + const renderMenu = () => { + if (!isActive) return; + process.stdout.write("\x1b[2J\x1b[H"); + const width = Math.min(process.stdout.columns || 40, 40); + console.log(`\n${COLORS.terracotta}${"=".repeat(width)}${COLORS.reset}`); + console.log(` ${COLORS.bright}${COLORS.terracotta}${title}${COLORS.reset}`); + if (subtitle) console.log(` ${COLORS.dim}${subtitle}${COLORS.reset}`); + console.log(`${COLORS.terracotta}${"=".repeat(width)}${COLORS.reset}`); + if (breadcrumb.length > 0) console.log(` ${COLORS.dim}${breadcrumb.join(" > ")}${COLORS.reset}`); + console.log(); + if (headerContent) { console.log(headerContent); console.log(); } + + const isWin = process.platform === "win32"; + items.forEach((item, index) => { + const isSelected = index === selectedIndex; + const icon = isSelected ? (isWin ? ">" : "★") : (isWin ? " " : "☆"); + if (isSelected) { + console.log(` ${COLORS.reverse}${COLORS.bright}${icon} ${item.label}${COLORS.reset}`); + } else { + console.log(` ${icon} ${item.label}`); + } + }); + }; + + const cleanup = () => { + if (!isActive) return; + isActive = false; + process.stdin.removeListener("keypress", onKeypress); + }; + + const move = (delta) => { + selectedIndex = (selectedIndex + delta + items.length) % items.length; + renderMenu(); + }; + + const onKeypress = (_str, key) => { + if (!isActive || !key) return; + if (key.name === "up") return move(-1); + if (key.name === "down") return move(1); + if (key.name === "return") { cleanup(); resolve(selectedIndex); return; } + if (key.name === "escape") { cleanup(); resolve(-1); return; } + if (key.ctrl && key.name === "c") { cleanup(); process.exit(0); } + }; + + process.stdin.on("keypress", onKeypress); + renderMenu(); + }); +} + +module.exports = { + prompt, + select, + confirm, + pause, + selectMenu, + COLORS +}; diff --git a/cli/src/cli/utils/menuHelper.js b/cli/src/cli/utils/menuHelper.js new file mode 100644 index 0000000..41d2906 --- /dev/null +++ b/cli/src/cli/utils/menuHelper.js @@ -0,0 +1,156 @@ +const { selectMenu } = require("./input"); + +/** + * Show a menu with back button at top and handle selection + * @param {Object} config - Menu configuration + * @param {string} config.title - Menu title + * @param {string} config.headerContent - Optional header content + * @param {Array<{label: string, action: Function}>} config.items - Menu items with actions + * @param {string} config.backLabel - Back button label (default: "← Back") + * @param {number} config.defaultIndex - Default selected index (default: 0) + * @param {Function} config.refresh - Optional refresh function to call after each action + * @param {Array} config.breadcrumb - Optional breadcrumb path + * @returns {Promise} + */ +async function showMenuWithBack(config) { + const { + title, + headerContent = "", + items, + backLabel = "← Back", + defaultIndex = 0, + refresh = null, + breadcrumb = [] + } = config; + + while (true) { + // Call refresh if provided + let refreshedData = null; + if (refresh) { + refreshedData = await refresh(); + if (refreshedData === null) { + // Refresh failed, exit menu + return; + } + } + + // Build menu items with back at top + const menuItems = [ + { label: backLabel, icon: "☆" }, + ...items.map(item => ({ + label: typeof item.label === "function" ? item.label(refreshedData) : item.label, + icon: "☆" + })) + ]; + + // Resolve headerContent if it's a function + const resolvedHeader = typeof headerContent === "function" + ? await headerContent(refreshedData) + : headerContent; + + const selected = await selectMenu( + title, + menuItems, + defaultIndex, + "", + resolvedHeader, + breadcrumb + ); + + // Back or ESC + if (selected === -1 || selected === 0) { + return; + } + + // Execute action for selected item + const actionIndex = selected - 1; + const item = items[actionIndex]; + + if (item && item.action) { + const shouldContinue = await item.action(refreshedData); + // If action returns false, exit menu + if (shouldContinue === false) { + return; + } + } + } +} + +/** + * Show a list menu where items are fetched dynamically + * @param {Object} config - Menu configuration + * @param {string} config.title - Menu title + * @param {string} config.headerContent - Optional header content + * @param {Function} config.fetchItems - Async function to fetch items array + * @param {Function} config.formatItem - Function to format each item to {label, data} + * @param {Function} config.onSelect - Action when item is selected + * @param {Object} config.createAction - Optional create action {label, action} + * @param {string} config.backLabel - Back button label + * @param {Array} config.breadcrumb - Optional breadcrumb path + * @returns {Promise} + */ +async function showListMenu(config) { + const { + title, + headerContent = "", + fetchItems, + formatItem, + onSelect, + createAction = null, + backLabel = "← Back", + breadcrumb = [] + } = config; + + while (true) { + // Fetch items + const result = await fetchItems(); + if (!result) { + return; + } + + const items = result.items || []; + const metadata = result.metadata || {}; + + // Build menu items + const menuItems = [{ label: backLabel, icon: "☆" }]; + + if (createAction) { + menuItems.push({ label: createAction.label, icon: "☆" }); + } + + items.forEach(item => { + const formatted = formatItem(item); + menuItems.push({ label: formatted, icon: "☆" }); + }); + + const header = typeof headerContent === "function" + ? await headerContent(metadata) + : headerContent; + + const selected = await selectMenu(title, menuItems, 0, "", header, breadcrumb); + + // Back or ESC + if (selected === -1 || selected === 0) { + return; + } + + // Create action + if (createAction && selected === 1) { + await createAction.action(); + continue; + } + + // Select item + const offset = createAction ? 2 : 1; + const itemIndex = selected - offset; + + if (itemIndex >= 0 && itemIndex < items.length) { + await onSelect(items[itemIndex]); + } + } +} + +module.exports = { + showMenuWithBack, + showListMenu +}; diff --git a/cli/src/cli/utils/modelSelector.js b/cli/src/cli/utils/modelSelector.js new file mode 100644 index 0000000..438220d --- /dev/null +++ b/cli/src/cli/utils/modelSelector.js @@ -0,0 +1,136 @@ +const api = require("../api/client"); +const { prompt } = require("./input"); +const { clearScreen } = require("./display"); + +// Provider alias order: OAuth first, then API Key (matches ModelSelectModal) +const PROVIDER_ALIAS_ORDER = [ + "cc", "ag", "cx", "if", "qw", "gc", "gh", "kr", + "openrouter", "glm", "kimi", "minimax", "openai", "anthropic", "gemini" +]; + +// Alias to display name mapping +const PROVIDER_ALIAS_NAMES = { + cc: "Claude Code", + ag: "Antigravity", + cx: "OpenAI Codex", + if: "iFlow AI", + qw: "Qwen Code", + gc: "Gemini CLI", + gh: "GitHub Copilot", + kr: "Kiro AI", + openrouter: "OpenRouter", + glm: "GLM Coding", + kimi: "Kimi Coding", + minimax: "Minimax Coding", + openai: "OpenAI", + anthropic: "Anthropic", + gemini: "Gemini" +}; + +/** + * Get all available models grouped by provider + combos + * @returns {Promise<{combos: Array, groups: Object}>} + */ +async function getAvailableModelsGrouped() { + const result = await api.getAvailableModels(); + if (!result.success) return { combos: [], groups: {} }; + + const models = result.data?.data || []; + const combos = []; + const groups = {}; + + models.forEach(m => { + if (m.owned_by === "combo") { + combos.push(m.id); + } else { + const provider = m.owned_by; + if (!groups[provider]) { + groups[provider] = []; + } + groups[provider].push(m.id); + } + }); + + return { combos, groups }; +} + +/** + * Display model list and prompt for selection + * @param {string} title - Title to display + * @param {string} currentValue - Current selected value (optional) + * @param {Object} options - { excludeCombos?: boolean } + * @returns {Promise} Selected model ID or null if cancelled + */ +async function selectModelFromList(title, currentValue = "", options = {}) { + const { excludeCombos = false } = options; + const { combos: rawCombos, groups } = await getAvailableModelsGrouped(); + const combos = excludeCombos ? [] : rawCombos; + + const totalModels = combos.length + Object.values(groups).flat().length; + if (totalModels === 0) { + return null; + } + + // Build flat list for selection + const allModels = []; + + // Display + clearScreen(); + console.log(`\n🎯 ${title}`); + console.log("=".repeat(50)); + if (currentValue) { + console.log(`Current: ${currentValue}\n`); + } else { + console.log(); + } + + let idx = 1; + + // Combos first (skipped when excludeCombos is true) + if (combos.length > 0) { + console.log("[Combos]"); + combos.forEach(combo => { + console.log(` ${idx}. ${combo}`); + allModels.push(combo); + idx++; + }); + console.log(); + } + + // Provider groups in order (by alias) + const sortedProviders = Object.keys(groups).sort((a, b) => { + const idxA = PROVIDER_ALIAS_ORDER.indexOf(a); + const idxB = PROVIDER_ALIAS_ORDER.indexOf(b); + return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB); + }); + + sortedProviders.forEach(provider => { + const providerName = PROVIDER_ALIAS_NAMES[provider] || provider; + console.log(`[${providerName}]`); + groups[provider].forEach(model => { + console.log(` ${idx}. ${model}`); + allModels.push(model); + idx++; + }); + console.log(); + }); + + console.log(" 0. Cancel\n"); + + // Prompt for number input + const input = await prompt("Enter number: "); + const num = parseInt(input, 10); + + if (isNaN(num) || num === 0 || num < 0 || num > allModels.length) { + return null; + } + + return allModels[num - 1]; +} + +module.exports = { + selectModelFromList, + getAvailableModelsGrouped, + PROVIDER_ALIAS_ORDER, + PROVIDER_ALIAS_NAMES +}; diff --git a/custom-server.js b/custom-server.js new file mode 100644 index 0000000..6e39683 --- /dev/null +++ b/custom-server.js @@ -0,0 +1,32 @@ +const http = require("http"); + +const origCreate = http.createServer.bind(http); + +// Wrap Next standalone HTTP server: derive client IP from the TCP socket +// (unspoofable) and strip client-supplied forwarding headers so downstream +// rate-limiting keys on the real peer address instead of attacker-controlled XFF. +http.createServer = (...args) => { + const handler = args.find((a) => typeof a === "function"); + const rest = args.filter((a) => typeof a !== "function"); + if (!handler) return origCreate(...args); + const wrapped = (req, res) => { + const socketIp = req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : ""; + const xff = req.headers["x-forwarded-for"]; + const xRealIp = req.headers["x-real-ip"]; + const viaProxy = !!(xff || xRealIp); + const isLoopbackProxy = socketIp === "127.0.0.1" || socketIp === "::1" || socketIp === "::ffff:127.0.0.1"; + // Trust forwarding headers only when the TCP peer is a local reverse proxy. + // Direct/public sockets remain keyed by the unspoofable peer address. + const proxyIp = xRealIp || (xff ? String(xff).split(",")[0].trim() : ""); + const ip = isLoopbackProxy && proxyIp ? proxyIp : socketIp; + delete req.headers["x-9r-real-ip"]; + delete req.headers["x-forwarded-for"]; + delete req.headers["x-9r-via-proxy"]; + req.headers["x-9r-real-ip"] = ip; + if (viaProxy) req.headers["x-9r-via-proxy"] = "1"; + return handler(req, res); + }; + return origCreate(...rest, wrapped); +}; + +require("./server.js"); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8331b26 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + 9router: + image: decolua/9router:latest + container_name: 9router + restart: always + ports: + - "20128:20128" + volumes: + - 9router-data:/app/data + env_file: + - .env + environment: + DATA_DIR: /app/data + PORT: "20128" + HOSTNAME: "0.0.0.0" + NODE_ENV: production + HEADROOM_URL: http://headroom:8787 + depends_on: + - headroom + + headroom: + image: ghcr.io/chopratejas/headroom:latest + container_name: headroom + restart: always + ports: + - "8787:8787" + +volumes: + 9router-data: + name: 9router-data diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..548c319 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,557 @@ +# 9Router Architecture + +_Last updated: 2026-02-06_ + +## Executive Summary + +9Router is a local AI routing gateway and dashboard built on Next.js. +It provides a single OpenAI-compatible endpoint (`/v1/*`) and routes traffic across multiple upstream providers with translation, fallback, token refresh, and usage tracking. + +Core capabilities: + +- OpenAI-compatible API surface for CLI/tools +- Request/response translation across provider formats +- Model combo fallback (multi-model sequence) +- Account-level fallback (multi-account per provider) +- OAuth + API-key provider connection management +- Local persistence for providers, keys, aliases, combos, settings, pricing +- Usage/cost tracking and request logging +- Optional cloud sync for multi-device/state sync + +Primary runtime model: + +- Next.js app routes under `src/app/api/*` implement both dashboard APIs and compatibility APIs +- A shared SSE/routing core in `src/sse/*` + `open-sse/*` handles provider execution, translation, streaming, fallback, and usage + +## Scope and Boundaries + +### In Scope + +- Local gateway runtime +- Dashboard management APIs +- Provider authentication and token refresh +- Request translation and SSE streaming +- Local state + usage persistence +- Optional cloud sync orchestration + +### Out of Scope + +- Cloud service implementation behind `NEXT_PUBLIC_CLOUD_URL` +- Provider SLA/control plane outside local process +- External CLI binaries themselves (Claude CLI, Codex CLI, etc.) + +## High-Level System Context + +```mermaid +flowchart LR + subgraph Clients[Developer Clients] + C1[Claude Code] + C2[Codex CLI] + C3[OpenClaw / Droid / Cline / Continue / Roo] + C4[Custom OpenAI-compatible clients] + BROWSER[Browser Dashboard] + end + + subgraph Router[9Router Local Process] + API[V1 Compatibility API\n/v1/*] + DASH[Dashboard + Management API\n/api/*] + CORE[SSE + Translation Core\nopen-sse + src/sse] + DB[(db.json)] + UDB[(usage.json + log.txt)] + end + + subgraph Upstreams[Upstream Providers] + P1[OAuth Providers\nClaude/Codex/Gemini/Qwen/iFlow/GitHub/Kiro/Cursor/Antigravity] + P2[API Key Providers\nOpenAI/Anthropic/OpenRouter/GLM/Kimi/MiniMax] + P3[Compatible Nodes\nOpenAI-compatible / Anthropic-compatible] + end + + subgraph Cloud[Optional Cloud Sync] + CLOUD[Cloud Sync Endpoint\nNEXT_PUBLIC_CLOUD_URL] + end + + C1 --> API + C2 --> API + C3 --> API + C4 --> API + BROWSER --> DASH + + API --> CORE + DASH --> DB + CORE --> DB + CORE --> UDB + + CORE --> P1 + CORE --> P2 + CORE --> P3 + + DASH --> CLOUD +``` + +## Core Runtime Components + +## 1) API and Routing Layer (Next.js App Routes) + +Main directories: + +- `src/app/api/v1/*` and `src/app/api/v1beta/*` for compatibility APIs +- `src/app/api/*` for management/configuration APIs +- Next rewrites in `next.config.mjs` map `/v1/*` to `/api/v1/*` + +Important compatibility routes: + +- `src/app/api/v1/chat/completions/route.js` +- `src/app/api/v1/messages/route.js` +- `src/app/api/v1/responses/route.js` +- `src/app/api/v1/models/route.js` +- `src/app/api/v1/messages/count_tokens/route.js` +- `src/app/api/v1beta/models/route.js` +- `src/app/api/v1beta/models/[...path]/route.js` + +Management domains: + +- Auth/settings: `src/app/api/auth/*`, `src/app/api/settings/*` +- Providers/connections: `src/app/api/providers*` +- Provider nodes: `src/app/api/provider-nodes*` +- OAuth: `src/app/api/oauth/*` +- Keys/aliases/combos/pricing: `src/app/api/keys*`, `src/app/api/models/alias`, `src/app/api/combos*`, `src/app/api/pricing` +- Usage: `src/app/api/usage/*` +- Sync/cloud: `src/app/api/sync/*`, `src/app/api/cloud/*` +- CLI tooling helpers: `src/app/api/cli-tools/*` + +## 2) SSE + Translation Core + +Main flow modules: + +- Entry: `src/sse/handlers/chat.js` +- Core orchestration: `open-sse/handlers/chatCore.js` +- Provider execution adapters: `open-sse/executors/*` +- Format detection/provider config: `open-sse/services/provider.js` +- Model parse/resolve: `src/sse/services/model.js`, `open-sse/services/model.js` +- Account fallback logic: `open-sse/services/accountFallback.js` +- Translation registry: `open-sse/translator/index.js` +- Stream transformations: `open-sse/utils/stream.js`, `open-sse/utils/streamHandler.js` +- Usage extraction/normalization: `open-sse/utils/usageTracking.js` + +## 3) Persistence Layer + +Primary state DB: + +- `src/lib/localDb.js` +- file: `${DATA_DIR}/db.json` (or `~/.9router/db.json` when `DATA_DIR` is unset) +- entities: providerConnections, providerNodes, modelAliases, combos, apiKeys, settings, pricing + +Usage DB: + +- `src/lib/usageDb.js` +- files: `~/.9router/usage.json`, `~/.9router/log.txt` +- note: currently independent from `DATA_DIR` + +## 4) Auth + Security Surfaces + +- Dashboard cookie auth: `src/proxy.js`, `src/app/api/auth/login/route.js` +- API key generation/verification: `src/shared/utils/apiKey.js` +- Provider secrets persisted in `providerConnections` entries +- Optional proxy support for upstream calls via env proxy variables (`open-sse/utils/proxyFetch.js`) + +## 5) Cloud Sync + +- Scheduler init: `src/lib/initCloudSync.js`, `src/shared/services/initializeCloudSync.js` +- Periodic task: `src/shared/services/cloudSyncScheduler.js` +- Control route: `src/app/api/sync/cloud/route.js` + +## Request Lifecycle (`/v1/chat/completions`) + +```mermaid +sequenceDiagram + autonumber + participant Client as CLI/SDK Client + participant Route as /api/v1/chat/completions + participant Chat as src/sse/handlers/chat + participant Core as open-sse/handlers/chatCore + participant Model as Model Resolver + participant Auth as Credential Selector + participant Exec as Provider Executor + participant Prov as Upstream Provider + participant Stream as Stream Translator + participant Usage as usageDb + + Client->>Route: POST /v1/chat/completions + Route->>Chat: handleChat(request) + Chat->>Model: parse/resolve model or combo + + alt Combo model + Chat->>Chat: iterate combo models (handleComboChat) + end + + Chat->>Auth: getProviderCredentials(provider) + Auth-->>Chat: active account + tokens/api key + + Chat->>Core: handleChatCore(body, modelInfo, credentials) + Core->>Core: detect source format + Core->>Core: translate request to target format + Core->>Exec: execute(provider, transformedBody) + Exec->>Prov: upstream API call + Prov-->>Exec: SSE/JSON response + Exec-->>Core: response + metadata + + alt 401/403 + Core->>Exec: refreshCredentials() + Exec-->>Core: updated tokens + Core->>Exec: retry request + end + + Core->>Stream: translate/normalize stream to client format + Stream-->>Client: SSE chunks / JSON response + + Stream->>Usage: extract usage + persist history/log +``` + +## Combo + Account Fallback Flow + +```mermaid +flowchart TD + A[Incoming model string] --> B{Is combo name?} + B -- Yes --> C[Load combo models sequence] + B -- No --> D[Single model path] + + C --> E[Try model N] + E --> F[Resolve provider/model] + D --> F + + F --> G[Select account credentials] + G --> H{Credentials available?} + H -- No --> I[Return provider unavailable] + H -- Yes --> J[Execute request] + + J --> K{Success?} + K -- Yes --> L[Return response] + K -- No --> M{Fallback-eligible error?} + + M -- No --> N[Return error] + M -- Yes --> O[Mark account unavailable cooldown] + O --> P{Another account for provider?} + P -- Yes --> G + P -- No --> Q{In combo with next model?} + Q -- Yes --> E + Q -- No --> R[Return all unavailable] +``` + +Fallback decisions are driven by `open-sse/services/accountFallback.js` using status codes and error-message heuristics. + +## OAuth Onboarding and Token Refresh Lifecycle + +```mermaid +sequenceDiagram + autonumber + participant UI as Dashboard UI + participant OAuth as /api/oauth/[provider]/[action] + participant ProvAuth as Provider Auth Server + participant DB as localDb + participant Test as /api/providers/[id]/test + participant Exec as Provider Executor + + UI->>OAuth: GET authorize or device-code + OAuth->>ProvAuth: create auth/device flow + ProvAuth-->>OAuth: auth URL or device code payload + OAuth-->>UI: flow data + + UI->>OAuth: POST exchange or poll + OAuth->>ProvAuth: token exchange/poll + ProvAuth-->>OAuth: access/refresh tokens + OAuth->>DB: createProviderConnection(oauth data) + OAuth-->>UI: success + connection id + + UI->>Test: POST /api/providers/[id]/test + Test->>Exec: validate credentials / optional refresh + Exec-->>Test: valid or refreshed token info + Test->>DB: update status/tokens/errors + Test-->>UI: validation result +``` + +Refresh during live traffic is executed inside `open-sse/handlers/chatCore.js` via executor `refreshCredentials()`. + +## Cloud Sync Lifecycle (Enable / Sync / Disable) + +```mermaid +sequenceDiagram + autonumber + participant UI as Endpoint Page UI + participant Sync as /api/sync/cloud + participant DB as localDb + participant Cloud as External Cloud Sync + participant Claude as ~/.claude/settings.json + + UI->>Sync: POST action=enable + Sync->>DB: set cloudEnabled=true + Sync->>DB: ensure API key exists + Sync->>Cloud: POST /sync/{machineId} (providers/aliases/combos/keys) + Cloud-->>Sync: sync result + Sync->>Cloud: GET /{machineId}/v1/verify + Sync-->>UI: enabled + verification status + + UI->>Sync: POST action=sync + Sync->>Cloud: POST /sync/{machineId} + Cloud-->>Sync: remote data + Sync->>DB: update newer local tokens/status + Sync-->>UI: synced + + UI->>Sync: POST action=disable + Sync->>DB: set cloudEnabled=false + Sync->>Cloud: DELETE /sync/{machineId} + Sync->>Claude: switch ANTHROPIC_BASE_URL back to local (if needed) + Sync-->>UI: disabled +``` + +Periodic sync is triggered by `CloudSyncScheduler` when cloud is enabled. + +## Data Model and Storage Map + +```mermaid +erDiagram + SETTINGS ||--o{ PROVIDER_CONNECTION : controls + PROVIDER_NODE ||--o{ PROVIDER_CONNECTION : backs_compatible_provider + PROVIDER_CONNECTION ||--o{ USAGE_ENTRY : emits_usage + + SETTINGS { + boolean cloudEnabled + number stickyRoundRobinLimit + boolean requireLogin + string password_hash + } + + PROVIDER_CONNECTION { + string id + string provider + string authType + string name + number priority + boolean isActive + string apiKey + string accessToken + string refreshToken + string expiresAt + string testStatus + string lastError + string rateLimitedUntil + json providerSpecificData + } + + PROVIDER_NODE { + string id + string type + string name + string prefix + string apiType + string baseUrl + } + + MODEL_ALIAS { + string alias + string targetModel + } + + COMBO { + string id + string name + string[] models + } + + API_KEY { + string id + string name + string key + string machineId + boolean isActive + } + + USAGE_ENTRY { + string provider + string model + number prompt_tokens + number completion_tokens + string connectionId + string timestamp + } +``` + +Physical storage files: + +- main state: `${DATA_DIR}/db.json` (or `~/.9router/db.json`) +- usage stats: `~/.9router/usage.json` +- request log lines: `~/.9router/log.txt` +- optional translator/request debug sessions: `/logs/...` + +## Deployment Topology + +```mermaid +flowchart LR + subgraph LocalHost[Developer Host] + CLI[CLI Tools] + Browser[Dashboard Browser] + end + + subgraph ContainerOrProcess[9Router Runtime] + Next[Next.js Server\nPORT=20128] + Core[SSE Core + Executors] + MainDB[(db.json)] + UsageDB[(usage.json/log.txt)] + end + + subgraph External[External Services] + Providers[AI Providers] + SyncCloud[Cloud Sync Service] + end + + CLI --> Next + Browser --> Next + Next --> Core + Next --> MainDB + Core --> MainDB + Core --> UsageDB + Core --> Providers + Next --> SyncCloud +``` + +## Module Mapping (Decision-Critical) + +### Route and API Modules + +- `src/app/api/v1/*`, `src/app/api/v1beta/*`: compatibility APIs +- `src/app/api/providers*`: provider CRUD, validation, testing +- `src/app/api/provider-nodes*`: custom compatible node management +- `src/app/api/oauth/*`: OAuth/device-code flows +- `src/app/api/keys*`: local API key lifecycle +- `src/app/api/models/alias`: alias management +- `src/app/api/combos*`: fallback combo management +- `src/app/api/pricing`: pricing overrides for cost calculation +- `src/app/api/usage/*`: usage and logs APIs +- `src/app/api/sync/*` + `src/app/api/cloud/*`: cloud sync and cloud-facing helpers +- `src/app/api/cli-tools/*`: local CLI config writers/checkers + +### Routing and Execution Core + +- `src/sse/handlers/chat.js`: request parse, combo handling, account selection loop +- `open-sse/handlers/chatCore.js`: translation, executor dispatch, retry/refresh handling, stream setup +- `open-sse/executors/*`: provider-specific network and format behavior + +### Translation Registry and Format Converters + +- `open-sse/translator/index.js`: translator registry and orchestration +- Request translators: `open-sse/translator/request/*` +- Response translators: `open-sse/translator/response/*` +- Format constants: `open-sse/translator/formats.js` + +### Persistence + +- `src/lib/localDb.js`: persistent config/state +- `src/lib/usageDb.js`: usage history and rolling request logs + +## Provider Executor Coverage + +Specialized executors: + +- `antigravity` +- `gemini-cli` +- `github` +- `kiro` +- `codex` +- `cursor` + +Default executor path: + +- all other providers (including compatible node providers) use `open-sse/executors/default.js` + +## Format Translation Coverage + +Detected source formats include: + +- `openai` +- `openai-responses` +- `claude` +- `gemini` + +Target formats include: + +- OpenAI chat/Responses +- Claude +- Gemini/Gemini-CLI/Antigravity envelope +- Kiro +- Cursor + +Translations are selected dynamically based on source payload shape and provider target format. + +## Failure Modes and Resilience + +## 1) Account/Provider Availability + +- provider account cooldown on transient/rate/auth errors +- account fallback before failing request +- combo model fallback when current model/provider path is exhausted + +## 2) Token Expiry + +- pre-check and refresh with retry for refreshable providers +- 401/403 retry after refresh attempt in core path + +## 3) Stream Safety + +- disconnect-aware stream controller +- translation stream with end-of-stream flush and `[DONE]` handling +- usage estimation fallback when provider usage metadata is missing + +## 4) Cloud Sync Degradation + +- sync errors are surfaced but local runtime continues +- scheduler has retry-capable logic, but periodic execution currently calls single-attempt sync by default + +## 5) Data Integrity + +- DB shape migration/repair for missing keys +- corrupt JSON reset safeguards for localDb and usageDb + +## Observability and Operational Signals + +Runtime visibility sources: + +- console logs from `src/sse/utils/logger.js` +- per-request usage aggregates in `usage.json` +- textual request status log in `log.txt` +- optional deep request/translation logs under `logs/` when `ENABLE_REQUEST_LOGS=true` +- dashboard usage endpoints (`/api/usage/*`) for UI consumption + +## Security-Sensitive Boundaries + +- JWT secret (`JWT_SECRET`) secures dashboard session cookie verification/signing +- Initial password fallback (`INITIAL_PASSWORD`, default `123456`) must be overridden in real deployments +- API key HMAC secret (`API_KEY_SECRET`) secures generated local API key format +- Provider secrets (API keys/tokens) are persisted in local DB and should be protected at filesystem level +- Cloud sync endpoints rely on API key auth + machine id semantics + +## Environment and Runtime Matrix + +Environment variables actively used by code: + +- App/auth: `JWT_SECRET`, `INITIAL_PASSWORD` +- Storage: `DATA_DIR` +- Security hashing: `API_KEY_SECRET`, `MACHINE_ID_SALT` +- Logging: `ENABLE_REQUEST_LOGS` +- Sync/cloud URLing: `NEXT_PUBLIC_BASE_URL`, `NEXT_PUBLIC_CLOUD_URL` +- Outbound proxy: `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` and lowercase variants +- Platform/runtime helpers (not app-specific config): `APPDATA`, `NODE_ENV`, `PORT`, `HOSTNAME` + +## Known Architectural Notes + +1. `usageDb` currently stores under `~/.9router` and does not follow `DATA_DIR`. +2. `/api/v1/route.js` returns a static model list and is not the main models source used by `/v1/models`. +3. Request logger writes full headers/body when enabled; treat log directory as sensitive. +4. Cloud behavior depends on correct `NEXT_PUBLIC_BASE_URL` and cloud endpoint reachability. + +## Operational Verification Checklist + +- Build from source: `cd /root/dev/9router && npm run build` +- Build Docker image: `cd /root/dev/9router && docker build -t 9router .` +- Start service and verify: +- `GET /api/settings` +- `GET /api/v1/models` +- CLI target base URL should be `http://:20128/v1` when `PORT=20128` diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..f443835 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,16 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; + +const eslintConfig = defineConfig([ + ...nextVitals, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/gitbook/.gitignore b/gitbook/.gitignore new file mode 100644 index 0000000..d972524 --- /dev/null +++ b/gitbook/.gitignore @@ -0,0 +1,44 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Testing +coverage/ + +# Next.js +.next/ +out/ +build/ +dist/ + +# Production +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Misc +*.tsbuildinfo + +# Wrangler +.wrangler/ +.dev.vars diff --git a/gitbook/app/[lang]/[...slug]/page.js b/gitbook/app/[lang]/[...slug]/page.js new file mode 100644 index 0000000..792856d --- /dev/null +++ b/gitbook/app/[lang]/[...slug]/page.js @@ -0,0 +1,36 @@ +import DocsLayout from "@/components/DocsLayout"; +import DocsContent from "@/components/DocsContent"; +import { extractHeadings } from "@/utils/markdown"; +import { loadContent, getAllSlugs } from "@/lib/content"; +import { LANG_CODES, isValidLang, DEFAULT_LANG } from "@/constants/languages"; +import { notFound } from "next/navigation"; + +export const dynamicParams = false; + +export async function generateStaticParams() { + // Build params for every (lang × slug) combination based on default language slugs. + const slugs = getAllSlugs(DEFAULT_LANG); + const params = []; + for (const lang of LANG_CODES) { + for (const slug of slugs) { + params.push({ lang, slug }); + } + } + return params; +} + +export default async function DocPage({ params }) { + const { lang, slug } = await params; + if (!isValidLang(lang)) notFound(); + + const content = loadContent(lang, slug); + if (!content) notFound(); + + const headings = extractHeadings(content); + + return ( + + + + ); +} diff --git a/gitbook/app/[lang]/page.js b/gitbook/app/[lang]/page.js new file mode 100644 index 0000000..4da007c --- /dev/null +++ b/gitbook/app/[lang]/page.js @@ -0,0 +1,26 @@ +import DocsLayout from "@/components/DocsLayout"; +import DocsContent from "@/components/DocsContent"; +import { extractHeadings } from "@/utils/markdown"; +import { loadContent } from "@/lib/content"; +import { LANG_CODES, isValidLang } from "@/constants/languages"; +import { notFound } from "next/navigation"; + +export const dynamicParams = false; + +export async function generateStaticParams() { + return LANG_CODES.map(lang => ({ lang })); +} + +export default async function LangHomePage({ params }) { + const { lang } = await params; + if (!isValidLang(lang)) notFound(); + + const content = loadContent(lang, "index") || "# 9Router Documentation\n\nContent coming soon..."; + const headings = extractHeadings(content); + + return ( + + + + ); +} diff --git a/gitbook/app/globals.css b/gitbook/app/globals.css new file mode 100644 index 0000000..8ac76c1 --- /dev/null +++ b/gitbook/app/globals.css @@ -0,0 +1,190 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap'); +@import "tailwindcss"; + +/* Base styles */ +* { + box-sizing: border-box; +} + +body { + font-family: 'Inter', system-ui, -apple-system, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + margin: 0; + padding: 0; +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #CBD5E1; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #E68A6E; +} + +/* Code highlighting */ +pre { + background: #F1F5F9 !important; + border-radius: 8px; + padding: 1rem; + overflow-x: auto; +} + +code { + font-family: 'Monaco', 'Menlo', 'Courier New', monospace; + font-size: 0.875rem; + line-height: 1.5; +} + +/* Inline code */ +:not(pre) > code { + background: #F1F5F9; + color: #E68A6E; + padding: 0.125rem 0.375rem; + border-radius: 4px; + font-size: 0.875em; +} + +/* Markdown content styles */ +.markdown-content h1 { + font-size: 2.5rem; + font-weight: 800; + color: #E68A6E; + /* margin-top: 2rem; */ + margin-bottom: 1rem; + line-height: 1.2; + display: flex; + align-items: center; +} + +.markdown-content h1 svg { + width: 2.5rem; + height: 2.5rem; + flex-shrink: 0; +} + +.markdown-content h2 { + font-size: 2rem; + font-weight: 700; + color: #000000; + margin-top: 2rem; + margin-bottom: 1rem; + line-height: 1.3; + border-bottom: 1px solid #E5E7EB; + padding-bottom: 0.5rem; +} + +.markdown-content h3 { + font-size: 1.5rem; + font-weight: 600; + color: #000000; + margin-top: 1.5rem; + margin-bottom: 0.75rem; + line-height: 1.4; +} + +.markdown-content p { + font-size: 1.125rem; + line-height: 1.75; + margin-bottom: 1rem; + color: #6B7280; +} + +.markdown-content ul, +.markdown-content ol { + margin-left: 1.5rem; + margin-bottom: 1rem; +} + +.markdown-content li { + font-size: 1.125rem; + line-height: 1.75; + margin-bottom: 0.5rem; + color: #6B7280; +} + +.markdown-content a { + color: #E68A6E; + text-decoration: underline; + transition: opacity 0.2s; +} + +.markdown-content a:hover { + opacity: 0.8; +} + +.markdown-content blockquote { + border-left: 4px solid #E68A6E; + padding-left: 1rem; + margin: 1rem 0; + font-style: italic; + color: #6B7280; +} + +.markdown-content strong { + font-weight: 600; + color: #000000; +} + +/* Smooth scroll */ +html { + scroll-behavior: smooth; + scroll-padding-top: 5rem; /* Offset for sticky header (64px + padding) */ +} + +/* Heading anchor offset for sticky header */ +.markdown-content h1, +.markdown-content h2, +.markdown-content h3 { + scroll-margin-top: 5rem; +} + +/* Mobile menu overlay */ +.mobile-menu-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 40; + animation: fadeIn 0.2s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.mobile-menu-drawer { + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: 280px; + background: white; + z-index: 50; + animation: slideInLeft 0.3s ease-out; + overflow-y: auto; +} + +@keyframes slideInLeft { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +} diff --git a/gitbook/app/layout.js b/gitbook/app/layout.js new file mode 100644 index 0000000..aa49925 --- /dev/null +++ b/gitbook/app/layout.js @@ -0,0 +1,21 @@ +import { DOCS_CONFIG } from "@/constants/docsConfig"; +import "./globals.css"; + +export const metadata = { + title: DOCS_CONFIG.title, + description: DOCS_CONFIG.description, +}; + +export default function RootLayout({ children }) { + return ( + + + + + + + {children} + + + ); +} diff --git a/gitbook/app/page.js b/gitbook/app/page.js new file mode 100644 index 0000000..71df826 --- /dev/null +++ b/gitbook/app/page.js @@ -0,0 +1,26 @@ +import { DEFAULT_LANG } from "@/constants/languages"; + +// Static-friendly redirect to default language (meta refresh + client script) +export const metadata = { + title: "Redirecting...", + other: { + "http-equiv:refresh": `0; url=/${DEFAULT_LANG}/` + } +}; + +export default function HomePage() { + const target = `/${DEFAULT_LANG}/`; + return ( + <> + + +`); + + // Call callback with params + onCallback(params); + } else { + res.writeHead(404); + res.end("Not found"); + } + }); + + // Listen on fixed port or find available port + const portToUse = fixedPort || 0; + server.listen(portToUse, "127.0.0.1", () => { + const { port } = server.address(); + resolve({ + server, + port, + close: () => server.close(), + }); + }); + + server.on("error", (err) => { + if (err.code === "EADDRINUSE" && fixedPort) { + reject(new Error(`Port ${fixedPort} is already in use. Please close other applications using this port.`)); + } else { + reject(err); + } + }); + }); +} + +/** + * Wait for callback with timeout + * @param {number} timeoutMs - Timeout in milliseconds + * @returns {Promise} - Callback params + */ +export function waitForCallback(timeoutMs = 300000) { + return new Promise((resolve, reject) => { + let resolved = false; + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + reject(new Error("Authentication timeout")); + } + }, timeoutMs); + + const onCallback = (params) => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + resolve(params); + } + }; + + // Return the callback function + resolve.__onCallback = onCallback; + }); +} + +// Singleton proxy server for Codex OAuth callback on fixed port +let codexProxyServer = null; +let codexProxyTimeout = null; + +const CODEX_PROXY_TIMEOUT_MS = 300000; // 5 minutes +const CODEX_PORT = CODEX_CONFIG.fixedPort; + +// Pending exchange sessions keyed by state — used by server-side exchange mode +const pendingExchanges = new Map(); + +/** + * Register a pending exchange session for server-side mode. + * Modal client calls this before opening popup. + */ +export function registerCodexSession({ state, codeVerifier, redirectUri }) { + if (!state || !codeVerifier || !redirectUri) return false; + pendingExchanges.set(state, { + codeVerifier, + redirectUri, + status: "pending", + createdAt: Date.now(), + }); + return true; +} + +/** + * Read session status (modal polls this). + */ +export function getCodexSessionStatus(state) { + return pendingExchanges.get(state) || null; +} + +/** + * Clear a session (called after modal consumes status). + */ +export function clearCodexSession(state) { + pendingExchanges.delete(state); +} + +function escapeHtml(str) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function renderCodexResultPage(success, message) { + const color = success ? "#22c55e" : "#ef4444"; + const icon = success ? "✓" : "✗"; + const title = success ? "Authentication Successful" : "Authentication Failed"; + const safeMessage = escapeHtml(message); + return ` +${title} + +
${icon}

${title}

${safeMessage}

Closing in 3s...

+ +
`; +} + +/** + * Start Codex proxy on fixed port 1455. + * Mode A (server-side): if any session was registered, proxy auto-exchanges + saves DB. + * Mode B (channel fallback): if no session, proxy 302 redirects to app port for legacy channel-based flow. + */ +export function startCodexProxy(appPort) { + return new Promise((resolve) => { + if (codexProxyServer) { + resolve({ success: true }); + return; + } + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, "http://localhost"); + + if (url.pathname !== "/callback" && url.pathname !== "/auth/callback") { + res.writeHead(404); + res.end("Not found"); + return; + } + + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const errorParam = url.searchParams.get("error"); + const session = state ? pendingExchanges.get(state) : null; + + // Mode A: server-side exchange (session registered) + if (session) { + try { + if (errorParam) { + throw new Error(url.searchParams.get("error_description") || errorParam); + } + if (!code) throw new Error("No authorization code received"); + + // Lazy import to avoid circular deps + const { exchangeTokens } = await import("../providers.js"); + const { createProviderConnection } = await import("@/models"); + + const tokenData = await exchangeTokens( + "codex", + code, + session.redirectUri, + session.codeVerifier, + state + ); + const connection = await createProviderConnection({ + provider: "codex", + authType: "oauth", + ...tokenData, + expiresAt: tokenData.expiresIn + ? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString() + : null, + testStatus: "active", + }); + + session.status = "done"; + session.connectionId = connection.id; + session.email = connection.email; + + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(true, "You can close this window.")); + } catch (err) { + session.status = "error"; + session.error = err.message; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, err.message)); + } finally { + stopCodexProxy(); + } + return; + } + + // Mode B: legacy channel fallback — 302 redirect to app /callback + const redirectUrl = `http://localhost:${appPort}/callback${url.search}`; + res.writeHead(302, { Location: redirectUrl }); + res.end(); + stopCodexProxy(); + }); + + server.listen(CODEX_PORT, "127.0.0.1", () => { + codexProxyServer = server; + codexProxyTimeout = setTimeout(() => stopCodexProxy(), CODEX_PROXY_TIMEOUT_MS); + resolve({ success: true }); + }); + + server.on("error", (err) => { + if (err.code === "EADDRINUSE") { + resolve({ success: false, reason: "port_busy" }); + } else { + resolve({ success: false, reason: err.message }); + } + }); + }); +} + +/** + * Stop the Codex proxy server and cleanup + */ +export function stopCodexProxy() { + if (codexProxyTimeout) { + clearTimeout(codexProxyTimeout); + codexProxyTimeout = null; + } + if (codexProxyServer) { + codexProxyServer.close(); + codexProxyServer = null; + } +} + +// ─────────────────────────────────────────────────────────────────────────── +// xAI fixed-port proxy on 127.0.0.1:56121 +// Same shape as the Codex proxy. Kept as a parallel implementation rather than +// generalizing the Codex one to keep the codex hot-path byte-equivalent. +// ─────────────────────────────────────────────────────────────────────────── + +let xaiProxyServer = null; +let xaiProxyTimeout = null; +const XAI_PROXY_TIMEOUT_MS = 300000; // 5 minutes +const XAI_PROXY_PORT = 56121; +const xaiPendingExchanges = new Map(); + +export function registerXaiSession({ state, codeVerifier, redirectUri }) { + if (!state || !codeVerifier || !redirectUri) return false; + xaiPendingExchanges.set(state, { + codeVerifier, + redirectUri, + status: "pending", + createdAt: Date.now(), + }); + return true; +} + +export function getXaiSessionStatus(state) { + return xaiPendingExchanges.get(state) || null; +} + +export function clearXaiSession(state) { + xaiPendingExchanges.delete(state); +} + +function renderXaiResultPage(success, message) { + return renderCodexResultPage(success, message); +} + +/** + * Start xAI proxy on fixed port 56121. + * Mode A (server-side): if any session was registered, proxy auto-exchanges + saves DB. + * Mode B (channel fallback): if no session, proxy 302 redirects to app port. + */ +export function startXaiProxy(appPort) { + return new Promise((resolve) => { + if (xaiProxyServer) { + resolve({ success: true }); + return; + } + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, "http://localhost"); + if (url.pathname !== "/callback" && url.pathname !== "/auth/callback") { + res.writeHead(404); + res.end("Not found"); + return; + } + + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const errorParam = url.searchParams.get("error"); + const session = state ? xaiPendingExchanges.get(state) : null; + + // Mode A: server-side exchange + if (session) { + try { + if (errorParam) { + throw new Error(url.searchParams.get("error_description") || errorParam); + } + if (!code) throw new Error("No authorization code received"); + + const { exchangeTokens } = await import("../providers.js"); + const { createProviderConnection } = await import("@/models"); + + const tokenData = await exchangeTokens( + "xai", + code, + session.redirectUri, + session.codeVerifier, + state + ); + const connection = await createProviderConnection({ + provider: "xai", + authType: "oauth", + ...tokenData, + expiresAt: tokenData.expiresIn + ? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString() + : null, + testStatus: "active", + }); + + session.status = "done"; + session.connectionId = connection.id; + session.email = connection.email; + + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderXaiResultPage(true, "You can close this window.")); + } catch (err) { + session.status = "error"; + session.error = err.message; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderXaiResultPage(false, err.message)); + } finally { + stopXaiProxy(); + } + return; + } + + // Mode B: legacy fallback redirect + const redirectUrl = `http://localhost:${appPort}/callback${url.search}`; + res.writeHead(302, { Location: redirectUrl }); + res.end(); + stopXaiProxy(); + }); + + server.listen(XAI_PROXY_PORT, "127.0.0.1", () => { + xaiProxyServer = server; + xaiProxyTimeout = setTimeout(() => stopXaiProxy(), XAI_PROXY_TIMEOUT_MS); + resolve({ success: true }); + }); + + server.on("error", (err) => { + if (err.code === "EADDRINUSE") { + resolve({ success: false, reason: "port_busy" }); + } else { + resolve({ success: false, reason: err.message }); + } + }); + }); +} + +export function stopXaiProxy() { + if (xaiProxyTimeout) { + clearTimeout(xaiProxyTimeout); + xaiProxyTimeout = null; + } + if (xaiProxyServer) { + xaiProxyServer.close(); + xaiProxyServer = null; + } +} + diff --git a/src/lib/oauth/utils/ui.js b/src/lib/oauth/utils/ui.js new file mode 100644 index 0000000..a81ff48 --- /dev/null +++ b/src/lib/oauth/utils/ui.js @@ -0,0 +1,48 @@ +import chalk from "chalk"; +import ora from "ora"; + +/** + * UI Helper Functions + */ + +export function success(message) { + console.log(chalk.green(`\n✓ ${message}\n`)); +} + +export function error(message) { + console.log(chalk.red(`\n✗ ${message}\n`)); +} + +export function info(message) { + console.log(chalk.blue(`\n${message}\n`)); +} + +export function warn(message) { + console.log(chalk.yellow(`\n⚠ ${message}\n`)); +} + +export function gray(message) { + console.log(chalk.gray(message)); +} + +export function spinner(text) { + return ora(text); +} + +export function printSection(title) { + console.log(chalk.blue(`\n${title}\n`)); +} + +export function printKeyValue(key, value, isSuccess = false) { + const color = isSuccess ? chalk.green : chalk.gray; + console.log(color(` ${key}: ${value}`)); +} + +export function printList(items, isSuccess = false) { + const symbol = isSuccess ? "✓" : "✗"; + const color = isSuccess ? chalk.green : chalk.gray; + items.forEach((item) => { + console.log(color(` ${symbol} ${item}`)); + }); +} + diff --git a/src/lib/providerNormalization.js b/src/lib/providerNormalization.js new file mode 100644 index 0000000..8eb2a7e --- /dev/null +++ b/src/lib/providerNormalization.js @@ -0,0 +1,45 @@ +import { AI_PROVIDERS } from "../shared/constants/providers.js"; + +/** + * Detect xAI Grok models by id pattern (grok-*, Grok_*, etc). + * @param {string} modelId + * @returns {boolean} + */ +export function isXaiModel(modelId) { + return typeof modelId === "string" && /^grok[-_]/i.test(modelId.trim()); +} + +export function normalizeProviderId(provider) { + if (typeof provider !== "string") return provider; + + const trimmed = provider.trim(); + if (AI_PROVIDERS[trimmed]) return trimmed; + + const slug = trimmed.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + if (AI_PROVIDERS[slug]) return slug; + + const providerByName = Object.values(AI_PROVIDERS).find( + (entry) => entry.name?.toLowerCase() === trimmed.toLowerCase() + ); + return providerByName?.id || trimmed; +} + +export function normalizeProviderSpecificData(provider, body = {}, providerSpecificData = null) { + const next = providerSpecificData && typeof providerSpecificData === "object" + ? { ...providerSpecificData } + : {}; + + if (provider === "ollama-local") { + const baseUrl = ( + next.baseUrl || + body.baseUrl || + body.baseURL || + body.ollamaHostUrl || + "" + ).trim(); + + if (baseUrl) next.baseUrl = baseUrl; + } + + return Object.keys(next).length > 0 ? next : null; +} diff --git a/src/lib/pxpipe/events.js b/src/lib/pxpipe/events.js new file mode 100644 index 0000000..b1bb880 --- /dev/null +++ b/src/lib/pxpipe/events.js @@ -0,0 +1,125 @@ +import fs from "fs"; +import path from "path"; +import { PXPIPE_DIR } from "./install.js"; + +const EVENTS_FILE = path.join(PXPIPE_DIR, "events.jsonl"); +const ROTATED_FILE = path.join(PXPIPE_DIR, "events.jsonl.1"); +const MAX_FILE_BYTES = 5 * 1024 * 1024; +const DAY_MS = 24 * 60 * 60 * 1000; + +function ensureDir() { + if (!fs.existsSync(PXPIPE_DIR)) fs.mkdirSync(PXPIPE_DIR, { recursive: true }); +} + +// Fire-and-forget: stats must never break the request path. +export function appendPxpipeEvent(event) { + try { + ensureDir(); + try { + const stat = fs.statSync(EVENTS_FILE); + if (stat.size > MAX_FILE_BYTES) fs.renameSync(EVENTS_FILE, ROTATED_FILE); + } catch { /* no file yet */ } + fs.appendFile(EVENTS_FILE, JSON.stringify({ ts: Date.now(), ...event }) + "\n", () => {}); + } catch { /* ignore */ } +} + +export function readPxpipeEvents({ sinceMs = null, limit = null } = {}) { + const events = []; + for (const file of [ROTATED_FILE, EVENTS_FILE]) { + try { + if (!fs.existsSync(file)) continue; + for (const line of fs.readFileSync(file, "utf8").split("\n")) { + if (!line) continue; + try { + const ev = JSON.parse(line); + if (sinceMs && ev.ts < sinceMs) continue; + events.push(ev); + } catch { /* skip corrupt line */ } + } + } catch { /* ignore */ } + } + events.sort((a, b) => a.ts - b.ts); + return limit ? events.slice(-limit) : events; +} + +function emptyTotals() { + return { + requests: 0, compressed: 0, bypassed: 0, errors: 0, + tokensBeforeEst: 0, tokensAfterEst: 0, tokensSavedEst: 0, savedPct: 0, + imagesGenerated: 0, compressionTimeMs: 0, avgCompressionMs: 0, + }; +} + +function accumulate(totals, ev) { + totals.requests++; + if (ev.applied) { + totals.compressed++; + totals.tokensBeforeEst += ev.tokensBeforeEst || 0; + totals.tokensAfterEst += ev.tokensAfterEst || 0; + totals.tokensSavedEst += ev.tokensSavedEst || 0; + totals.imagesGenerated += ev.imageCount || 0; + totals.compressionTimeMs += ev.durationMs || 0; + } else if (ev.reason === "transform_error" || ev.reason === "timeout") { + totals.errors++; + } else { + totals.bypassed++; + } +} + +function finalize(totals) { + totals.savedPct = totals.tokensBeforeEst > 0 + ? +((totals.tokensSavedEst / totals.tokensBeforeEst) * 100).toFixed(2) + : 0; + totals.avgCompressionMs = totals.compressed > 0 + ? Math.round(totals.compressionTimeMs / totals.compressed) + : 0; + return totals; +} + +// Aggregated stats for the dashboard: all-time + windowed totals, a daily +// tokens-saved timeline (last `timelineDays`), and the most recent events. +export function getPxpipeStats({ timelineDays = 30, recentLimit = 100 } = {}) { + const events = readPxpipeEvents(); + const now = Date.now(); + const startOfToday = new Date(new Date(now).setHours(0, 0, 0, 0)).getTime(); + + const windows = { + all: emptyTotals(), + today: emptyTotals(), + yesterday: emptyTotals(), + last7d: emptyTotals(), + last30d: emptyTotals(), + }; + + const timeline = new Map(); + for (let i = timelineDays - 1; i >= 0; i--) { + const day = new Date(startOfToday - i * DAY_MS); + timeline.set(day.toISOString().slice(0, 10), { date: day.toISOString().slice(0, 10), tokensSavedEst: 0, compressed: 0, requests: 0 }); + } + + for (const ev of events) { + accumulate(windows.all, ev); + if (ev.ts >= startOfToday) accumulate(windows.today, ev); + else if (ev.ts >= startOfToday - DAY_MS) accumulate(windows.yesterday, ev); + if (ev.ts >= now - 7 * DAY_MS) accumulate(windows.last7d, ev); + if (ev.ts >= now - 30 * DAY_MS) accumulate(windows.last30d, ev); + + const key = new Date(ev.ts).toISOString().slice(0, 10); + const bucket = timeline.get(key); + if (bucket) { + bucket.requests++; + if (ev.applied) { + bucket.compressed++; + bucket.tokensSavedEst += ev.tokensSavedEst || 0; + } + } + } + + for (const w of Object.values(windows)) finalize(w); + + return { + windows, + timeline: [...timeline.values()], + recent: events.slice(-recentLimit).reverse(), + }; +} diff --git a/src/lib/pxpipe/install.js b/src/lib/pxpipe/install.js new file mode 100644 index 0000000..cdf5a8c --- /dev/null +++ b/src/lib/pxpipe/install.js @@ -0,0 +1,123 @@ +import fs from "fs"; +import path from "path"; +import { spawn, execSync } from "child_process"; +import { DATA_DIR } from "@/lib/dataDir.js"; + +export const PXPIPE_DIR = path.join(DATA_DIR, "pxpipe"); +export const PXPIPE_PACKAGE = "pxpipe-proxy"; +const INSTALL_LOG = path.join(PXPIPE_DIR, "install.log"); +const INSTALL_TIMEOUT_MS = 5 * 60 * 1000; + +const IS_WIN = process.platform === "win32"; +const NPM_CMD = IS_WIN ? "npm.cmd" : "npm"; + +// Same PATH extension trick as headroom/detect.js: packaged/launchd environments +// often miss the Node bin dirs. +const EXTRA_BINS = IS_WIN + ? [`${process.env.ProgramFiles || ""}\\nodejs`, `${process.env.APPDATA || ""}\\npm`] + : ["/usr/local/bin", "/opt/homebrew/bin", `${process.env.HOME || ""}/.local/bin`, "/usr/bin", "/bin"]; +const EXTENDED_PATH = [...EXTRA_BINS, process.env.PATH || ""].filter(Boolean).join(path.delimiter); + +let installInFlight = null; + +function ensureDir() { + if (!fs.existsSync(PXPIPE_DIR)) fs.mkdirSync(PXPIPE_DIR, { recursive: true }); +} + +export function packageRoot() { + return path.join(PXPIPE_DIR, "node_modules", PXPIPE_PACKAGE); +} + +export function libraryEntry() { + return path.join(packageRoot(), "dist", "core", "library.js"); +} + +export function findNpm() { + try { + const out = execSync(`${IS_WIN ? "where" : "which"} npm`, { + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + env: { ...process.env, PATH: EXTENDED_PATH }, + }).toString().trim(); + return out ? out.split(/\r?\n/)[0].trim() : null; + } catch { + return null; + } +} + +// { installed, version, path } — installed means the library entry exists on disk. +export function getInstallInfo() { + try { + const pkgJson = path.join(packageRoot(), "package.json"); + if (!fs.existsSync(pkgJson) || !fs.existsSync(libraryEntry())) { + return { installed: false, version: null, path: null }; + } + const pkg = JSON.parse(fs.readFileSync(pkgJson, "utf8")); + return { installed: true, version: pkg.version || null, path: packageRoot() }; + } catch { + return { installed: false, version: null, path: null }; + } +} + +export function isInstalling() { + return installInFlight !== null; +} + +// Install (or repair by reinstalling) pxpipe-proxy into DATA_DIR/pxpipe. +// Serialized: concurrent calls await the same run. +export function installPxpipe() { + if (installInFlight) return installInFlight; + installInFlight = runInstall().finally(() => { installInFlight = null; }); + return installInFlight; +} + +async function runInstall() { + const npm = findNpm(); + if (!npm) { + const err = new Error("npm not found on PATH — Node.js/npm is required to install PXPIPE"); + err.code = "NPM_NOT_FOUND"; + throw err; + } + + ensureDir(); + const pkgJson = path.join(PXPIPE_DIR, "package.json"); + if (!fs.existsSync(pkgJson)) { + fs.writeFileSync(pkgJson, JSON.stringify({ name: "9router-pxpipe-host", private: true }, null, 2)); + } + + const outFd = fs.openSync(INSTALL_LOG, "a"); + fs.writeSync(outFd, `\n[${new Date().toISOString()}] npm install ${PXPIPE_PACKAGE}@latest\n`); + + await new Promise((resolve, reject) => { + const child = spawn(npm, ["install", `${PXPIPE_PACKAGE}@latest`, "--no-audit", "--no-fund", "--omit=dev"], { + cwd: PXPIPE_DIR, + stdio: ["ignore", outFd, outFd], + windowsHide: true, + env: { ...process.env, PATH: EXTENDED_PATH }, + }); + const timer = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error("npm install timed out after 5 minutes — see install.log")); + }, INSTALL_TIMEOUT_MS); + child.once("error", (e) => { clearTimeout(timer); reject(e); }); + child.once("exit", (code) => { + clearTimeout(timer); + if (code === 0) resolve(); + else reject(new Error(`npm install exited with code ${code} — see install.log`)); + }); + }).finally(() => fs.closeSync(outFd)); + + const info = getInstallInfo(); + if (!info.installed) throw new Error("install finished but package is missing — see install.log"); + return info; +} + +export function getInstallLogTail(maxLines = 200) { + try { + if (!fs.existsSync(INSTALL_LOG)) return ""; + const lines = fs.readFileSync(INSTALL_LOG, "utf8").split(/\r?\n/).filter(Boolean); + return lines.slice(-maxLines).join("\n"); + } catch { + return ""; + } +} diff --git a/src/lib/pxpipe/loader.js b/src/lib/pxpipe/loader.js new file mode 100644 index 0000000..fbd418f --- /dev/null +++ b/src/lib/pxpipe/loader.js @@ -0,0 +1,70 @@ +import { pathToFileURL } from "url"; +import { getInstallInfo, libraryEntry } from "./install.js"; + +// Module cache: pxpipe is loaded once per process ("started") and dropped on +// "stop". In library mode start/stop govern the in-process module, not a daemon. +let cached = null; // { module, version, loadedAt } +let loadPromise = null; + +export function getLoadedInfo() { + return cached ? { loaded: true, version: cached.version, loadedAt: cached.loadedAt } : { loaded: false }; +} + +export async function loadPxpipe() { + if (cached) return cached; + if (loadPromise) return loadPromise; + loadPromise = doLoad().finally(() => { loadPromise = null; }); + return loadPromise; +} + +async function doLoad() { + const info = getInstallInfo(); + if (!info.installed) { + const err = new Error("PXPIPE is not installed"); + err.code = "NOT_INSTALLED"; + throw err; + } + // Cache-bust per version so Repair/upgrade takes effect without a server restart. + const url = `${pathToFileURL(libraryEntry()).href}?v=${encodeURIComponent(info.version || "0")}`; + const mod = await import(/* webpackIgnore: true */ url); + if (typeof mod.transformAnthropicMessages !== "function") { + throw new Error("installed pxpipe package does not export transformAnthropicMessages"); + } + cached = { module: mod, version: info.version, loadedAt: Date.now() }; + return cached; +} + +export function unloadPxpipe() { + const wasLoaded = !!cached; + cached = null; + return wasLoaded; +} + +// Transform function for the request pipeline; null when unavailable (fail-open). +// autoLoad controls whether a cold cache triggers a load (first request warms it). +export async function getTransform({ autoLoad = true } = {}) { + try { + if (!cached && !autoLoad) return null; + const { module: mod } = await loadPxpipe(); + return mod.transformAnthropicMessages; + } catch { + return null; + } +} + +// Health self-test: run a tiny synthetic Claude request through the transformer. +// A healthy module parses it and answers with a machine-readable reason. +export async function selfTest() { + const startedAt = Date.now(); + const { module: mod } = await loadPxpipe(); + const body = new TextEncoder().encode(JSON.stringify({ + model: "claude-fable-5", + max_tokens: 16, + messages: [{ role: "user", content: "ping" }], + })); + const result = await mod.transformAnthropicMessages({ body, model: "claude-fable-5" }); + if (!result || typeof result.applied !== "boolean" || !(result.body instanceof Uint8Array)) { + throw new Error("transform returned an unexpected shape"); + } + return { ok: true, reason: result.reason, durationMs: Date.now() - startedAt }; +} diff --git a/src/lib/pxpipe/service.js b/src/lib/pxpipe/service.js new file mode 100644 index 0000000..d117c82 --- /dev/null +++ b/src/lib/pxpipe/service.js @@ -0,0 +1,49 @@ +import { getInstallInfo, isInstalling, findNpm } from "./install.js"; +import { getLoadedInfo, loadPxpipe, selfTest } from "./loader.js"; + +// Aggregate status for the Token Saver card and /api/pxpipe/status. +// "running" in library mode = module loaded into this process. +export function getPxpipeStatus() { + const install = getInstallInfo(); + const loaded = getLoadedInfo(); + return { + installed: install.installed, + installing: isInstalling(), + version: install.version, + path: install.path, + running: loaded.loaded, + loadedAt: loaded.loadedAt || null, + uptimeMs: loaded.loaded ? Date.now() - loaded.loadedAt : 0, + npmAvailable: !!findNpm(), + mode: "library", // in-process transform, not an external proxy + }; +} + +// PRD health checklist, adapted to library mode: installed? → module loads +// (the "executable found / port listening" equivalent) → test request transforms. +export async function runHealthCheck() { + const checks = []; + const fail = (error) => ({ healthy: false, checks, error }); + + const install = getInstallInfo(); + checks.push({ id: "installed", label: "PXPIPE installed", ok: install.installed, detail: install.version ? `v${install.version}` : null }); + if (!install.installed) return fail("pxpipe not installed"); + + try { + await loadPxpipe(); + checks.push({ id: "module", label: "Transform module loads", ok: true, detail: `v${install.version}` }); + } catch (e) { + checks.push({ id: "module", label: "Transform module loads", ok: false, detail: e.message }); + return fail(`Cannot load module: ${e.message}`); + } + + try { + const test = await selfTest(); + checks.push({ id: "transform", label: "Test request transforms", ok: true, detail: `${test.durationMs}ms (${test.reason})` }); + } catch (e) { + checks.push({ id: "transform", label: "Test request transforms", ok: false, detail: e.message }); + return fail(`Self-test failed: ${e.message}`); + } + + return { healthy: true, checks, error: null }; +} diff --git a/src/lib/qoder/constants.js b/src/lib/qoder/constants.js new file mode 100644 index 0000000..bd1fc98 --- /dev/null +++ b/src/lib/qoder/constants.js @@ -0,0 +1,2 @@ +// Re-export: qoder constants moved to open-sse/shared/qoder (open-sse self-contained, docs 00 §1b). +export * from "../../../open-sse/shared/qoder/constants.js"; diff --git a/src/lib/qoder/cosy.js b/src/lib/qoder/cosy.js new file mode 100644 index 0000000..02b9c33 --- /dev/null +++ b/src/lib/qoder/cosy.js @@ -0,0 +1,2 @@ +// Re-export: qoder cosy helpers moved to open-sse/shared/qoder (docs 00 §1b). +export * from "../../../open-sse/shared/qoder/cosy.js"; diff --git a/src/lib/qoder/encoding.js b/src/lib/qoder/encoding.js new file mode 100644 index 0000000..a96fb9a --- /dev/null +++ b/src/lib/qoder/encoding.js @@ -0,0 +1,2 @@ +// Re-export: qoder encoding moved to open-sse/shared/qoder (docs 00 §1b). +export * from "../../../open-sse/shared/qoder/encoding.js"; diff --git a/src/lib/requestDetailsDb.js b/src/lib/requestDetailsDb.js new file mode 100644 index 0000000..0daaa8a --- /dev/null +++ b/src/lib/requestDetailsDb.js @@ -0,0 +1,4 @@ +// Shim → re-export from new SQLite-based DB layer (src/lib/db/) +export { + saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders, +} from "@/lib/db/index.js"; diff --git a/src/lib/tunnel/cloudflare/cloudflared.js b/src/lib/tunnel/cloudflare/cloudflared.js new file mode 100644 index 0000000..38e4bf7 --- /dev/null +++ b/src/lib/tunnel/cloudflare/cloudflared.js @@ -0,0 +1,449 @@ +import fs from "fs"; +import path from "path"; +import https from "https"; +import os from "os"; +import { execSync, spawn } from "child_process"; +import { savePid, loadPid, clearPid } from "./pid.js"; +import { DATA_DIR } from "@/lib/dataDir.js"; + +const BIN_DIR = path.join(DATA_DIR, "bin"); +const BINARY_NAME = "cloudflared"; +const IS_WINDOWS = os.platform() === "win32"; +const BIN_NAME = IS_WINDOWS ? `${BINARY_NAME}.exe` : BINARY_NAME; +const BIN_PATH = path.join(BIN_DIR, BIN_NAME); +const POWERSHELL_HIDDEN_COMMAND = "powershell -NoProfile -NonInteractive -WindowStyle Hidden -Command"; +const DEFAULT_QUICK_TUNNEL_PROTOCOL = "http2"; +const QUICK_TUNNEL_PROTOCOLS = new Set(["http2", "quic", "auto"]); + +const GITHUB_BASE_URL = "https://github.com/cloudflare/cloudflared/releases/latest/download"; + +const PLATFORM_MAPPINGS = { + darwin: { + x64: "cloudflared-darwin-amd64.tgz", + arm64: "cloudflared-darwin-arm64.tgz" + }, + win32: { + x64: "cloudflared-windows-amd64.exe", + ia32: "cloudflared-windows-386.exe", + arm64: "cloudflared-windows-386.exe" + }, + linux: { + x64: "cloudflared-linux-amd64", + arm64: "cloudflared-linux-arm64" + } +}; + +// Fallback order: prefer smallest/most-compatible binary per platform +const PLATFORM_FALLBACK = { + darwin: "cloudflared-darwin-amd64.tgz", + win32: "cloudflared-windows-386.exe", + linux: "cloudflared-linux-amd64" +}; + +function getDownloadUrl() { + const platform = os.platform(); + const arch = os.arch(); + + const platformMapping = PLATFORM_MAPPINGS[platform]; + if (!platformMapping) { + throw new Error(`Unsupported platform: ${platform}`); + } + + const binaryName = platformMapping[arch] || PLATFORM_FALLBACK[platform]; + return `${GITHUB_BASE_URL}/${binaryName}`; +} + +// Download state — shared so status API can read it +const dlState = { downloading: false, progress: 0 }; + +export function getDownloadStatus() { + return { downloading: dlState.downloading, progress: dlState.progress }; +} + +function downloadFile(url, dest) { + return new Promise((resolve, reject) => { + const file = fs.createWriteStream(dest); + + https.get(url, (response) => { + if ([301, 302, 303, 307, 308].includes(response.statusCode)) { + file.close(); + fs.unlinkSync(dest); + downloadFile(response.headers.location, dest).then(resolve).catch(reject); + return; + } + + if (response.statusCode !== 200) { + file.close(); + fs.unlinkSync(dest); + reject(new Error(`Download failed with status ${response.statusCode}`)); + return; + } + + const totalBytes = parseInt(response.headers["content-length"], 10) || 0; + let receivedBytes = 0; + dlState.downloading = true; + dlState.progress = 0; + + response.on("data", (chunk) => { + receivedBytes += chunk.length; + if (totalBytes > 0) dlState.progress = Math.round((receivedBytes / totalBytes) * 100); + }); + + response.pipe(file); + + file.on("finish", () => { + dlState.downloading = false; + dlState.progress = 100; + file.close(() => resolve(dest)); + }); + + file.on("error", (err) => { + dlState.downloading = false; + dlState.progress = 0; + file.close(); + fs.unlinkSync(dest); + reject(err); + }); + }).on("error", (err) => { + dlState.downloading = false; + dlState.progress = 0; + file.close(); + if (fs.existsSync(dest)) fs.unlinkSync(dest); + reject(err); + }); + }); +} + +const MIN_BINARY_SIZE = 1024 * 1024; // 1MB - cloudflared is ~30MB+ + +// Validate binary is executable on current platform and not truncated +function isValidBinary(filePath) { + try { + const stat = fs.statSync(filePath); + if (stat.size < MIN_BINARY_SIZE) return false; + const fd = fs.openSync(filePath, "r"); + const buf = Buffer.alloc(4); + fs.readSync(fd, buf, 0, 4, 0); + fs.closeSync(fd); + const magic = buf.toString("hex"); + if (IS_WINDOWS) return magic.startsWith("4d5a"); // PE (MZ) + if (os.platform() === "darwin") return magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); + return magic.startsWith("7f454c46"); // ELF (Linux) + } catch { + return false; + } +} + +let downloadPromise = null; + +export async function ensureCloudflared() { + if (downloadPromise) return downloadPromise; + downloadPromise = _ensureCloudflared().finally(() => { downloadPromise = null; }); + return downloadPromise; +} + +async function _ensureCloudflared() { + if (!fs.existsSync(BIN_DIR)) { + fs.mkdirSync(BIN_DIR, { recursive: true }); + } + + // Clean up incomplete downloads from previous runs + const tmpPath = `${BIN_PATH}.tmp`; + if (fs.existsSync(tmpPath)) { + try { fs.unlinkSync(tmpPath); } catch { /* ignore */ } + } + + if (fs.existsSync(BIN_PATH)) { + if (!isValidBinary(BIN_PATH)) { + console.log("[cloudflared] Invalid binary detected, re-downloading..."); + fs.unlinkSync(BIN_PATH); + } else { + if (!IS_WINDOWS) fs.chmodSync(BIN_PATH, "755"); + return BIN_PATH; + } + } + + const url = getDownloadUrl(); + const isArchive = url.endsWith(".tgz"); + const downloadDest = isArchive ? path.join(BIN_DIR, "cloudflared.tgz.tmp") : tmpPath; + + await downloadFile(url, downloadDest); + + if (isArchive) { + execSync(`tar -xzf "${downloadDest}" -C "${BIN_DIR}"`, { stdio: "pipe", windowsHide: true }); + fs.unlinkSync(downloadDest); + } else { + fs.renameSync(downloadDest, BIN_PATH); + } + + if (!IS_WINDOWS) { + fs.chmodSync(BIN_PATH, "755"); + } + + return BIN_PATH; +} + +let cloudflaredProcess = null; +let unexpectedExitHandler = null; +let intentionalKill = false; // suppress unexpected-exit callback during deliberate kill + +/** Register a callback to be called when cloudflared exits unexpectedly after connecting */ +export function setUnexpectedExitHandler(handler) { + unexpectedExitHandler = handler; +} + +export async function spawnCloudflared(tunnelToken) { + const binaryPath = await ensureCloudflared(); + + const child = spawn(binaryPath, ["tunnel", "run", "--dns-resolver-addrs", "1.1.1.1:53", "--token", tunnelToken], { + detached: false, + windowsHide: true, + cwd: os.tmpdir(), + stdio: ["ignore", "pipe", "pipe"] + }); + + cloudflaredProcess = child; + savePid(child.pid); + + return new Promise((resolve, reject) => { + let connectionCount = 0; + let resolved = false; + const timeout = setTimeout(() => { + resolved = true; + resolve(child); + }, 90000); + + const handleLog = (data) => { + const msg = data.toString(); + // Count exact occurrences in this chunk (each chunk may contain multiple lines) + const matches = msg.match(/Registered tunnel connection/g); + if (matches) { + connectionCount += matches.length; + if (connectionCount >= 4 && !resolved) { + resolved = true; + clearTimeout(timeout); + resolve(child); + } + } + }; + + child.stdout.on("data", handleLog); + child.stderr.on("data", handleLog); + + child.on("error", (err) => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + reject(err); + } + }); + + child.on("exit", (code, signal) => { + cloudflaredProcess = null; + clearPid(); + const wasConnected = resolved; // true = already connected successfully + if (!resolved) { + resolved = true; + clearTimeout(timeout); + // Collect stderr output for better error diagnosis + let stderrOutput = ""; + if (child.stderr && !child.stderr.destroyed) { + // Try to read any buffered stderr (may not have all output but helps with common errors) + stderrOutput = " Check cloudflared logs for details."; + } + if (code === 1) { + // Common exit code 1 issues: invalid token, auth failure, network issues + reject(new Error(`cloudflared exited with code ${code}${stderrOutput} Ensure your tunnel token is valid and network is reachable.`)); + } else if (code === 2) { + reject(new Error(`cloudflared exited with code ${code}${stderrOutput} Check if required arguments are correct.`)); + } else { + reject(new Error(`cloudflared exited with code ${code}${stderrOutput}`)); + } + return; + } + // Watchdog (initializeApp) handles recovery — no auto-reconnect here + if (intentionalKill) { intentionalKill = false; return; } + if (wasConnected && unexpectedExitHandler) unexpectedExitHandler(); + }); + }); +} + +/** + * Spawn cloudflared quick tunnel (no account needed) + * Returns the generated trycloudflare.com URL + */ +export async function spawnQuickTunnel(localPort, onUrlUpdate) { + const binaryPath = await ensureCloudflared(); + + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "cloudflared-quick-")); + const configPath = path.join(configDir, "config.yml"); + // Avoid using default ~/.cloudflared/config.yml, which can conflict with quick tunnel behavior. + fs.writeFileSync(configPath, "# quick-tunnel config placeholder\n", "utf8"); + + let isCleaned = false; + const cleanup = () => { + if (isCleaned) return; + isCleaned = true; + try { + fs.rmSync(configDir, { recursive: true, force: true }); + } catch (e) { /* ignore */ } + }; + + const requestedProtocol = String(process.env.TUNNEL_TRANSPORT_PROTOCOL || process.env.CLOUDFLARED_PROTOCOL || DEFAULT_QUICK_TUNNEL_PROTOCOL).trim().toLowerCase(); + const tunnelProtocol = QUICK_TUNNEL_PROTOCOLS.has(requestedProtocol) ? requestedProtocol : DEFAULT_QUICK_TUNNEL_PROTOCOL; + const child = spawn(binaryPath, ["tunnel", "--url", `http://127.0.0.1:${localPort}`, "--config", configPath, "--no-autoupdate", "--retries", "99"], { + detached: false, + windowsHide: true, + cwd: os.tmpdir(), + env: { + ...process.env, + TUNNEL_TRANSPORT_PROTOCOL: tunnelProtocol, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + cloudflaredProcess = child; + savePid(child.pid); + + return new Promise((resolve, reject) => { + let resolved = false; + // Keep a small tail of raw cloudflared logs to surface real failure causes + let logTail = ""; + + function getQuickTunnelUrlFromLog(message) { + // cloudflared logs may contain "api.trycloudflare.com" as well, + // but that is NOT the quick-tunnel endpoint we need. + const regex = /https:\/\/([a-z0-9-]+)\.trycloudflare\.com/gi; + const candidates = []; + + for (const match of message.matchAll(regex)) { + const host = match[1]; + if (host === "api") continue; + candidates.push(`https://${host}.trycloudflare.com`); + } + + if (!candidates.length) return null; + return candidates[candidates.length - 1]; + } + + const timeout = setTimeout(() => { + if (resolved) return; + resolved = true; + cleanup(); + reject(new Error(`Quick tunnel timed out. Last log: ${logTail.slice(-800) || "(empty)"}`)); + }, 90000); + + let lastUrl = null; + + const handleLog = (data) => { + const msg = data.toString(); + logTail = (logTail + msg).slice(-4000); + const tunnelUrl = getQuickTunnelUrlFromLog(msg); + if (!tunnelUrl) return; + + if (!resolved) { + // First URL — resolve the promise, do NOT call onUrlUpdate (caller handles initial register) + resolved = true; + lastUrl = tunnelUrl; + clearTimeout(timeout); + cleanup(); + console.log(`[Tunnel] cloudflared URL: ${tunnelUrl}`); + resolve({ child, tunnelUrl }); + return; + } + + // URL changed after initial connect — notify caller to re-register + if (tunnelUrl !== lastUrl) { + console.log(`[Tunnel] cloudflared URL changed: ${tunnelUrl}`); + lastUrl = tunnelUrl; + if (onUrlUpdate) onUrlUpdate(tunnelUrl); + } + }; + + child.stdout.on("data", handleLog); + child.stderr.on("data", handleLog); + + child.on("error", (err) => { + if (resolved) return; + resolved = true; + clearTimeout(timeout); + cleanup(); + reject(err); + }); + + child.on("exit", (code, signal) => { + cloudflaredProcess = null; + clearPid(); + // Deliberate kill (restart/disable) — exit silently, no error noise + if (intentionalKill) { + intentionalKill = false; + clearTimeout(timeout); + cleanup(); + if (!resolved) { resolved = true; reject(new Error("cloudflared killed")); } + return; + } + console.log(`[Tunnel] cloudflared exit code=${code} signal=${signal}`); + if (!resolved) { + resolved = true; + clearTimeout(timeout); + cleanup(); + const tail = logTail.slice(-600).trim() || "(empty)"; + if (code === 1) { + reject(new Error(`cloudflared quick tunnel exited (code 1). Common causes: (1) outbound port 7844 (TCP/UDP) blocked, (2) TryCloudflare service issue, (3) cannot reach 127.0.0.1:${localPort}, (4) protocol (http2/quic) blocked by network. Last log: ${tail}`)); + } else if (code === 2) { + reject(new Error(`cloudflared exited (code 2). Bad arguments. Last log: ${tail}`)); + } else { + reject(new Error(`cloudflared exited (code ${code}). Last log: ${tail}`)); + } + return; + } + if (unexpectedExitHandler) unexpectedExitHandler(); + cleanup(); + }); + }); +} + +// Kill cloudflared processes whose command line targets the given port (any host). +// Boundary check ensures :20128 doesn't match :201280 or :202128. +function killCloudflaredByPort(port) { + if (!port) return; + try { + if (IS_WINDOWS) { + const psCmd = `Get-CimInstance Win32_Process -Filter \\"Name='cloudflared.exe'\\" | Where-Object { $_.CommandLine -match ':${port}(\\D|$)' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }`; + execSync(`${POWERSHELL_HIDDEN_COMMAND} "${psCmd}"`, { stdio: "ignore", windowsHide: true }); + } else { + execSync(`pkill -f "cloudflared.*:${port}([^0-9]|$)" 2>/dev/null || true`, { stdio: "ignore", windowsHide: true }); + } + } catch (e) { /* ignore */ } +} + +export function killCloudflared(localPort) { + intentionalKill = true; + if (cloudflaredProcess) { + try { + cloudflaredProcess.kill(); + } catch (e) { /* ignore */ } + cloudflaredProcess = null; + } + + const pid = loadPid(); + if (pid) { + try { + process.kill(pid); + } catch (e) { /* ignore */ } + clearPid(); + } + + killCloudflaredByPort(localPort); +} + +export function isCloudflaredRunning() { + const pid = loadPid(); + if (!pid) return false; + try { + process.kill(pid, 0); + return true; + } catch (e) { + return false; + } +} diff --git a/src/lib/tunnel/cloudflare/config.js b/src/lib/tunnel/cloudflare/config.js new file mode 100644 index 0000000..2cc3f9d --- /dev/null +++ b/src/lib/tunnel/cloudflare/config.js @@ -0,0 +1,9 @@ +// Cloudflare quick tunnel: DNS propagates fast, short timeouts OK +export const HEALTH_CHECK = { + intervalMs: 2000, + timeoutMs: 60000, + fetchTimeoutMs: 5000, + dnsTimeoutMs: 2000, +}; + +export const WORKER_URL = process.env.TUNNEL_WORKER_URL || "https://abc-tunnel.us"; diff --git a/src/lib/tunnel/cloudflare/healthCheck.js b/src/lib/tunnel/cloudflare/healthCheck.js new file mode 100644 index 0000000..428b351 --- /dev/null +++ b/src/lib/tunnel/cloudflare/healthCheck.js @@ -0,0 +1,29 @@ +import { resolveDns } from "../shared/dnsResolver.js"; +import { HEALTH_CHECK } from "./config.js"; + +export async function probeUrlAlive(url) { + if (!url) return false; + let hostname; + try { hostname = new URL(url).hostname; } catch { return false; } + + if (!await resolveDns(hostname, HEALTH_CHECK.dnsTimeoutMs)) return false; + + try { + const res = await fetch(`${url}/api/health`, { + signal: AbortSignal.timeout(HEALTH_CHECK.fetchTimeoutMs), + }); + return res.ok; + } catch { + return false; + } +} + +export async function waitForHealth(url, cancelToken = { cancelled: false }) { + const start = Date.now(); + while (Date.now() - start < HEALTH_CHECK.timeoutMs) { + if (cancelToken.cancelled) throw new Error("cancelled"); + if (await probeUrlAlive(url)) return true; + await new Promise((r) => setTimeout(r, HEALTH_CHECK.intervalMs)); + } + throw new Error(`Health check timeout after ${HEALTH_CHECK.timeoutMs}ms`); +} diff --git a/src/lib/tunnel/cloudflare/manager.js b/src/lib/tunnel/cloudflare/manager.js new file mode 100644 index 0000000..e15b47b --- /dev/null +++ b/src/lib/tunnel/cloudflare/manager.js @@ -0,0 +1,151 @@ +import { loadState, saveState, generateShortId } from "../shared/state.js"; +import { spawnQuickTunnel, killCloudflared, isCloudflaredRunning, setUnexpectedExitHandler } from "./cloudflared.js"; +import { clearPid } from "./pid.js"; +import { waitForHealth, probeUrlAlive } from "./healthCheck.js"; +import { WORKER_URL } from "./config.js"; +import { getSettings, updateSettings } from "@/lib/localDb"; + +const svc = { + cancelToken: { cancelled: false }, + spawnInProgress: false, + lastRestartAt: 0, + activeLocalPort: null, +}; + +export function getTunnelService() { return svc; } +export function isTunnelManuallyDisabled() { return svc.cancelToken.cancelled; } +export function isTunnelReconnecting() { return svc.spawnInProgress; } + +let onUnexpectedExit = null; +export function setTunnelUnexpectedExitCallback(cb) { onUnexpectedExit = cb; } + +async function registerTunnelUrl(shortId, tunnelUrl) { + await fetch(`${WORKER_URL}/api/tunnel/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ shortId, tunnelUrl }) + }); +} + +function throwIfCancelled(token) { + if (token.cancelled) throw new Error("tunnel cancelled"); +} + +export async function enableTunnel(localPort = 20128) { + console.log(`[Tunnel] enable start (port=${localPort})`); + svc.cancelToken = { cancelled: false }; + svc.activeLocalPort = localPort; + svc.spawnInProgress = true; + const token = svc.cancelToken; + + try { + if (isCloudflaredRunning()) { + const existing = loadState(); + if (existing?.tunnelUrl && existing?.shortId) { + const publicUrl = `https://r${existing.shortId}.abc-tunnel.us`; + // Reuse only if BOTH direct + public URL alive (avoid stale socket after network change) + const [directOk, publicOk] = await Promise.all([ + probeUrlAlive(existing.tunnelUrl), + probeUrlAlive(publicUrl), + ]); + if (directOk && publicOk) { + console.log(`[Tunnel] already running, reuse: ${existing.tunnelUrl}`); + return { success: true, tunnelUrl: existing.tunnelUrl, shortId: existing.shortId, publicUrl, alreadyRunning: true }; + } + console.log(`[Tunnel] stale (direct=${directOk} public=${publicOk}), respawn`); + } + } + + killCloudflared(localPort); + console.log("[Tunnel] killed existing cloudflared"); + throwIfCancelled(token); + + const existing = loadState(); + const shortId = existing?.shortId || generateShortId(); + + const onUrlUpdate = async (url) => { + if (token.cancelled) return; + console.log(`[Tunnel] url updated: ${url}`); + await registerTunnelUrl(shortId, url); + saveState({ shortId, tunnelUrl: url }); + await updateSettings({ tunnelEnabled: true, tunnelUrl: url }); + }; + + // Register exit handler BEFORE spawn so it fires even on early exit + setUnexpectedExitHandler(() => { + console.warn("[Tunnel] cloudflared exited unexpectedly, scheduling respawn"); + if (onUnexpectedExit) onUnexpectedExit(); + }); + + const { tunnelUrl } = await spawnQuickTunnel(localPort, onUrlUpdate); + console.log(`[Tunnel] spawned: ${tunnelUrl}`); + throwIfCancelled(token); + + const publicUrl = `https://r${shortId}.abc-tunnel.us`; + await registerTunnelUrl(shortId, tunnelUrl); + saveState({ shortId, tunnelUrl }); + await updateSettings({ tunnelEnabled: true, tunnelUrl }); + console.log(`[Tunnel] registered shortId=${shortId} publicUrl=${publicUrl}`); + + // Verify publicUrl first (worker route is reliable; direct *.trycloudflare.com DNS may lag) + await waitForHealth(publicUrl, token); + console.log("[Tunnel] public URL healthy"); + // Direct tunnel probe is best-effort: DNS for *.trycloudflare.com can be slow/blocked + if (!(await probeUrlAlive(tunnelUrl))) { + console.warn("[Tunnel] direct URL not reachable yet, continuing via publicUrl"); + } else { + console.log("[Tunnel] direct URL healthy"); + } + + console.log("[Tunnel] enable success"); + return { success: true, tunnelUrl, shortId, publicUrl }; + } catch (e) { + // Suppress noise when spawn was deliberately killed (restart/disable superseded it) + if (!/cloudflared killed|tunnel cancelled/.test(e.message)) { + console.error(`[Tunnel] enable error: ${e.message}`); + } + throw e; + } finally { + svc.spawnInProgress = false; + } +} + +export async function disableTunnel() { + console.log("[Tunnel] disable"); + // Abort any in-flight enable so it cannot resurrect state after we clear it + svc.cancelToken.cancelled = true; + setUnexpectedExitHandler(null); + + try { killCloudflared(svc.activeLocalPort); } catch (e) { console.warn(`[Tunnel] kill warn: ${e.message}`); } + clearPid(); + + const state = loadState(); + if (state) saveState({ shortId: state.shortId, tunnelUrl: null }); + + await updateSettings({ tunnelEnabled: false, tunnelUrl: "" }); + // Force-clear flags so a subsequent enable is not blocked by a stuck spawnInProgress + svc.spawnInProgress = false; + svc.activeLocalPort = null; + return { success: true }; +} + +export async function getTunnelStatus() { + const settings = await getSettings(); + const settingsEnabled = settings.tunnelEnabled === true; + const state = loadState(); + const shortId = state?.shortId || ""; + const publicUrl = shortId ? `https://r${shortId}.abc-tunnel.us` : ""; + const tunnelUrl = state?.tunnelUrl || ""; + + // Lazy: skip PID probe entirely when user disabled tunnel + const running = settingsEnabled ? isCloudflaredRunning() : false; + + return { + enabled: settingsEnabled && running, + settingsEnabled, + tunnelUrl, + shortId, + publicUrl, + running + }; +} diff --git a/src/lib/tunnel/cloudflare/pid.js b/src/lib/tunnel/cloudflare/pid.js new file mode 100644 index 0000000..919837c --- /dev/null +++ b/src/lib/tunnel/cloudflare/pid.js @@ -0,0 +1,23 @@ +import fs from "fs"; +import path from "path"; +import { TUNNEL_DIR, ensureTunnelDir } from "../shared/state.js"; + +const PID_FILE = path.join(TUNNEL_DIR, "cloudflared.pid"); + +export function savePid(pid) { + ensureTunnelDir(); + fs.writeFileSync(PID_FILE, pid.toString()); +} + +export function loadPid() { + try { + if (fs.existsSync(PID_FILE)) return parseInt(fs.readFileSync(PID_FILE, "utf8")); + } catch { /* ignore */ } + return null; +} + +export function clearPid() { + try { + if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE); + } catch { /* ignore */ } +} diff --git a/src/lib/tunnel/index.js b/src/lib/tunnel/index.js new file mode 100644 index 0000000..3ffab37 --- /dev/null +++ b/src/lib/tunnel/index.js @@ -0,0 +1,53 @@ +// Cloudflare service +export { + enableTunnel, + disableTunnel, + getTunnelStatus, + isTunnelManuallyDisabled, + isTunnelReconnecting, + getTunnelService, + setTunnelUnexpectedExitCallback, +} from "./cloudflare/manager.js"; +export { + killCloudflared, + isCloudflaredRunning, + ensureCloudflared, + getDownloadStatus, +} from "./cloudflare/cloudflared.js"; +export { probeUrlAlive as probeCloudflareAlive } from "./cloudflare/healthCheck.js"; + +// Tailscale service +export { + enableTailscale, + disableTailscale, + getTailscaleStatus, + isTailscaleReconnecting, + getTailscaleService, +} from "./tailscale/manager.js"; +export { + isTailscaleInstalled, + isTailscaleRunning, + isTailscaleRunningStrict, + isTailscaleLoggedIn, + isTailscaleLoggedInStrict, + isSystemDaemonRunning, + isDaemonAlive, + startFunnel, + getTailscaleBin, + installTailscale, + startLogin, + startDaemonWithPassword, + TAILSCALE_SOCKET, +} from "./tailscale/tailscale.js"; +export { probeUrlAlive as probeTailscaleAlive } from "./tailscale/healthCheck.js"; + +// Shared +export { loadState, generateShortId } from "./shared/state.js"; +export { checkInternet } from "./shared/internetCheck.js"; +export { + RESTART_COOLDOWN_MS, + NETWORK_SETTLE_MS, + WATCHDOG_INTERVAL_MS, + NETWORK_CHECK_INTERVAL_MS, + VIRTUAL_IFACE_REGEX, +} from "./shared/watchdogConfig.js"; diff --git a/src/lib/tunnel/shared/dnsResolver.js b/src/lib/tunnel/shared/dnsResolver.js new file mode 100644 index 0000000..e8fde7d --- /dev/null +++ b/src/lib/tunnel/shared/dnsResolver.js @@ -0,0 +1,17 @@ +import dns from "dns"; + +// Force public DNS to bypass OS negative cache (mDNSResponder holds NXDOMAIN) +const resolver = new dns.promises.Resolver(); +resolver.setServers(["1.1.1.1", "1.0.0.1", "8.8.8.8"]); + +// Try custom public DNS first, fall back to OS resolver +// (Cloudflare DNS may not resolve all hostnames, e.g. *.ts.net) +export async function resolveDns(hostname, timeoutMs) { + const tryResolver = (fn) => Promise.race([ + fn(), + new Promise((_, rej) => setTimeout(() => rej(new Error("dns timeout")), timeoutMs)), + ]).then(() => true).catch(() => false); + + if (await tryResolver(() => resolver.resolve4(hostname))) return true; + return tryResolver(() => dns.promises.resolve4(hostname)); +} diff --git a/src/lib/tunnel/shared/internetCheck.js b/src/lib/tunnel/shared/internetCheck.js new file mode 100644 index 0000000..756640d --- /dev/null +++ b/src/lib/tunnel/shared/internetCheck.js @@ -0,0 +1,26 @@ +import net from "net"; + +const INTERNET_CHECK = { + host: "1.1.1.1", + port: 443, + timeoutMs: 3000, +}; + +export function checkInternet() { + return new Promise((resolve) => { + const socket = new net.Socket(); + let done = false; + const finish = (ok) => { + if (done) return; + done = true; + try { socket.destroy(); } catch { /* ignore */ } + resolve(ok); + }; + socket.setTimeout(INTERNET_CHECK.timeoutMs); + socket.once("connect", () => finish(true)); + socket.once("timeout", () => finish(false)); + socket.once("error", () => finish(false)); + try { socket.connect(INTERNET_CHECK.port, INTERNET_CHECK.host); } + catch { finish(false); } + }); +} diff --git a/src/lib/tunnel/shared/state.js b/src/lib/tunnel/shared/state.js new file mode 100644 index 0000000..6a16181 --- /dev/null +++ b/src/lib/tunnel/shared/state.js @@ -0,0 +1,41 @@ +import fs from "fs"; +import path from "path"; +import { DATA_DIR } from "@/lib/dataDir.js"; + +const TUNNEL_DIR = path.join(DATA_DIR, "tunnel"); +const STATE_FILE = path.join(TUNNEL_DIR, "state.json"); + +const SHORT_ID_LENGTH = 6; +const SHORT_ID_CHARS = "abcdefghijklmnpqrstuvwxyz23456789"; + +export function ensureTunnelDir() { + if (!fs.existsSync(TUNNEL_DIR)) fs.mkdirSync(TUNNEL_DIR, { recursive: true }); +} + +export function loadState() { + try { + if (fs.existsSync(STATE_FILE)) return JSON.parse(fs.readFileSync(STATE_FILE, "utf8")); + } catch { /* ignore corrupt state */ } + return null; +} + +export function saveState(state) { + ensureTunnelDir(); + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); +} + +export function clearState() { + try { + if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE); + } catch { /* ignore */ } +} + +export function generateShortId() { + let result = ""; + for (let i = 0; i < SHORT_ID_LENGTH; i++) { + result += SHORT_ID_CHARS.charAt(Math.floor(Math.random() * SHORT_ID_CHARS.length)); + } + return result; +} + +export { TUNNEL_DIR }; diff --git a/src/lib/tunnel/shared/watchdogConfig.js b/src/lib/tunnel/shared/watchdogConfig.js new file mode 100644 index 0000000..0274997 --- /dev/null +++ b/src/lib/tunnel/shared/watchdogConfig.js @@ -0,0 +1,8 @@ +// Watchdog + network monitor timings (shared by both services) +export const RESTART_COOLDOWN_MS = 120000; +export const NETWORK_SETTLE_MS = 2500; +export const WATCHDOG_INTERVAL_MS = 60000; +export const NETWORK_CHECK_INTERVAL_MS = 5000; + +// Skip virtual/transient interfaces (tailscale utun, AirDrop awdl, bridges) that flap and cause false netchange +export const VIRTUAL_IFACE_REGEX = /^(utun|awdl|llw|anpi|bridge|gif|stf|ipsec|ap|tun|tap|vmnet|veth|docker)/i; diff --git a/src/lib/tunnel/tailscale/config.js b/src/lib/tunnel/tailscale/config.js new file mode 100644 index 0000000..3195ad6 --- /dev/null +++ b/src/lib/tunnel/tailscale/config.js @@ -0,0 +1,7 @@ +// Tailscale Funnel: cert provisioning + *.ts.net DNS propagation slower → longer timeouts +export const HEALTH_CHECK = { + intervalMs: 2000, + timeoutMs: 180000, + fetchTimeoutMs: 8000, + dnsTimeoutMs: 3000, +}; diff --git a/src/lib/tunnel/tailscale/healthCheck.js b/src/lib/tunnel/tailscale/healthCheck.js new file mode 100644 index 0000000..428b351 --- /dev/null +++ b/src/lib/tunnel/tailscale/healthCheck.js @@ -0,0 +1,29 @@ +import { resolveDns } from "../shared/dnsResolver.js"; +import { HEALTH_CHECK } from "./config.js"; + +export async function probeUrlAlive(url) { + if (!url) return false; + let hostname; + try { hostname = new URL(url).hostname; } catch { return false; } + + if (!await resolveDns(hostname, HEALTH_CHECK.dnsTimeoutMs)) return false; + + try { + const res = await fetch(`${url}/api/health`, { + signal: AbortSignal.timeout(HEALTH_CHECK.fetchTimeoutMs), + }); + return res.ok; + } catch { + return false; + } +} + +export async function waitForHealth(url, cancelToken = { cancelled: false }) { + const start = Date.now(); + while (Date.now() - start < HEALTH_CHECK.timeoutMs) { + if (cancelToken.cancelled) throw new Error("cancelled"); + if (await probeUrlAlive(url)) return true; + await new Promise((r) => setTimeout(r, HEALTH_CHECK.intervalMs)); + } + throw new Error(`Health check timeout after ${HEALTH_CHECK.timeoutMs}ms`); +} diff --git a/src/lib/tunnel/tailscale/manager.js b/src/lib/tunnel/tailscale/manager.js new file mode 100644 index 0000000..7fe40e7 --- /dev/null +++ b/src/lib/tunnel/tailscale/manager.js @@ -0,0 +1,129 @@ +import { loadState, generateShortId } from "../shared/state.js"; +import { startFunnel, stopFunnel, isTailscaleRunning, isTailscaleRunningStrict, isTailscaleLoggedIn, isTailscaleLoggedInStrict, startLogin, startDaemonWithPassword, provisionCert } from "./tailscale.js"; +import { waitForHealth } from "./healthCheck.js"; +import { getSettings, updateSettings } from "@/lib/localDb"; +import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager"; + +initDbHooks(getSettings, updateSettings); + +const svc = { + cancelToken: { cancelled: false }, + spawnInProgress: false, + lastRestartAt: 0, + activeLocalPort: null, +}; + +export function getTailscaleService() { return svc; } +export function isTailscaleReconnecting() { return svc.spawnInProgress; } + +function throwIfCancelled(token) { + if (token.cancelled) throw new Error("tailscale cancelled"); +} + +export async function enableTailscale(localPort = 20128) { + console.log(`[Tailscale] enable start (port=${localPort})`); + svc.cancelToken = { cancelled: false }; + svc.activeLocalPort = localPort; + svc.spawnInProgress = true; + const token = svc.cancelToken; + + try { + const sudoPass = getCachedPassword() || await loadEncryptedPassword() || ""; + await startDaemonWithPassword(sudoPass); + console.log("[Tailscale] daemon ready"); + throwIfCancelled(token); + + const existing = loadState(); + const shortId = existing?.shortId || generateShortId(); + const tsHostname = shortId; + + const loggedIn = await isTailscaleLoggedInStrict(); + console.log(`[Tailscale] loggedIn=${loggedIn}`); + if (!loggedIn) { + const loginResult = await startLogin(tsHostname); + if (loginResult.authUrl) { + console.log(`[Tailscale] needs login, authUrl=${loginResult.authUrl}`); + return { success: false, needsLogin: true, authUrl: loginResult.authUrl }; + } + console.log("[Tailscale] login resolved alreadyLoggedIn"); + } + throwIfCancelled(token); + + stopFunnel(); + let result; + try { + console.log("[Tailscale] starting funnel"); + result = await startFunnel(localPort); + } catch (e) { + console.error(`[Tailscale] funnel error: ${e.message}`); + // Daemon not logged in / not ready → auto-trigger login flow so user stays in-app + if (/NoState|unexpected state|not logged in|Logged ?out|NeedsLogin/i.test(e.message || "")) { + console.log("[Tailscale] retry via startLogin"); + const loginResult = await startLogin(tsHostname); + if (loginResult.authUrl) return { success: false, needsLogin: true, authUrl: loginResult.authUrl }; + } + throw e; + } + throwIfCancelled(token); + + if (result.funnelNotEnabled) { + console.log(`[Tailscale] funnel not enabled, enableUrl=${result.enableUrl}`); + return { success: false, funnelNotEnabled: true, enableUrl: result.enableUrl }; + } + + // Strict probe: bypass cache so we don't false-negative on first invocation + if (!(await isTailscaleLoggedInStrict()) || !(await isTailscaleRunningStrict())) { + console.error("[Tailscale] strict probe failed (device removed?)"); + stopFunnel(); + return { success: false, error: "Tailscale not connected. Device may have been removed. Please re-login." }; + } + + await updateSettings({ tailscaleEnabled: true, tailscaleUrl: result.tunnelUrl }); + console.log(`[Tailscale] funnel up: ${result.tunnelUrl}`); + + // Provision TLS cert so Funnel can serve HTTPS (non-fatal if fails) + const hostname = new URL(result.tunnelUrl).hostname; + await provisionCert(hostname); + + // Verify funnel serves /api/health — timeout is non-fatal (DNS may still be propagating) + let reachableNow = false; + try { + await waitForHealth(result.tunnelUrl, token); + reachableNow = true; + } catch (he) { + if (!he.message.startsWith("Health check timeout")) throw he; + console.warn(`[Tailscale] health check timed out, will retry via watchdog`); + } + console.log(`[Tailscale] enable success (reachable=${reachableNow})`); + return { success: true, tunnelUrl: result.tunnelUrl }; + } catch (e) { + console.error(`[Tailscale] enable error: ${e.message}`); + throw e; + } finally { + svc.spawnInProgress = false; + } +} + +export async function disableTailscale() { + console.log("[Tailscale] disable"); + svc.cancelToken.cancelled = true; + stopFunnel(); + await updateSettings({ tailscaleEnabled: false, tailscaleUrl: "" }); + return { success: true }; +} + +export async function getTailscaleStatus() { + const settings = await getSettings(); + const settingsEnabled = settings.tailscaleEnabled === true; + const tunnelUrl = settings.tailscaleUrl || ""; + // Skip probes entirely when disabled; check login before running (device removed = not logged in) + const loggedIn = settingsEnabled ? isTailscaleLoggedIn() : false; + const running = loggedIn ? isTailscaleRunning() : false; + return { + enabled: settingsEnabled && running, + settingsEnabled, + tunnelUrl, + running, + loggedIn + }; +} diff --git a/src/lib/tunnel/tailscale/tailscale.js b/src/lib/tunnel/tailscale/tailscale.js new file mode 100644 index 0000000..467ad28 --- /dev/null +++ b/src/lib/tunnel/tailscale/tailscale.js @@ -0,0 +1,859 @@ +import fs from "fs"; +import path from "path"; +import os from "os"; +import crypto from "crypto"; +import { execSync, exec, spawn } from "child_process"; +import { promisify } from "util"; +import { execWithPassword } from "@/mitm/dns/dnsConfig"; +import { DATA_DIR } from "@/lib/dataDir.js"; + +const execAsync = promisify(exec); + +const BIN_DIR = path.join(DATA_DIR, "bin"); +const IS_MAC = os.platform() === "darwin"; +const IS_LINUX = os.platform() === "linux"; +const IS_WINDOWS = os.platform() === "win32"; +const TAILSCALE_BIN = path.join(BIN_DIR, IS_WINDOWS ? "tailscale.exe" : "tailscale"); + +// Custom socket for userspace-networking mode (no root required) +const TAILSCALE_DIR = path.join(DATA_DIR, "tailscale"); +export const TAILSCALE_SOCKET = path.join(TAILSCALE_DIR, "tailscaled.sock"); +const SOCKET_FLAG = IS_WINDOWS ? [] : ["--socket", TAILSCALE_SOCKET]; + +// System daemon socket (sudo install: apt/snap/systemd) — read-only status detection +const SYSTEM_TAILSCALE_SOCKET = IS_WINDOWS ? null : "/var/run/tailscale/tailscaled.sock"; +const SYSTEM_SOCKET_FLAG = SYSTEM_TAILSCALE_SOCKET ? ["--socket", SYSTEM_TAILSCALE_SOCKET] : []; + +// Well-known Windows install path +const WINDOWS_TAILSCALE_BIN = "C:\\Program Files\\Tailscale\\tailscale.exe"; + +// Common Unix install paths to probe synchronously (system tailscale) +const UNIX_TAILSCALE_CANDIDATES = [ + "/usr/local/bin/tailscale", + "/opt/homebrew/bin/tailscale", + "/usr/sbin/tailscale", // apt package on Debian/Ubuntu + "/usr/bin/tailscale", + "/snap/bin/tailscale", // Snap package +]; + +// ─── Cache + background refresh (avoid blocking event loop on dead daemon) ── +const PROBE_TTL_MS = 10000; +const PROBE_TIMEOUT_MS = 1500; + +const binCache = { value: undefined, fetchedAt: 0, refreshing: false }; +const runningCache = { value: false, fetchedAt: 0, refreshing: false }; +const loggedInCache = { value: false, fetchedAt: 0, refreshing: false }; +const funnelUrlCache = { value: null, port: null, fetchedAt: 0, refreshing: false }; + +function fallbackBin() { + if (fs.existsSync(TAILSCALE_BIN)) return TAILSCALE_BIN; + if (IS_WINDOWS && fs.existsSync(WINDOWS_TAILSCALE_BIN)) return WINDOWS_TAILSCALE_BIN; + if (!IS_WINDOWS) return UNIX_TAILSCALE_CANDIDATES.find((p) => fs.existsSync(p)) || null; + return null; +} + +function bgRefreshBin() { + if (binCache.refreshing) return; + binCache.refreshing = true; + const cmd = IS_WINDOWS ? "where tailscale 2>nul" : "which tailscale 2>/dev/null"; + execAsync(cmd, { windowsHide: true, timeout: PROBE_TIMEOUT_MS, env: { ...process.env, PATH: EXTENDED_PATH } }) + .then(({ stdout }) => { + const sys = stdout.trim(); + binCache.value = sys || fallbackBin(); + }) + .catch(() => { binCache.value = fallbackBin(); }) + .finally(() => { + binCache.fetchedAt = Date.now(); + binCache.refreshing = false; + }); +} + +// Sync getter: returns cached value, triggers background refresh if stale +export function getTailscaleBin() { + if (Date.now() - binCache.fetchedAt > PROBE_TTL_MS) bgRefreshBin(); + // First call: synchronously probe common install paths (no exec, no event-loop block) + if (binCache.value === undefined) { + if (fs.existsSync(TAILSCALE_BIN)) binCache.value = TAILSCALE_BIN; + else if (IS_WINDOWS && fs.existsSync(WINDOWS_TAILSCALE_BIN)) binCache.value = WINDOWS_TAILSCALE_BIN; + else if (!IS_WINDOWS) { + const found = UNIX_TAILSCALE_CANDIDATES.find((p) => fs.existsSync(p)); + binCache.value = found || null; + } else binCache.value = null; + } + return binCache.value; +} + +export function isTailscaleInstalled() { + return getTailscaleBin() !== null; +} + +/** Build tailscale CLI args with custom socket (no root needed) */ +function tsArgs(...args) { + return [...SOCKET_FLAG, ...args]; +} + +// Async strict probe: authoritative, awaitable (never blocks event loop). Updates cache. +export async function isTailscaleLoggedInStrict() { + const bin = getTailscaleBin(); + if (!bin) return false; + try { + const { stdout } = await execAsync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { + windowsHide: true, + env: { ...process.env, PATH: EXTENDED_PATH }, + timeout: 5000 + }); + const json = JSON.parse(stdout); + // BackendState=Running + Self.Online=true → device still exists in tailnet + const loggedIn = json.BackendState === "Running" && json.Self?.Online === true; + loggedInCache.value = loggedIn; + loggedInCache.fetchedAt = Date.now(); + return loggedIn; + } catch { + return false; + } +} + +function bgRefreshLoggedIn() { + if (loggedInCache.refreshing) return; + const bin = getTailscaleBin(); + if (!bin) { + loggedInCache.value = false; + loggedInCache.fetchedAt = Date.now(); + return; + } + loggedInCache.refreshing = true; + // Dual-socket aware: probe custom socket first, then system socket + probeStatusAsync(bin) + .then((json) => { + loggedInCache.value = !!json && json.BackendState === "Running" && json.Self?.Online === true; + }) + .catch(() => { loggedInCache.value = false; }) + .finally(() => { + loggedInCache.fetchedAt = Date.now(); + loggedInCache.refreshing = false; + }); +} + +// Probe `status --json` over custom then system socket. Resolves parsed JSON or null. Never blocks event loop. +async function probeStatusAsync(bin) { + for (const socketArgs of [SOCKET_FLAG, SYSTEM_SOCKET_FLAG]) { + try { + const { stdout } = await execAsync(`"${bin}" ${socketArgs.join(" ")} status --json`, { + windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH }, timeout: PROBE_TIMEOUT_MS, + }); + return JSON.parse(stdout); + } catch { /* try next socket */ } + } + return null; +} + +// Sync getter: never blocks; returns last known state, refreshes in background +export function isTailscaleLoggedIn() { + if (Date.now() - loggedInCache.fetchedAt > PROBE_TTL_MS) bgRefreshLoggedIn(); + return loggedInCache.value; +} + +function bgRefreshRunning() { + if (runningCache.refreshing) return; + const bin = getTailscaleBin(); + if (!bin) { + runningCache.value = false; + runningCache.fetchedAt = Date.now(); + return; + } + runningCache.refreshing = true; + execAsync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel status --json`, { windowsHide: true, timeout: PROBE_TIMEOUT_MS }) + .then(({ stdout }) => { + try { + const json = JSON.parse(stdout); + runningCache.value = Object.keys(json.AllowFunnel || {}).length > 0; + } catch { runningCache.value = false; } + }) + .catch(() => { runningCache.value = false; }) + .finally(() => { + runningCache.fetchedAt = Date.now(); + runningCache.refreshing = false; + }); +} + +// Sync getter: never blocks; returns last known state, refreshes in background +export function isTailscaleRunning() { + if (Date.now() - runningCache.fetchedAt > PROBE_TTL_MS) bgRefreshRunning(); + return runningCache.value; +} + +// Async strict probe for hot user-initiated paths (enable/connect flow). +// Awaitable, never blocks event loop; updates cache as a side effect. +export async function isTailscaleRunningStrict() { + const bin = getTailscaleBin(); + if (!bin) return false; + try { + const { stdout } = await execAsync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel status --json`, { + windowsHide: true, + timeout: PROBE_TIMEOUT_MS, + }); + const json = JSON.parse(stdout); + const running = Object.keys(json.AllowFunnel || {}).length > 0; + runningCache.value = running; + runningCache.fetchedAt = Date.now(); + return running; + } catch { + return false; + } +} + +// Check if a system-level tailscaled is running (uses system socket, not 9Router's custom one). +export function isSystemDaemonRunning() { + if (IS_WINDOWS || !SYSTEM_TAILSCALE_SOCKET || !fs.existsSync(SYSTEM_TAILSCALE_SOCKET)) return false; + const bin = getTailscaleBin(); + if (!bin) return false; + try { + const out = execSync(`"${bin}" ${SYSTEM_SOCKET_FLAG.join(" ")} status --json`, { + encoding: "utf8", windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH }, timeout: PROBE_TIMEOUT_MS, + }); + return JSON.parse(out).BackendState === "Running"; + } catch { + return false; + } +} + +function bgRefreshFunnelUrl(port) { + if (funnelUrlCache.refreshing) return; + const bin = getTailscaleBin(); + if (!bin) return; + funnelUrlCache.refreshing = true; + execAsync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { windowsHide: true, timeout: PROBE_TIMEOUT_MS }) + .then(({ stdout }) => { + try { + const json = JSON.parse(stdout); + const dnsName = json.Self?.DNSName?.replace(/\.$/, ""); + funnelUrlCache.value = dnsName ? `https://${dnsName}` : null; + } catch { /* keep prev */ } + }) + .catch(() => { /* keep prev */ }) + .finally(() => { + funnelUrlCache.port = port; + funnelUrlCache.fetchedAt = Date.now(); + funnelUrlCache.refreshing = false; + }); +} + +/** Get actual funnel URL from Self.DNSName (sync, authoritative — avoids hostname-conflict suffix). */ +function getActualFunnelUrl() { + const bin = getTailscaleBin(); + if (!bin) return null; + try { + const out = execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { + encoding: "utf8", + windowsHide: true, + env: { ...process.env, PATH: EXTENDED_PATH }, + timeout: 5000, + }); + const json = JSON.parse(out); + const dnsName = json.Self?.DNSName?.replace(/\.$/, ""); + return dnsName ? `https://${dnsName}` : null; + } catch { return null; } +} + +/** Get funnel URL from tailscale status (cached, non-blocking) */ +export function getTailscaleFunnelUrl(port) { + if (Date.now() - funnelUrlCache.fetchedAt > PROBE_TTL_MS || funnelUrlCache.port !== port) { + bgRefreshFunnelUrl(port); + } + return funnelUrlCache.value; +} + +/** + * Install tailscale. + * - macOS + brew: brew install tailscale (no sudo needed) + * - macOS no brew: download .pkg then sudo installer -pkg + * - Linux: fetch install.sh, pipe to sudo -S sh via stdin + * - Windows: download MSI via UAC-elevated PowerShell + */ +export async function installTailscale(sudoPassword, hostname, onProgress) { + const log = onProgress || (() => {}); + if (IS_WINDOWS) { + await installTailscaleWindows(log); + return { success: true }; + } + if (IS_MAC) await installTailscaleMac(sudoPassword, log); + else await installTailscaleLinux(sudoPassword, log); + + log("Starting daemon..."); + await startDaemonWithPassword(sudoPassword); + log("Logging in..."); + return startLogin(hostname); +} + +const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/sbin:/usr/bin:/bin:/snap/bin:${process.env.PATH || ""}`; + +function hasBrew() { + try { execSync("which brew", { stdio: "ignore", windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH } }); return true; } catch { return false; } +} + +async function installTailscaleMac(sudoPassword, log) { + if (hasBrew()) { + log("Installing via Homebrew..."); + await new Promise((resolve, reject) => { + const child = spawn("brew", ["install", "tailscale"], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + env: { ...process.env, PATH: EXTENDED_PATH } + }); + child.stdout.on("data", (d) => { + const line = d.toString().trim(); + if (line) log(line); + }); + child.stderr.on("data", (d) => { + const line = d.toString().trim(); + if (line) log(line); + }); + child.on("close", (c) => { + if (c === 0) resolve(); + else reject(new Error(`brew install failed (code ${c})`)); + }); + child.on("error", reject); + }); + return; + } + + // No brew: download .pkg and install via sudo installer + const pkgUrl = "https://pkgs.tailscale.com/stable/tailscale-latest.pkg"; + const pkgPath = path.join(os.tmpdir(), "tailscale.pkg"); + + log("Downloading Tailscale package..."); + await new Promise((resolve, reject) => { + const child = spawn("curl", ["-fL", "--progress-bar", pkgUrl, "-o", pkgPath], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true + }); + child.stderr.on("data", (d) => { + const line = d.toString().trim(); + if (line) log(line); + }); + child.on("close", (c) => { + if (c === 0) resolve(); + else reject(new Error("Download failed")); + }); + child.on("error", reject); + }); + + log("Installing package..."); + await new Promise((resolve, reject) => { + const child = spawn("sudo", ["-S", "installer", "-pkg", pkgPath, "-target", "/"], { + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true + }); + let stderr = ""; + child.stderr.on("data", (d) => { stderr += d.toString(); }); + child.stdout.on("data", (d) => { + const line = d.toString().trim(); + if (line) log(line); + }); + child.on("close", (c) => { + try { execSync(`rm -f ${pkgPath}`, { stdio: "ignore", windowsHide: true }); } catch { /* ignore */ } + if (c === 0) resolve(); + else { + const msg = (stderr.includes("incorrect password") || stderr.includes("Sorry")) + ? "Wrong sudo password" + : stderr || `Exit code ${c}`; + reject(new Error(msg)); + } + }); + child.on("error", reject); + child.stdin.write(`${sudoPassword}\n`); + child.stdin.end(); + }); +} + +async function installTailscaleLinux(sudoPassword, log) { + // Reject password containing newline → prevents stdin command injection + if (typeof sudoPassword !== "string" || sudoPassword.includes("\n")) { + throw new Error("Invalid sudo password"); + } + log("Downloading install script..."); + return new Promise((resolve, reject) => { + const curlChild = spawn("curl", ["-fsSL", "https://tailscale.com/install.sh"], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true + }); + let scriptContent = ""; + let curlErr = ""; + curlChild.stdout.on("data", (d) => { scriptContent += d.toString(); }); + curlChild.stderr.on("data", (d) => { curlErr += d.toString(); }); + curlChild.on("exit", (code) => { + if (code !== 0) return reject(new Error(`Failed to download install script: ${curlErr}`)); + log("Running install script..."); + // Persist script to temp file → exec by path (NOT via stdin) → sh never reads attacker-controlled stdin + const tmpScript = path.join(os.tmpdir(), `tailscale-install-${crypto.randomBytes(8).toString("hex")}.sh`); + try { + fs.writeFileSync(tmpScript, scriptContent, { mode: 0o700 }); + } catch (e) { + return reject(new Error(`Failed to write install script: ${e.message}`)); + } + const cleanup = () => { try { fs.unlinkSync(tmpScript); } catch {} }; + const child = spawn("sudo", ["-S", "sh", tmpScript], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true }); + let stderr = ""; + child.stdout.on("data", (d) => { + const line = d.toString().trim(); + if (line) log(line); + }); + child.stderr.on("data", (d) => { stderr += d.toString(); }); + child.on("close", (c) => { + cleanup(); + if (c === 0) resolve(); + else { + const msg = (stderr.includes("incorrect password") || stderr.includes("Sorry")) + ? "Wrong sudo password" + : stderr || `Exit code ${c}`; + reject(new Error(msg)); + } + }); + child.on("error", (e) => { cleanup(); reject(e); }); + child.stdin.write(`${sudoPassword}\n`); + child.stdin.end(); + }); + curlChild.on("error", reject); + }); +} + +async function installTailscaleWindows(log) { + const msiUrl = "https://pkgs.tailscale.com/stable/tailscale-setup-latest-amd64.msi"; + const msiPath = path.join(os.tmpdir(), "tailscale-setup.msi"); + + // Download MSI via curl.exe (built-in on Win10+) — no PowerShell window, streams progress + log("Downloading Tailscale installer..."); + await new Promise((resolve, reject) => { + const child = spawn("curl.exe", ["-L", "-#", "-o", msiPath, msiUrl], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true + }); + // curl outputs progress to stderr with -# flag + let lastPct = ""; + child.stderr.on("data", (d) => { + const text = d.toString(); + const match = text.match(/(\d+\.\d)%/); + if (match && match[1] !== lastPct) { + lastPct = match[1]; + log(`Downloading... ${lastPct}%`); + } + }); + child.on("close", (c) => c === 0 ? resolve() : reject(new Error("Download failed"))); + child.on("error", reject); + }); + + // Install MSI with UAC elevation via PowerShell Start-Process -Verb RunAs + log("Installing Tailscale (UAC prompt may appear)..."); + await new Promise((resolve, reject) => { + const args = `'/i','${msiPath}','TS_NOLAUNCH=true','/quiet','/norestart'`; + const child = spawn("powershell", [ + "-NoProfile", "-NonInteractive", "-Command", + `Start-Process msiexec -ArgumentList ${args} -Verb RunAs -Wait` + ], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }); + child.stderr.on("data", (d) => { const l = d.toString().trim(); if (l) log(l); }); + child.on("close", (c) => { + try { fs.unlinkSync(msiPath); } catch { /* ignore */ } + c === 0 ? resolve() : reject(new Error(`msiexec failed (code ${c})`)); + }); + child.on("error", reject); + }); + + // Verify tailscale.exe exists after install + log("Verifying installation..."); + const maxWait = 10000; + const start = Date.now(); + while (Date.now() - start < maxWait) { + if (fs.existsSync(WINDOWS_TAILSCALE_BIN)) { + log("Installation complete."); + return; + } + await new Promise((r) => setTimeout(r, 1000)); + } + throw new Error("Installation finished but tailscale.exe not found"); +} + +// Self-heal: if state dir/files were previously created by root (e.g. legacy sudo daemon), +// reclaim ownership recursively so the user-mode daemon can read/write state files. +async function ensureUserOwnedDir(dir) { + try { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + return; + } + const uid = process.getuid(); + const gid = process.getgid(); + + // Walk dir + all entries to find any non-user-owned items + const needsChown = (() => { + const stack = [dir]; + while (stack.length) { + const cur = stack.pop(); + try { + const st = fs.statSync(cur); + if (st.uid !== uid) return true; + if (st.isDirectory()) { + for (const name of fs.readdirSync(cur)) stack.push(path.join(cur, name)); + } + } catch { /* ignore */ } + } + return false; + })(); + + if (!needsChown) return; + + // Try direct chown first (works if already owned). Fallback to passwordless sudo. + try { + execSync(`chown -R ${uid}:${gid} "${dir}"`, { stdio: "ignore", timeout: 3000 }); + } catch { + try { execSync(`sudo -n chown -R ${uid}:${gid} "${dir}"`, { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ } + } + } catch { /* ignore */ } +} + +/** Check if running daemon uses TUN mode (Funnel TLS requires TUN). */ +function isDaemonTunMode() { + try { + const ps = execSync(`pgrep -af "tailscaled.*${TAILSCALE_SOCKET}"`, { encoding: "utf8", timeout: 2000 }).trim(); + if (!ps) return null; + return !ps.includes("--tun=userspace-networking"); + } catch { return null; } +} + +/** Daemon process alive (independent of funnel state) — mirrors cloudflared PID check semantic. */ +export function isDaemonAlive() { + return isDaemonTunMode() !== null; +} + +/** + * Start tailscaled. + * - With sudoPassword: TUN mode (root) → Funnel TLS works + * - Without: userspace-networking fallback (no sudo, but Funnel TLS unstable) + * State always lives in ~/.9router/tailscale/ via --statedir. + */ +export async function startDaemonWithPassword(sudoPassword) { + if (IS_WINDOWS) { + // Windows: tailscale runs as a Windows Service. Start it then poll BackendState + // until daemon finishes init (avoids "NoState" errors when calling funnel/up too early). + const bin = getTailscaleBin(); + console.log("[Tailscale] win: net start Tailscale"); + try { execSync("net start Tailscale", { stdio: "ignore", windowsHide: true, timeout: 10000 }); } + catch { /* may need admin, or already running */ } + if (!bin) return; + // Poll up to ~10s for backend to leave NoState + for (let i = 0; i < 20; i++) { + try { + const out = execSync(`"${bin}" status --json`, { encoding: "utf8", windowsHide: true, timeout: 2000 }); + const j = JSON.parse(out); + if (j.BackendState && j.BackendState !== "NoState") { + console.log(`[Tailscale] win: BackendState=${j.BackendState} after ${i*500}ms`); + return; + } + } catch { /* daemon not ready */ } + await new Promise((r) => setTimeout(r, 500)); + } + console.log("[Tailscale] win: BackendState still NoState after poll"); + return; + } + + const currentMode = isDaemonTunMode(); // true=TUN, false=userspace, null=not running + // No password but a healthy TUN daemon already runs → keep TUN, never downgrade-kill it. + const wantTun = sudoPassword ? true : currentMode === true; + + // Daemon already running in correct mode → reuse + if (currentMode !== null && currentMode === wantTun) { + try { + const bin = getTailscaleBin() || "tailscale"; + execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { + stdio: "ignore", windowsHide: true, + env: { ...process.env, PATH: EXTENDED_PATH }, timeout: 3000 + }); + return; + } catch { /* unresponsive, restart below */ } + } + + // Mode mismatch or unresponsive → kill all daemons on our socket + try { execSync(`pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ } + if (sudoPassword) { + try { await execWithPassword(`pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, sudoPassword); } catch { /* ignore */ } + } else { + try { execSync(`sudo -n pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ } + } + await new Promise((r) => setTimeout(r, 1500)); + + // Reclaim folder ownership (previous root daemon may have locked it) + await ensureUserOwnedDir(TAILSCALE_DIR); + + const tailscaledBin = IS_MAC ? "/usr/local/bin/tailscaled" : "tailscaled"; + const daemonArgs = [ + `--socket=${TAILSCALE_SOCKET}`, + `--statedir=${TAILSCALE_DIR}`, + ]; + if (!wantTun) daemonArgs.push("--tun=userspace-networking"); + + if (wantTun) { + // TUN mode: spawn via sudo, password via stdin. Detached so it survives parent exit. + const child = spawn("sudo", ["-S", tailscaledBin, ...daemonArgs], { + detached: true, + stdio: ["pipe", "ignore", "ignore"], + cwd: os.tmpdir(), + env: { ...process.env, PATH: EXTENDED_PATH }, + }); + child.stdin.write(`${sudoPassword}\n`); + child.stdin.end(); + child.unref(); + } else { + const child = spawn(tailscaledBin, daemonArgs, { + detached: true, + stdio: "ignore", + cwd: os.tmpdir(), + env: { ...process.env, PATH: EXTENDED_PATH }, + }); + child.unref(); + } + + // Wait for socket ready + await new Promise((r) => setTimeout(r, 3000)); +} + +/** Best-effort: ensure daemon running (used for login flow) */ +function ensureDaemon() { + startDaemonWithPassword("").catch(() => {}); +} + +/** Read AuthURL from `tailscale status --json` (Win exposes it there, not stdout). */ +function getAuthUrlFromStatus() { + const bin = getTailscaleBin(); + if (!bin) return null; + try { + const out = execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { + encoding: "utf8", windowsHide: true, timeout: 2000 + }); + const j = JSON.parse(out); + if (j.AuthURL) return j.AuthURL; + return null; + } catch { return null; } +} + +/** + * Run `tailscale up` and capture the auth URL for browser login. + * Resolves with { authUrl } or { alreadyLoggedIn: true }. + * On Windows, AuthURL comes from `status --json` (not stdout) — must poll status. + */ +export function startLogin(hostname) { + const bin = getTailscaleBin(); + if (!bin) return Promise.reject(new Error("Tailscale not installed")); + + return new Promise((resolve, reject) => { + // Ensure daemon is running (best-effort, no sudo) + ensureDaemon(); + + // Check if already logged in + if (isTailscaleLoggedIn()) { + resolve({ alreadyLoggedIn: true }); + return; + } + + const args = tsArgs("up", "--accept-routes"); + if (hostname) args.push(`--hostname=${hostname}`); + const child = spawn(bin, args, { + stdio: ["ignore", "pipe", "pipe"], + detached: true, + windowsHide: true + }); + + let resolved = false; + let output = ""; + + const parseAuthUrl = (text) => { + const match = text.match(/https:\/\/login\.tailscale\.com\/a\/[a-zA-Z0-9]+/); + return match ? match[0] : null; + }; + + const finishWithUrl = (url, source) => { + if (resolved) return; + resolved = true; + clearTimeout(timeout); + clearInterval(statusPoll); + console.log(`[Tailscale] login authUrl detected (${source})`); + child.unref(); + resolve({ authUrl: url }); + }; + + // Poll status --json every 500ms — Windows exposes AuthURL only there + const statusPoll = setInterval(() => { + if (resolved) return; + const url = getAuthUrlFromStatus(); + if (url) finishWithUrl(url, "status"); + }, 500); + + const timeout = setTimeout(() => { + if (resolved) return; + resolved = true; + clearInterval(statusPoll); + child.unref(); + const url = parseAuthUrl(output) || getAuthUrlFromStatus(); + if (url) resolve({ authUrl: url }); + else reject(new Error("tailscale up timed out without auth URL")); + }, 15000); + + const handleData = (data) => { + output += data.toString(); + const url = parseAuthUrl(output); + if (url) finishWithUrl(url, "stdout"); + }; + + child.stdout.on("data", handleData); + child.stderr.on("data", handleData); + + child.on("error", (err) => { + if (resolved) return; + resolved = true; + clearTimeout(timeout); + clearInterval(statusPoll); + console.error(`[Tailscale] login spawn error: ${err.message}`); + reject(err); + }); + + child.on("exit", (code) => { + if (resolved) return; + console.log(`[Tailscale] login exit code=${code}`); + // Don't trust exit code alone — Win `tailscale up` exits 0 even when not logged in. + // Let status poll continue until AuthURL appears or timeout. + const url = parseAuthUrl(output) || getAuthUrlFromStatus(); + if (url) { + finishWithUrl(url, "exit"); + return; + } + // Only resolve alreadyLoggedIn if status confirms BackendState=Running + if (isTailscaleLoggedIn()) { + resolved = true; + clearTimeout(timeout); + clearInterval(statusPoll); + resolve({ alreadyLoggedIn: true }); + return; + } + // Otherwise keep polling — daemon may publish AuthURL shortly after exit + }); + }); +} + +/** Start tailscale funnel for the given port */ +export async function startFunnel(port) { + const bin = getTailscaleBin(); + if (!bin) throw new Error("Tailscale not installed"); + + // Reset any existing funnel + try { execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel --bg reset`, { stdio: "ignore", windowsHide: true }); } catch (e) { /* ignore */ } + + return new Promise((resolve, reject) => { + const child = spawn(bin, tsArgs("funnel", "--bg", `${port}`), { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true + }); + + let resolved = false; + let output = ""; + + const timeout = setTimeout(() => { + if (resolved) return; + resolved = true; + // --bg exits after setup, read actual hostname from status + const url = getActualFunnelUrl() || getTailscaleFunnelUrl(port); + if (url) resolve({ tunnelUrl: url }); + else reject(new Error(`Tailscale funnel timed out: ${output.trim() || "no output"}`)); + }, 30000); + + // Always resolve via Self.DNSName to get the real hostname (avoids -1 suffix from conflicts) + const parseFunnelUrl = () => getActualFunnelUrl(); + + let funnelNotEnabled = false; + + const handleData = (data) => { + output += data.toString(); + + if (output.includes("Funnel is not enabled")) funnelNotEnabled = true; + + // Wait for the enable URL to arrive in a later chunk + if (funnelNotEnabled && !resolved) { + const enableMatch = output.match(/https:\/\/login\.tailscale\.com\/[^\s]+/); + if (enableMatch) { + resolved = true; + clearTimeout(timeout); + child.kill(); + resolve({ funnelNotEnabled: true, enableUrl: enableMatch[0] }); + return; + } + } + + const url = parseFunnelUrl(); + if (url && !resolved) { + resolved = true; + clearTimeout(timeout); + resolve({ tunnelUrl: url }); + } + }; + + child.stdout.on("data", handleData); + child.stderr.on("data", handleData); + + child.on("exit", (code) => { + if (resolved) return; + resolved = true; + clearTimeout(timeout); + console.log(`[Tailscale] funnel exit code=${code} output="${output.trim().slice(0, 200)}"`); + const url = parseFunnelUrl() || getTailscaleFunnelUrl(port); + if (url) resolve({ tunnelUrl: url }); + else reject(new Error(`tailscale funnel failed (code ${code}): ${output.trim()}`)); + }); + + child.on("error", (err) => { + if (resolved) return; + resolved = true; + clearTimeout(timeout); + reject(err); + }); + }); +} + +/** Provision TLS cert for funnel domain (required before Funnel serves HTTPS). Best-effort. */ +export async function provisionCert(hostname) { + const bin = getTailscaleBin(); + if (!bin || !hostname) return; + const certsDir = path.join(TAILSCALE_DIR, "certs"); + fs.mkdirSync(certsDir, { recursive: true }); + const certFile = path.join(certsDir, `${hostname}.crt`); + const keyFile = path.join(certsDir, `${hostname}.key`); + try { + await execAsync( + `"${bin}" ${SOCKET_FLAG.join(" ")} cert --cert-file "${certFile}" --key-file "${keyFile}" "${hostname}"`, + { windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH }, timeout: 30000 } + ); + console.log(`[Tailscale] cert provisioned for ${hostname}`); + } catch (e) { + console.warn(`[Tailscale] cert provision failed (non-fatal): ${e.message}`); + } +} + +/** Stop tailscale funnel */ +export function stopFunnel() { + const bin = getTailscaleBin(); + if (!bin) return; + try { execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel --bg reset`, { stdio: "ignore", windowsHide: true }); } catch (e) { /* ignore */ } +} + +/** Kill tailscaled daemon (runs as root, needs sudo) */ +export async function stopDaemon(sudoPassword) { + // Try non-sudo first + try { execSync("pkill -x tailscaled", { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { /* ignore */ } + + // Check if still alive + try { execSync("pgrep -x tailscaled", { stdio: "ignore", windowsHide: true, timeout: 2000 }); } catch { return; } // Dead, done + + // Kill with sudo password + if (!IS_WINDOWS) { + try { await execWithPassword("pkill -x tailscaled", sudoPassword || ""); } catch { /* ignore */ } + } + + // Cleanup socket + try { if (fs.existsSync(TAILSCALE_SOCKET)) fs.unlinkSync(TAILSCALE_SOCKET); } catch { /* ignore */ } +} diff --git a/src/lib/updater/updater.js b/src/lib/updater/updater.js new file mode 100644 index 0000000..0ad48af --- /dev/null +++ b/src/lib/updater/updater.js @@ -0,0 +1,235 @@ +// Standalone detached updater process. +// Spawns `npm i -g @latest`, exposes progress via tiny HTTP server. +// Survives after parent Next server exits (detached + unref by spawner). + +const { spawn } = require("child_process"); +const http = require("http"); +const net = require("net"); +const path = require("path"); +const fs = require("fs"); +const os = require("os"); + +const packageName = process.env.UPDATER_PKG_NAME || "9router"; +const port = parseInt(process.env.UPDATER_PORT || "20129", 10); +const tailLines = parseInt(process.env.UPDATER_TAIL_LINES || "8", 10); +const maxRetries = parseInt(process.env.UPDATER_RETRIES || "3", 10); +const retryDelayMs = parseInt(process.env.UPDATER_RETRY_DELAY_MS || "5000", 10); +const lingerMs = parseInt(process.env.UPDATER_LINGER_MS || "30000", 10); +const waitMinMs = parseInt(process.env.UPDATER_WAIT_MIN_MS || "3000", 10); +const waitMaxMs = parseInt(process.env.UPDATER_WAIT_MAX_MS || "15000", 10); +const waitCheckMs = parseInt(process.env.UPDATER_WAIT_CHECK_MS || "500", 10); +const appPort = parseInt(process.env.UPDATER_APP_PORT || "20128", 10); + +// Data directory (match mitm/paths.js logic) +function getDataDir() { + if (process.env.DATA_DIR) return process.env.DATA_DIR; + if (process.platform === "win32") { + return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "9router"); + } + return path.join(os.homedir(), ".9router"); +} +const updateDir = path.join(getDataDir(), "update"); +try { fs.mkdirSync(updateDir, { recursive: true }); } catch { /* best effort */ } +const statusFile = path.join(updateDir, "status.json"); +const logFile = path.join(updateDir, "install.log"); + +const state = { + phase: "starting", + packageName, + startedAt: Date.now(), + finishedAt: null, + attempt: 0, + maxRetries, + done: false, + success: false, + exitCode: null, + error: null, + logTail: [], +}; + +function pushLog(line) { + const trimmed = line.replace(/\r?\n$/, ""); + if (!trimmed) return; + state.logTail.push(trimmed); + if (state.logTail.length > tailLines) state.logTail = state.logTail.slice(-tailLines); + try { fs.appendFileSync(logFile, `${trimmed}\n`); } catch { /* best effort */ } +} + +function persistStatus() { + try { fs.writeFileSync(statusFile, JSON.stringify(state, null, 2)); } catch { /* best effort */ } +} + +function setPhase(phase) { + state.phase = phase; + persistStatus(); +} + +// HTTP server exposing status (browser polls this while Next server is dead) +const server = http.createServer((req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Cache-Control", "no-store"); + if (req.url === "/update/status" || req.url === "/") { + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(state)); + return; + } + res.statusCode = 404; + res.end("not found"); +}); + +server.on("error", (e) => { + state.error = `status server error: ${e.message}`; + persistStatus(); +}); + +server.listen(port, "127.0.0.1", () => { + persistStatus(); + waitForAppExit().then(runInstall); +}); + +// Check if app port is still being listened on (= app server still alive) +function isAppPortBusy() { + return new Promise((resolve) => { + const socket = new net.Socket(); + const done = (busy) => { + socket.destroy(); + resolve(busy); + }; + socket.setTimeout(300); + socket.once("connect", () => done(true)); + socket.once("timeout", () => done(false)); + socket.once("error", () => done(false)); + socket.connect(appPort, "127.0.0.1"); + }); +} + +// Wait for app process to fully exit before running npm (avoids Windows file-lock) +async function waitForAppExit() { + setPhase("waitingForExit"); + pushLog(`[updater] waiting for app to exit (min ${Math.round(waitMinMs / 1000)}s)...`); + + // Hard minimum delay: OS needs time to release file handles + await sleep(waitMinMs); + + // Poll app port until free or max timeout + const deadline = Date.now() + (waitMaxMs - waitMinMs); + while (Date.now() < deadline) { + const busy = await isAppPortBusy(); + if (!busy) { + pushLog(`[updater] app port :${appPort} is free, proceeding`); + return; + } + await sleep(waitCheckMs); + } + pushLog(`[updater] timeout waiting for app, proceeding anyway`); +} + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +function runInstall() { + state.attempt += 1; + setPhase("installing"); + pushLog(`[updater] attempt ${state.attempt}/${maxRetries} — npm i -g ${packageName} --prefer-online`); + + const isWin = process.platform === "win32"; + const cmd = isWin ? "npm.cmd" : "npm"; + const args = ["i", "-g", packageName, "--prefer-online"]; + + const child = spawn(cmd, args, { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + shell: isWin, + }); + + child.stdout.on("data", (buf) => { + buf.toString().split(/\r?\n/).forEach(pushLog); + persistStatus(); + }); + child.stderr.on("data", (buf) => { + buf.toString().split(/\r?\n/).forEach(pushLog); + persistStatus(); + }); + + child.on("error", (e) => { + pushLog(`[updater] spawn error: ${e.message}`); + finalize(false, null, e.message); + }); + + child.on("close", (code) => { + pushLog(`[updater] npm exited with code ${code}`); + if (code === 0) { + finalize(true, code, null); + return; + } + if (state.attempt < maxRetries) { + pushLog(`[updater] retrying in ${Math.round(retryDelayMs / 1000)}s...`); + setTimeout(runInstall, retryDelayMs); + return; + } + finalize(false, code, `Install failed after ${maxRetries} attempts`); + }); +} + +function openBrowser(url) { + const platform = process.platform; + const cmd = platform === "darwin" ? `open "${url}"` + : platform === "win32" ? `start "" "${url}"` + : `xdg-open "${url}"`; + try { spawn(cmd, { shell: true, detached: true, stdio: "ignore" }).unref(); } catch { /* ignore */ } +} + +// Wait until app port is listening (server alive again), then open dashboard +async function waitForAppAndOpenBrowser() { + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + const busy = await isAppPortBusy(); + if (busy) { + openBrowser(`http://localhost:${appPort}/dashboard`); + pushLog(`[updater] app ready, opened dashboard`); + return; + } + await sleep(1000); + } + pushLog(`[updater] app not responding within 30s, skip browser open`); +} + +function relaunchApp() { + if (process.env.UPDATER_RELAUNCH !== "1") return; + const cmd = process.env.UPDATER_RELAUNCH_CMD; + if (!cmd) return; + let args = []; + try { args = JSON.parse(process.env.UPDATER_RELAUNCH_ARGS || "[]"); } catch { /* noop */ } + const isWin = process.platform === "win32"; + try { + const child = spawn(cmd, args, { + detached: true, + stdio: "ignore", + windowsHide: true, + shell: isWin, + env: { ...process.env, UPDATER_RELAUNCH: "", UPDATER_RELAUNCH_CMD: "", UPDATER_RELAUNCH_ARGS: "" }, + }); + child.unref(); + pushLog(`[updater] relaunched: ${cmd} ${args.join(" ")} (pid=${child.pid})`); + // Wait for new app to come up, then auto-open browser so user sees the result + waitForAppAndOpenBrowser(); + } catch (e) { + pushLog(`[updater] relaunch failed: ${e.message}`); + } +} + +function finalize(success, exitCode, error) { + state.done = true; + state.success = success; + state.exitCode = exitCode; + state.error = error; + state.finishedAt = Date.now(); + setPhase(success ? "done" : "error"); + if (success) relaunchApp(); + // Linger so browser can poll final status, then exit & close the port + setTimeout(() => { + try { server.close(); } catch { /* ignore */ } + process.exit(success ? 0 : 1); + }, lingerMs); +} diff --git a/src/lib/usageDb.js b/src/lib/usageDb.js new file mode 100644 index 0000000..d56d58a --- /dev/null +++ b/src/lib/usageDb.js @@ -0,0 +1,7 @@ +// Shim → re-export from new SQLite-based DB layer (src/lib/db/) +export { + statsEmitter, trackPendingRequest, getActiveRequests, + saveRequestUsage, getUsageHistory, getUsageStats, getChartData, + appendRequestLog, getRecentLogs, + saveRequestDetail, getRequestDetails, getRequestDetailById, +} from "@/lib/db/index.js"; diff --git a/src/mitm/antigravityIdeVersion.js b/src/mitm/antigravityIdeVersion.js new file mode 100644 index 0000000..1b9458d --- /dev/null +++ b/src/mitm/antigravityIdeVersion.js @@ -0,0 +1,50 @@ +"use strict"; + +// Rewrite Antigravity IDE markers so upstream AG 2.x backend accepts the request. +// User-Agent header (antigravity/) and body.metadata.ideVersion are forced +// to a known-good IDE version. Hardcoded MVP — toggle/version configurable later. + +const ANTIGRAVITY_IDE_VERSION = "1.23.2"; +const ANTIGRAVITY_IDE_VERSION_OVERRIDE_ENABLED = true; + +function shouldRewriteMetadata(metadata) { + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return false; + if (String(metadata.ideName || "").toLowerCase() === "antigravity") return true; + if (String(metadata.ideType || "").toUpperCase() === "ANTIGRAVITY") return true; + return Object.prototype.hasOwnProperty.call(metadata, "ideVersion"); +} + +function rewriteAntigravityUserAgent(userAgent, version) { + if (typeof userAgent !== "string" || !userAgent.includes("antigravity/")) return userAgent; + return userAgent.replace(/antigravity\/[^\s]+/, `antigravity/${version}`); +} + +function applyAntigravityIdeVersionOverride(bodyBuffer, headers) { + if (!ANTIGRAVITY_IDE_VERSION_OVERRIDE_ENABLED) { + return { bodyBuffer, headers, applied: false, version: ANTIGRAVITY_IDE_VERSION }; + } + + const nextHeaders = { ...headers }; + const nextUserAgent = rewriteAntigravityUserAgent(nextHeaders["user-agent"], ANTIGRAVITY_IDE_VERSION); + const userAgentChanged = nextUserAgent !== nextHeaders["user-agent"]; + if (userAgentChanged) nextHeaders["user-agent"] = nextUserAgent; + + try { + const parsed = JSON.parse(bodyBuffer.toString()); + if (!shouldRewriteMetadata(parsed?.metadata)) { + return { bodyBuffer, headers: nextHeaders, applied: userAgentChanged, version: ANTIGRAVITY_IDE_VERSION }; + } + + parsed.metadata.ideVersion = ANTIGRAVITY_IDE_VERSION; + const nextBodyBuffer = Buffer.from(JSON.stringify(parsed)); + return { bodyBuffer: nextBodyBuffer, headers: nextHeaders, applied: true, version: ANTIGRAVITY_IDE_VERSION }; + } catch { + return { bodyBuffer, headers: nextHeaders, applied: userAgentChanged, version: ANTIGRAVITY_IDE_VERSION }; + } +} + +module.exports = { + ANTIGRAVITY_IDE_VERSION, + applyAntigravityIdeVersionOverride, + rewriteAntigravityUserAgent, +}; diff --git a/src/mitm/cert/generate.js b/src/mitm/cert/generate.js new file mode 100644 index 0000000..e85f9dc --- /dev/null +++ b/src/mitm/cert/generate.js @@ -0,0 +1,32 @@ +const path = require("path"); +const fs = require("fs"); +const { MITM_DIR } = require("../paths"); +const { generateRootCA, loadRootCA, generateLeafCert } = require("./rootCA"); + +/** + * Generate Root CA certificate (one-time setup) + * This replaces the old static wildcard cert approach + */ +function generateCert() { + return generateRootCA(); +} + +/** + * Get certificate for a specific domain (dynamic generation) + * Used by SNICallback in server.js + */ +function getCertForDomain(domain) { + try { + const rootCA = loadRootCA(); + const leafCert = generateLeafCert(domain, rootCA); + return { + key: leafCert.key, + cert: leafCert.cert + }; + } catch (error) { + console.error(`Failed to generate cert for ${domain}:`, error.message); + return null; + } +} + +module.exports = { generateCert, getCertForDomain }; diff --git a/src/mitm/cert/install.js b/src/mitm/cert/install.js new file mode 100644 index 0000000..f8f8fe7 --- /dev/null +++ b/src/mitm/cert/install.js @@ -0,0 +1,269 @@ +const fs = require("fs"); +const crypto = require("crypto"); +const { exec } = require("child_process"); +const { execWithPassword, isSudoAvailable } = require("../dns/dnsConfig.js"); +const { runElevatedPowerShell, quotePs } = require("../winElevated.js"); +const { log, err } = require("../logger"); + +const IS_WIN = process.platform === "win32"; +const IS_MAC = process.platform === "darwin"; +const LINUX_CERT_PATHS = [ + // Debian / Ubuntu + { dir: "/usr/local/share/ca-certificates", cmd: "update-ca-certificates" }, + // Arch Linux / CachyOS / Manjaro + { dir: "/etc/ca-certificates/trust-source/anchors", cmd: "update-ca-trust" }, + // Fedora / RHEL / CentOS + { dir: "/etc/pki/ca-trust/source/anchors", cmd: "update-ca-trust" }, + // openSUSE + { dir: "/etc/pki/trust/anchors", cmd: "update-ca-certificates" } +]; + +function getLinuxCertConfig() { + for (const config of LINUX_CERT_PATHS) { + if (fs.existsSync(config.dir)) { + return config; + } + } + // Fallback to Debian default if none exist + return LINUX_CERT_PATHS[0]; +} +const ROOT_CA_CN = "9Router MITM Root CA"; + +// Get SHA1 fingerprint from cert file using Node.js crypto +function getCertFingerprint(certPath) { + const pem = fs.readFileSync(certPath, "utf-8"); + const der = Buffer.from(pem.replace(/-----[^-]+-----/g, "").replace(/\s/g, ""), "base64"); + return crypto.createHash("sha1").update(der).digest("hex").toUpperCase().match(/.{2}/g).join(":"); +} + +/** + * Check if certificate is already installed in system store + */ +async function checkCertInstalled(certPath) { + if (IS_WIN) return checkCertInstalledWindows(certPath); + if (IS_MAC) return checkCertInstalledMac(certPath); + return checkCertInstalledLinux(); +} + +function checkCertInstalledMac(certPath) { + return new Promise((resolve) => { + try { + const fingerprint = getCertFingerprint(certPath).replace(/:/g, ""); + // Verify exact cert bytes match — same CN with different fingerprint = stale cert + exec(`security find-certificate -a -c "${ROOT_CA_CN}" -Z /Library/Keychains/System.keychain 2>/dev/null`, { windowsHide: true }, (error, stdout) => { + if (error || !stdout) return resolve(false); + const match = new RegExp(`SHA-1 hash:\\s*${fingerprint}`, "i").test(stdout); + if (!match) return resolve(false); + // Cert exists with matching fingerprint — confirm trust policy + exec(`security verify-cert -c "${certPath}" -p ssl -k /Library/Keychains/System.keychain 2>/dev/null`, { windowsHide: true }, (err2) => { + resolve(!err2); + }); + }); + } catch { + resolve(false); + } + }); +} + +function checkCertInstalledWindows(certPath) { + return new Promise((resolve) => { + // Check by SHA1 fingerprint — detects stale cert with same CN but different key + let fingerprint; + try { + fingerprint = getCertFingerprint(certPath).replace(/:/g, ""); + } catch { + return resolve(false); + } + exec(`certutil -store Root ${fingerprint}`, { windowsHide: true }, (error) => { + resolve(!error); + }); + }); +} + +/** + * Install SSL certificate to system trust store + */ +async function installCert(sudoPassword, certPath) { + if (!fs.existsSync(certPath)) { + throw new Error(`Certificate file not found: ${certPath}`); + } + + const isInstalled = await checkCertInstalled(certPath); + if (isInstalled) { + log("🔐 Cert: already trusted ✅"); + return; + } + + if (IS_WIN) { + await installCertWindows(certPath); + } else if (IS_MAC) { + await installCertMac(sudoPassword, certPath); + } else { + await installCertLinux(sudoPassword, certPath); + } +} + +async function installCertMac(sudoPassword, certPath) { + // Remove all old certs with same name first to avoid duplicate/stale cert conflict + const deleteOld = `security delete-certificate -c "9Router MITM Root CA" /Library/Keychains/System.keychain 2>/dev/null || true`; + const install = `security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${certPath}"`; + try { + await execWithPassword(`${deleteOld} && ${install}`, sudoPassword); + log("🔐 Cert: ✅ installed to system keychain"); + } catch (error) { + const msg = error.message?.includes("canceled") ? "User canceled authorization" : "Certificate install failed"; + throw new Error(msg); + } +} + +async function installCertWindows(certPath) { + // Auto-elevate via UAC popup if not admin (zero popup if already admin). + // Delete any stale cert with same CN before adding to avoid duplicates. + const script = ` + certutil -delstore Root ${quotePs(ROOT_CA_CN)} 2>$null | Out-Null + $exit = & certutil -addstore Root ${quotePs(certPath)} 2>&1 + if ($LASTEXITCODE -ne 0) { throw "certutil exit $LASTEXITCODE" } + `; + try { + await runElevatedPowerShell(script); + log("🔐 Cert: ✅ installed to Windows Root store"); + } catch (e) { + throw new Error(`Failed to install certificate: ${e.message}`); + } +} + +/** + * Uninstall SSL certificate from system store + */ +async function uninstallCert(sudoPassword, certPath) { + const isInstalled = await checkCertInstalled(certPath); + if (!isInstalled) { + log("🔐 Cert: not found in system store"); + return; + } + + if (IS_WIN) { + await uninstallCertWindows(); + } else if (IS_MAC) { + await uninstallCertMac(sudoPassword, certPath); + } else { + await uninstallCertLinux(sudoPassword); + } +} + +async function uninstallCertMac(sudoPassword, certPath) { + const fingerprint = getCertFingerprint(certPath).replace(/:/g, ""); + const command = `security delete-certificate -Z "${fingerprint}" /Library/Keychains/System.keychain`; + try { + await execWithPassword(command, sudoPassword); + log("🔐 Cert: ✅ uninstalled from system keychain"); + } catch (err) { + throw new Error("Failed to uninstall certificate"); + } +} + +async function uninstallCertWindows() { + // Auto-elevate via UAC popup if not admin + const script = `certutil -delstore Root ${quotePs(ROOT_CA_CN)}`; + try { + await runElevatedPowerShell(script); + log("🔐 Cert: ✅ uninstalled from Windows Root store"); + } catch (e) { + throw new Error(`Failed to uninstall certificate: ${e.message}`); + } +} + +function checkCertInstalledLinux() { + const config = getLinuxCertConfig(); + const certFile = `${config.dir}/9router-root-ca.crt`; + return Promise.resolve(fs.existsSync(certFile)); +} + +async function updateNssDatabases(certPath, action = 'add') { + const certName = "9Router MITM Root CA"; + + const script = ` + if ! command -v certutil &> /dev/null; then + exit 0 + fi + + DIRS="$HOME/.pki/nssdb $HOME/snap/chromium/current/.pki/nssdb" + + if [ -d "$HOME/.mozilla/firefox" ]; then + for profile in "$HOME"/.mozilla/firefox/*/; do + if [ -f "\${profile}cert9.db" ] || [ -f "\${profile}cert8.db" ]; then + DIRS="$DIRS $profile" + fi + done + fi + + if [ -d "$HOME/snap/firefox/common/.mozilla/firefox" ]; then + for profile in "$HOME"/snap/firefox/common/.mozilla/firefox/*/; do + if [ -f "\${profile}cert9.db" ] || [ -f "\${profile}cert8.db" ]; then + DIRS="$DIRS $profile" + fi + done + fi + + for db in $DIRS; do + if [ -d "$db" ]; then + if [ "${action}" = "add" ]; then + certutil -d sql:"$db" -A -t "C,," -n "${certName}" -i "${certPath}" 2>/dev/null || \\ + certutil -d "$db" -A -t "C,," -n "${certName}" -i "${certPath}" 2>/dev/null || true + else + certutil -d sql:"$db" -D -n "${certName}" 2>/dev/null || \\ + certutil -d "$db" -D -n "${certName}" 2>/dev/null || true + fi + fi + done + `; + + return new Promise((resolve) => { + exec(script, { shell: "/bin/bash" }, () => resolve()); + }); +} + +async function installCertLinux(sudoPassword, certPath) { + if (!isSudoAvailable()) { + log(`🔐 Cert: cannot install to system store without sudo — trust this file on clients: ${certPath}`); + // Still try to update user NSS DBs even if no sudo! + await updateNssDatabases(certPath, 'add'); + return; + } + + const config = getLinuxCertConfig(); + const destFile = `${config.dir}/9router-root-ca.crt`; + + // Copy to the discovered directory and execute the specific update command + const cmd = `cp "${certPath}" "${destFile}" && (${config.cmd} 2>/dev/null || true)`; + + try { + await execWithPassword(cmd, sudoPassword); + await updateNssDatabases(certPath, 'add'); + log(`🔐 Cert: ✅ installed to Linux trust store (${config.dir}) and user browser databases`); + } catch (error) { + throw new Error(`Certificate install failed: ${error.message}`); + } +} + +async function uninstallCertLinux(sudoPassword) { + // Always try to uninstall from user DBs even without sudo + await updateNssDatabases(null, 'delete'); + + if (!isSudoAvailable()) { + return; + } + + const config = getLinuxCertConfig(); + const destFile = `${config.dir}/9router-root-ca.crt`; + const cmd = `rm -f "${destFile}" && (${config.cmd} 2>/dev/null || true)`; + + try { + await execWithPassword(cmd, sudoPassword); + log("🔐 Cert: ✅ uninstalled from Linux trust store and user browser databases"); + } catch (error) { + throw new Error("Failed to uninstall certificate"); + } +} + +module.exports = { installCert, uninstallCert, checkCertInstalled }; diff --git a/src/mitm/cert/rootCA.js b/src/mitm/cert/rootCA.js new file mode 100644 index 0000000..3f033f0 --- /dev/null +++ b/src/mitm/cert/rootCA.js @@ -0,0 +1,173 @@ +const path = require("path"); +const fs = require("fs"); +const forge = require("node-forge"); +const { MITM_DIR } = require("../paths"); + +const ROOT_CA_KEY_PATH = path.join(MITM_DIR, "rootCA.key"); +const ROOT_CA_CERT_PATH = path.join(MITM_DIR, "rootCA.crt"); + +/** + * Check if cert file is expired or expiring within 30 days + */ +function isCertExpired(certPath) { + try { + const cert = forge.pki.certificateFromPem(fs.readFileSync(certPath, "utf8")); + const expiryThreshold = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + return cert.validity.notAfter < expiryThreshold; + } catch { + return true; // treat unreadable cert as expired + } +} + +/** + * Generate Root CA certificate (only once, auto-regenerate if expired) + * This Root CA will sign all dynamic leaf certificates + */ +function generateRootCA() { + const exists = fs.existsSync(ROOT_CA_KEY_PATH) && fs.existsSync(ROOT_CA_CERT_PATH); + if (exists && !isCertExpired(ROOT_CA_CERT_PATH)) { + console.log("✅ Root CA already exists"); + return { key: ROOT_CA_KEY_PATH, cert: ROOT_CA_CERT_PATH }; + } + if (exists) { + console.log("🔐 Root CA expired or expiring soon — regenerating..."); + try { fs.unlinkSync(ROOT_CA_KEY_PATH); } catch { /* ignore */ } + try { fs.unlinkSync(ROOT_CA_CERT_PATH); } catch { /* ignore */ } + } + + if (!fs.existsSync(MITM_DIR)) { + fs.mkdirSync(MITM_DIR, { recursive: true }); + } + + console.log("🔐 Generating Root CA certificate..."); + + // Generate RSA key pair + const keys = forge.pki.rsa.generateKeyPair(2048); + + // Create Root CA certificate + const cert = forge.pki.createCertificate(); + cert.publicKey = keys.publicKey; + cert.serialNumber = "01"; + cert.validity.notBefore = new Date(); + cert.validity.notAfter = new Date(); + cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 10); + + const attrs = [ + { name: "commonName", value: "9Router MITM Root CA" }, + { name: "organizationName", value: "9Router" }, + { name: "countryName", value: "US" } + ]; + + cert.setSubject(attrs); + cert.setIssuer(attrs); // Self-signed + + cert.setExtensions([ + { + name: "basicConstraints", + cA: true, + critical: true + }, + { + name: "keyUsage", + keyCertSign: true, + cRLSign: true, + critical: true + }, + { + name: "subjectKeyIdentifier" + } + ]); + + // Self-sign the certificate + cert.sign(keys.privateKey, forge.md.sha256.create()); + + // Save to disk + const privateKeyPem = forge.pki.privateKeyToPem(keys.privateKey); + const certPem = forge.pki.certificateToPem(cert); + + fs.writeFileSync(ROOT_CA_KEY_PATH, privateKeyPem); + fs.writeFileSync(ROOT_CA_CERT_PATH, certPem); + + console.log("✅ Root CA generated successfully"); + return { key: ROOT_CA_KEY_PATH, cert: ROOT_CA_CERT_PATH }; +} + +/** + * Load Root CA from disk + */ +function loadRootCA() { + if (!fs.existsSync(ROOT_CA_KEY_PATH) || !fs.existsSync(ROOT_CA_CERT_PATH)) { + throw new Error("Root CA not found. Generate it first."); + } + + const keyPem = fs.readFileSync(ROOT_CA_KEY_PATH, "utf8"); + const certPem = fs.readFileSync(ROOT_CA_CERT_PATH, "utf8"); + + return { + key: forge.pki.privateKeyFromPem(keyPem), + cert: forge.pki.certificateFromPem(certPem) + }; +} + +/** + * Generate leaf certificate for a specific domain, signed by Root CA + */ +function generateLeafCert(domain, rootCA) { + // Generate key pair for leaf cert + const keys = forge.pki.rsa.generateKeyPair(2048); + + // Create leaf certificate + const cert = forge.pki.createCertificate(); + cert.publicKey = keys.publicKey; + cert.serialNumber = Math.floor(Math.random() * 1000000).toString(); + cert.validity.notBefore = new Date(); + cert.validity.notAfter = new Date(); + cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1); + + cert.setSubject([ + { name: "commonName", value: domain } + ]); + + cert.setIssuer(rootCA.cert.subject.attributes); + + cert.setExtensions([ + { + name: "basicConstraints", + cA: false + }, + { + name: "keyUsage", + digitalSignature: true, + keyEncipherment: true + }, + { + name: "extKeyUsage", + serverAuth: true, + clientAuth: true + }, + { + name: "subjectAltName", + altNames: [ + { type: 2, value: domain }, // DNS + { type: 2, value: `*.${domain}` } // Wildcard + ] + } + ]); + + // Sign with Root CA + cert.sign(rootCA.key, forge.md.sha256.create()); + + return { + key: forge.pki.privateKeyToPem(keys.privateKey), + cert: forge.pki.certificateToPem(cert) + }; +} + +module.exports = { + generateRootCA, + loadRootCA, + generateLeafCert, + isCertExpired, + ROOT_CA_CERT_PATH, + ROOT_CA_KEY_PATH +}; diff --git a/src/mitm/config.js b/src/mitm/config.js new file mode 100644 index 0000000..e109ec9 --- /dev/null +++ b/src/mitm/config.js @@ -0,0 +1,89 @@ +// All intercepted domains + URL patterns per tool + +const fs = require("fs"); + +const IS_DEV = process.env.NODE_ENV === "development"; + +// Resolve lsof absolute path — packaged apps / sudo secure_path may strip /usr/sbin from PATH +const LSOF_BIN = (() => { + if (process.platform === "win32") return null; + for (const p of ["/usr/sbin/lsof", "/usr/bin/lsof", "/sbin/lsof"]) { + try { fs.accessSync(p, fs.constants.X_OK); return p; } catch { /* try next */ } + } + return "lsof"; // last-resort fallback (depends on PATH) +})(); + +const TARGET_HOSTS = [ + "daily-cloudcode-pa.googleapis.com", + "cloudcode-pa.googleapis.com", + "api.individual.githubcopilot.com", + "q.us-east-1.amazonaws.com", + "codewhisperer.us-east-1.amazonaws.com", + "runtime.us-east-1.kiro.dev", + "api2.cursor.sh", +]; + +const URL_PATTERNS = { + antigravity: [":generateContent", ":streamGenerateContent"], + copilot: ["/chat/completions", "/v1/messages", "/responses"], + kiro: ["/generateAssistantResponse"], + cursor: ["/BidiAppend", "/RunSSE", "/RunPoll", "/Run"], +}; + +// Synonym map: rawModel from request → canonical alias key in mitmAlias DB +const MODEL_SYNONYMS = { + antigravity: { + "gemini-default": "gemini-3.5-flash-low", + "gemini-3.5-flash-high": "gemini-3-flash-agent", + "gemini-3.5-flash-medium": "gemini-3.5-flash-low", + "gemini-3.5-flash-extra-low": "gemini-3.5-flash-extra-low", + "gemini-3.1-pro-high": "gemini-pro-agent", + "gemini-3-pro-high": "gemini-pro-agent", + "gemini-3-pro-low": "gemini-3.1-pro-low", + }, +}; + +// Pattern fallback: rawModel regex → canonical alias key (when exact + prefix match fail) +// Order matters: more specific patterns first. Catches AG renamed variants (e.g. gemini-pro-agent) +const MODEL_PATTERNS = { + antigravity: [ + { match: /flash.*extra.*low|extra.*low.*flash|flash.*low|low.*flash/i, alias: "gemini-3.5-flash-extra-low" }, + { match: /flash.*medium|medium.*flash/i, alias: "gemini-3.5-flash-low" }, + { match: /flash.*agent|agent.*flash|flash/i, alias: "gemini-3-flash-agent" }, + { match: /pro.*low|low.*pro/i, alias: "gemini-3.1-pro-low" }, + { match: /gemini.*pro|pro.*gemini/i, alias: "gemini-pro-agent" }, + { match: /opus/i, alias: "claude-opus-4-6-thinking" }, + { match: /sonnet|claude/i, alias: "claude-sonnet-4-6" }, + { match: /gpt.*oss|oss/i, alias: "gpt-oss-120b-medium" }, + ], +}; + +// Models that must NEVER be re-routed — always passthrough to the real upstream, even when +// the tool's other models are mapped. Antigravity's tab-autocomplete (`tab_jump_flash_lite_preview`, +// `tab_flash_lite_preview`, requestType tab/tab_jump) is latency-critical inline completion; routing +// it through 9Router to an external chat model makes typing laggy and burns provider quota per +// keystroke. Without this guard the broad `flash` pattern in MODEL_PATTERNS hijacks them onto the +// flash-agent slot. Verified via MITM dump capture of streamGenerateContent (see AI_JOURNAL). +const MODEL_NO_MAP = { + antigravity: [/^tab[_-]/i], +}; + +// URL substrings whose request/response should NOT be dumped to file (telemetry, polling, empty) +const LOG_BLACKLIST_URL_PARTS = [ + "recordCodeAssistMetrics", + "recordTrajectoryAnalytics", + "fetchAdminControls", + "listExperiments", + "fetchUserInfo", +]; + +function getToolForHost(host) { + const h = (host || "").split(":")[0]; + if (h === "api.individual.githubcopilot.com") return "copilot"; + if (h === "daily-cloudcode-pa.googleapis.com" || h === "cloudcode-pa.googleapis.com") return "antigravity"; + if (h === "q.us-east-1.amazonaws.com" || h === "codewhisperer.us-east-1.amazonaws.com" || h === "runtime.us-east-1.kiro.dev") return "kiro"; + if (h === "api2.cursor.sh") return "cursor"; + return null; +} + +module.exports = { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, LOG_BLACKLIST_URL_PARTS, getToolForHost }; diff --git a/src/mitm/dbReader.js b/src/mitm/dbReader.js new file mode 100644 index 0000000..7133947 --- /dev/null +++ b/src/mitm/dbReader.js @@ -0,0 +1,22 @@ +// CJS reader for MITM standalone process. Reads mitmAlias from JSON cache +// at $DATA_DIR/mitm/aliases.json (synced by app from SQLite on startup + writes). +// JSON-only: no SQLite native binding required in MITM bundle. +const fs = require("fs"); +const path = require("path"); +const { DATA_DIR } = require("./paths"); + +const CACHE_FILE = path.join(DATA_DIR, "mitm", "aliases.json"); + +function readCache() { + try { + if (!fs.existsSync(CACHE_FILE)) return null; + return JSON.parse(fs.readFileSync(CACHE_FILE, "utf-8")); + } catch { return null; } +} + +function getMitmAlias(toolName) { + const all = readCache(); + return all?.[toolName] || null; +} + +module.exports = { getMitmAlias }; diff --git a/src/mitm/dns/dnsConfig.js b/src/mitm/dns/dnsConfig.js new file mode 100644 index 0000000..ae1568a --- /dev/null +++ b/src/mitm/dns/dnsConfig.js @@ -0,0 +1,266 @@ +const { exec, spawn, execSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { log, err } = require("../logger"); +const { TOOL_HOSTS } = require("../../shared/constants/mitmToolHosts.js"); +const { runElevatedPowerShell, isAdmin } = require("../winElevated.js"); + +/** + * Atomic-ish write for Windows hosts file with rollback on failure. + * Strategy: write `.new` sibling → rename current to `.bak` → rename `.new` to target. + * If anything fails mid-way, restore from `.bak`. Same-volume renames are atomic on NTFS. + */ +function atomicWriteHostsWin(target, originalContent, newContent) { + const tmpNew = `${target}.9router.new`; + const tmpBak = `${target}.9router.bak`; + try { + fs.writeFileSync(tmpNew, newContent, "utf8"); + try { fs.unlinkSync(tmpBak); } catch { /* none */ } + fs.renameSync(target, tmpBak); + try { + fs.renameSync(tmpNew, target); + } catch (e) { + // Rollback: restore original + try { fs.renameSync(tmpBak, target); } catch { fs.writeFileSync(target, originalContent, "utf8"); } + throw e; + } + try { fs.unlinkSync(tmpBak); } catch { /* best effort */ } + } finally { + try { fs.unlinkSync(tmpNew); } catch { /* already moved or never created */ } + } +} + +const IS_WIN = process.platform === "win32"; +const IS_MAC = process.platform === "darwin"; +const HOSTS_FILE = IS_WIN + ? path.join(process.env.SystemRoot || "C:\\Windows", "System32", "drivers", "etc", "hosts") + : "/etc/hosts"; + +/** True when `sudo` exists (e.g. missing on minimal Docker images like Alpine). */ +function isSudoAvailable() { + if (IS_WIN) return false; + try { + execSync("command -v sudo", { stdio: "ignore", windowsHide: true }); + return true; + } catch { + return false; + } +} + +function canRunSudoWithoutPassword() { + if (IS_WIN || !isSudoAvailable()) return true; + try { + execSync("sudo -n true", { stdio: "ignore", windowsHide: true }); + return true; + } catch { + return false; + } +} + +function isSudoPasswordRequired() { + return !IS_WIN && isSudoAvailable() && !canRunSudoWithoutPassword(); +} + +/** + * Execute command with sudo password via stdin (macOS/Linux only). + * Without sudo in PATH (containers), runs via sh — same user, no elevation. + */ +function execWithPassword(command, password) { + return new Promise((resolve, reject) => { + const useSudo = isSudoAvailable(); + const child = useSudo + ? spawn("sudo", ["-S", "sh", "-c", command], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true }) + : spawn("sh", ["-c", command], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d; }); + child.stderr.on("data", (d) => { stderr += d; }); + + child.on("close", (code) => { + if (code === 0) resolve(stdout); + else reject(new Error(stderr || `Exit code ${code}`)); + }); + + if (useSudo) { + child.stdin.write(`${password}\n`); + child.stdin.end(); + } + }); +} + +/** + * Trim trailing blank lines/whitespace, ensure file ends with exactly one newline. + */ +function normalizeHostsContent(content) { + const eol = IS_WIN ? "\r\n" : "\n"; + return content.replace(/[\r\n\s]+$/g, "") + eol; +} + +/** + * Flush DNS cache (macOS/Linux) + */ +async function flushDNS(sudoPassword) { + if (IS_WIN) return; // Windows flushes inline via ipconfig + if (IS_MAC) { + await execWithPassword("dscacheutil -flushcache && killall -HUP mDNSResponder", sudoPassword); + } else { + await execWithPassword("resolvectl flush-caches 2>/dev/null || true", sudoPassword); + } +} + +/** + * Check if DNS entry exists for a specific host + */ +function checkDNSEntry(host = null) { + try { + const hostsContent = fs.readFileSync(HOSTS_FILE, "utf8"); + if (host) return hostsContent.includes(host); + // Legacy: check all antigravity hosts (backward compat) + return TOOL_HOSTS.antigravity.every(h => hostsContent.includes(h)); + } catch { + return false; + } +} + +/** + * Check DNS status per tool — returns { [tool]: boolean } + */ +function checkAllDNSStatus() { + try { + const hostsContent = fs.readFileSync(HOSTS_FILE, "utf8"); + const result = {}; + for (const [tool, hosts] of Object.entries(TOOL_HOSTS)) { + result[tool] = hosts.every(h => hostsContent.includes(h)); + } + return result; + } catch { + return Object.fromEntries(Object.keys(TOOL_HOSTS).map(t => [t, false])); + } +} + +/** + * Add DNS entries for a specific tool + */ +async function addDNSEntry(tool, sudoPassword) { + const hosts = TOOL_HOSTS[tool]; + if (!hosts) throw new Error(`Unknown tool: ${tool}`); + + const entriesToAdd = hosts.filter(h => !checkDNSEntry(h)); + if (entriesToAdd.length === 0) { + log(`🌐 DNS ${tool}: already active`); + return; + } + + try { + if (IS_WIN) { + // Read → trim → append → atomic write (Node-side, no CLI size limit) + const current = fs.readFileSync(HOSTS_FILE, "utf8"); + const trimmed = current.replace(/[\r\n\s]+$/g, ""); + const toAppend = entriesToAdd.map(h => `127.0.0.1 ${h}`).join("\r\n"); + const next = `${trimmed}\r\n${toAppend}\r\n`; + atomicWriteHostsWin(HOSTS_FILE, current, next); + await runElevatedPowerShell("ipconfig /flushdns | Out-Null"); + } else { + const current = fs.readFileSync(HOSTS_FILE, "utf8"); + const trimmed = current.replace(/[\r\n\s]+$/g, ""); + const toAppend = entriesToAdd.map(h => `127.0.0.1 ${h}`).join("\n"); + const next = `${trimmed}\n${toAppend}\n`; + // Use tee via sudo to overwrite atomically — escape single quotes in content + const escaped = next.replace(/'/g, "'\\''"); + await execWithPassword(`printf '%s' '${escaped}' | tee ${HOSTS_FILE} > /dev/null`, sudoPassword); + await flushDNS(sudoPassword); + } + log(`🌐 DNS ${tool}: ✅ added ${entriesToAdd.join(", ")}`); + } catch (error) { + const msg = error.message?.includes("incorrect password") ? "Wrong sudo password" : `Failed to add DNS entry: ${error.message}`; + throw new Error(msg); + } +} + +/** + * Remove DNS entries for a specific tool + */ +async function removeDNSEntry(tool, sudoPassword) { + const hosts = TOOL_HOSTS[tool]; + if (!hosts) throw new Error(`Unknown tool: ${tool}`); + + const entriesToRemove = hosts.filter(h => checkDNSEntry(h)); + if (entriesToRemove.length === 0) { + log(`🌐 DNS ${tool}: already inactive`); + return; + } + + try { + if (IS_WIN) { + const current = fs.readFileSync(HOSTS_FILE, "utf8"); + const filtered = current.split(/\r?\n/).filter(l => !entriesToRemove.some(h => l.includes(h))).join("\r\n"); + const next = filtered.replace(/[\r\n\s]+$/g, "") + "\r\n"; + atomicWriteHostsWin(HOSTS_FILE, current, next); + await runElevatedPowerShell("ipconfig /flushdns | Out-Null"); + } else { + const current = fs.readFileSync(HOSTS_FILE, "utf8"); + const filtered = current.split(/\r?\n/).filter(l => !entriesToRemove.some(h => l.includes(h))).join("\n"); + const next = filtered.replace(/[\r\n\s]+$/g, "") + "\n"; + const escaped = next.replace(/'/g, "'\\''"); + await execWithPassword(`printf '%s' '${escaped}' | tee ${HOSTS_FILE} > /dev/null`, sudoPassword); + await flushDNS(sudoPassword); + } + log(`🌐 DNS ${tool}: ✅ removed ${entriesToRemove.join(", ")}`); + } catch (error) { + const msg = error.message?.includes("incorrect password") ? "Wrong sudo password" : `Failed to remove DNS entry: ${error.message}`; + throw new Error(msg); + } +} + +/** + * Remove ALL tool DNS entries (used when stopping server) + */ +async function removeAllDNSEntries(sudoPassword) { + for (const tool of Object.keys(TOOL_HOSTS)) { + try { + await removeDNSEntry(tool, sudoPassword); + } catch (e) { + err(`DNS ${tool}: failed to remove — ${e.message}`); + } + } +} + +/** + * Sync removal of ALL tool DNS entries — for use during process shutdown + * when async ops aren't safe. Assumes caller already has root/admin rights. + */ +function removeAllDNSEntriesSync() { + try { + if (!fs.existsSync(HOSTS_FILE)) return; + const allHosts = Object.values(TOOL_HOSTS).flat(); + const content = fs.readFileSync(HOSTS_FILE, "utf8"); + const eol = IS_WIN ? "\r\n" : "\n"; + const filtered = content.split(/\r?\n/).filter(l => !allHosts.some(h => l.includes(h))).join(eol); + const next = filtered.replace(/[\r\n\s]+$/g, "") + eol; + if (next === content) return; + fs.writeFileSync(HOSTS_FILE, next, "utf8"); + if (IS_WIN) { + try { execSync("ipconfig /flushdns", { windowsHide: true, stdio: "ignore" }); } catch { /* ignore */ } + } else if (IS_MAC) { + try { execSync("dscacheutil -flushcache && killall -HUP mDNSResponder", { stdio: "ignore" }); } catch { /* ignore */ } + } else { + try { execSync("resolvectl flush-caches 2>/dev/null || true", { stdio: "ignore" }); } catch { /* ignore */ } + } + } catch { /* best effort during shutdown */ } +} + +module.exports = { + TOOL_HOSTS, + addDNSEntry, + removeDNSEntry, + removeAllDNSEntries, + removeAllDNSEntriesSync, + execWithPassword, + isSudoAvailable, + canRunSudoWithoutPassword, + isSudoPasswordRequired, + checkDNSEntry, + checkAllDNSStatus, +}; diff --git a/src/mitm/handlers/antigravity.js b/src/mitm/handlers/antigravity.js new file mode 100644 index 0000000..aa9273f --- /dev/null +++ b/src/mitm/handlers/antigravity.js @@ -0,0 +1,33 @@ +const { err, createResponseDumper } = require("../logger"); +const { IS_DEV } = require("../config"); +const { fetchRouter, pipeSSE } = require("./base"); + +/** + * Intercept Antigravity request — forward Gemini body as-is to /v1/chat/completions. + * Router auto-detects format via body.userAgent==="antigravity" + body.request.contents, + * runs antigravity→openai→provider→openai→antigravity translators internally. + */ +async function intercept(req, res, bodyBuffer, mappedModel) { + const dumper = IS_DEV ? createResponseDumper(req, "intercept-antigravity") : null; + const isStream = req.url.includes(":streamGenerateContent"); + try { + const body = JSON.parse(bodyBuffer.toString()); + if (body.model) body.model = mappedModel; + + const routerRes = await fetchRouter(body, "/v1/chat/completions", req.headers); + await pipeSSE(routerRes, res, dumper); + } catch (error) { + err(`[antigravity] ${error.message}`); + if (dumper) { dumper.writeChunk(`\n[ERROR] ${error.message}\n`); dumper.end(); } + // For stream endpoint, send SSE error chunk so SDK doesn't hang waiting + if (isStream) { + if (!res.headersSent) res.writeHead(200, { "Content-Type": "text/event-stream" }); + res.end(`data: ${JSON.stringify({ error: { message: error.message } })}\r\n\r\n`); + } else { + if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: { message: error.message, type: "mitm_error" } })); + } + } +} + +module.exports = { intercept }; diff --git a/src/mitm/handlers/base.js b/src/mitm/handlers/base.js new file mode 100644 index 0000000..5f2d3d1 --- /dev/null +++ b/src/mitm/handlers/base.js @@ -0,0 +1,226 @@ +const { log, err } = require("../logger"); + +const DEFAULT_LOCAL_ROUTER = "http://localhost:20128"; +const ROUTER_BASE = String(process.env.MITM_ROUTER_BASE || DEFAULT_LOCAL_ROUTER) + .trim() + .replace(/\/+$/, "") || DEFAULT_LOCAL_ROUTER; +const API_KEY = process.env.ROUTER_API_KEY; + +// Headers that must not be forwarded to 9Router +const STRIP_HEADERS = new Set([ + "host", "content-length", "connection", "transfer-encoding", + "content-type", "authorization" +]); + +/** + * Send body to 9Router at the given path and return the fetch Response object. + * Optionally forwards client headers (stripped of hop-by-hop / overridden keys). + */ +async function fetchRouter(openaiBody, path = "/v1/chat/completions", clientHeaders = {}) { + const forwarded = {}; + for (const [k, v] of Object.entries(clientHeaders)) { + if (!STRIP_HEADERS.has(k.toLowerCase())) forwarded[k] = v; + } + + const response = await fetch(`${ROUTER_BASE}${path}`, { + method: "POST", + headers: { + ...forwarded, + "Content-Type": "application/json", + ...(API_KEY && { "Authorization": `Bearer ${API_KEY}` }) + }, + body: JSON.stringify(openaiBody) + }); + + // Forward response as-is (status + body). pipeSSE will propagate status. + return response; +} + +/** + * Pipe SSE stream from router directly to client response. + * Optional dumper tees the stream into a debug file. + */ +async function pipeSSE(routerRes, res, dumper) { + const ct = routerRes.headers.get("content-type") || "application/json"; + const status = routerRes.status || 200; + const resHeaders = { "Content-Type": ct, "Cache-Control": "no-cache", "Connection": "keep-alive" }; + if (ct.includes("text/event-stream")) resHeaders["X-Accel-Buffering"] = "no"; + res.writeHead(status, resHeaders); + if (dumper) dumper.writeHeader(routerRes.status, Object.fromEntries(routerRes.headers)); + + if (!routerRes.body) { + const text = await routerRes.text().catch(() => ""); + if (dumper) { dumper.writeChunk(text); dumper.end(); } + res.end(text); + return; + } + + const reader = routerRes.body.getReader(); + const decoder = new TextDecoder(); + while (true) { + const { done, value } = await reader.read(); + if (done) { if (dumper) dumper.end(); res.end(); break; } + if (dumper) dumper.writeChunk(value); + res.write(decoder.decode(value, { stream: true })); + } +} + +/** + * Pipe SSE stream from router, transforming each chunk through a user function. + * Reads SSE data: lines, parses JSON, calls transformFn(parsed, state), + * and writes returned SSE strings to the client response. + * + * @param {Response} routerRes - Fetch Response from 9Router + * @param {http.ServerResponse} res - Client response + * @param {Function} transformFn - (parsedChunk, state) => string|string[]|null + * @param {object} state - Mutable state object shared across chunks and flush + */ +async function pipeTransformedSSE(routerRes, res, transformFn, state) { + const ct = routerRes.headers.get("content-type") || "application/json"; + const resHeaders = { "Content-Type": ct, "Cache-Control": "no-cache", "Connection": "keep-alive" }; + if (ct.includes("text/event-stream")) resHeaders["X-Accel-Buffering"] = "no"; + res.writeHead(200, resHeaders); + + if (!routerRes.body) { + res.end(await routerRes.text().catch(() => "")); + return; + } + + const reader = routerRes.body.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: false }); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith("data:")) continue; + + const data = trimmed.slice(5).trim(); + if (data === "[DONE]") continue; + + if (process.env.DEBUG_MITM) { + log(`[SSE in] ${data.slice(0, 200)}`); + } + + try { + const parsed = JSON.parse(data); + const result = transformFn(parsed, state); + if (result != null) { + const outputs = Array.isArray(result) ? result : [result]; + for (const output of outputs) { + if (process.env.DEBUG_MITM) { + const len = output.length || output.byteLength || 0; + log(`[write binary frame] (${len}B) first 20B: ${Array.from(output.slice(0, 20)).join(',')}`); + } + res.write(Buffer.from(output)); + } + } + } catch { + // Skip unparseable lines + } + } + } + + // Flush: pass null to signal stream end + try { + const flushed = transformFn(null, state); + if (flushed != null) { + const outputs = Array.isArray(flushed) ? flushed : [flushed]; + for (const output of outputs) { + res.write(output); + } + } + } catch { /* ignore flush errors */ } + + res.end(); +} + +/** + * Pipe SSE stream from router, transforming each chunk through a user function, + * and writing binary EventStream frames to the client. + * + * Reads SSE data: lines, parses JSON, calls transformFn(parsed, state), + * and writes returned Uint8Array frames to the client response. + * + * @param {Response} routerRes - Fetch Response from 9Router + * @param {http.ServerResponse} res - Client response + * @param {Function} transformFn - (parsedChunk, state) => Uint8Array|Uint8Array[]|null + * @param {object} state - Mutable state object shared across chunks and flush + */ +async function pipeTransformedEventStream(routerRes, res, transformFn, state) { + const resHeaders = { + "Content-Type": "application/vnd.amazon.eventstream", + "Cache-Control": "no-cache", + "Connection": "keep-alive" + }; + res.writeHead(200, resHeaders); + + if (!routerRes.body) { + res.end(await routerRes.text().catch(() => "")); + return; + } + + const reader = routerRes.body.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: false }); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith("data:")) continue; + + const data = trimmed.slice(5).trim(); + if (data === "[DONE]") continue; + + if (process.env.DEBUG_MITM) { + log(`[SSE in] ${data.slice(0, 200)}`); + } + + try { + const parsed = JSON.parse(data); + const result = transformFn(parsed, state); + if (result != null) { + const outputs = Array.isArray(result) ? result : [result]; + for (const output of outputs) { + if (process.env.DEBUG_MITM) { + const len = output.length || output.byteLength || 0; + log(`[write binary frame] (${len}B) first 20B: ${Array.from(output.slice(0, 20)).join(',')}`); + } + res.write(Buffer.from(output)); + } + } + } catch { + // Skip unparseable lines + } + } + } + + // Flush: pass null to signal stream end + try { + const flushed = transformFn(null, state); + if (flushed != null) { + const outputs = Array.isArray(flushed) ? flushed : [flushed]; + for (const output of outputs) { + res.write(output); + } + } + } catch { /* ignore flush errors */ } + + res.end(); +} + +module.exports = { fetchRouter, pipeSSE, pipeTransformedSSE, pipeTransformedEventStream }; \ No newline at end of file diff --git a/src/mitm/handlers/copilot.js b/src/mitm/handlers/copilot.js new file mode 100644 index 0000000..cf6a920 --- /dev/null +++ b/src/mitm/handlers/copilot.js @@ -0,0 +1,35 @@ +const { err } = require("../logger"); +const { fetchRouter, pipeSSE } = require("./base"); + +// Map Copilot endpoint → 9Router path +const URL_MAP = { + "/chat/completions": "/v1/chat/completions", + "/v1/messages": "/v1/messages", + "/responses": "/v1/responses", +}; + +function resolveRouterPath(reqUrl) { + for (const [pattern, routerPath] of Object.entries(URL_MAP)) { + if (reqUrl.includes(pattern)) return routerPath; + } + return "/v1/chat/completions"; +} + +/** + * Intercept Copilot request — replace model and forward to matching 9Router endpoint + */ +async function intercept(req, res, bodyBuffer, mappedModel) { + try { + const body = JSON.parse(bodyBuffer.toString()); + body.model = mappedModel; + const routerPath = resolveRouterPath(req.url); + const routerRes = await fetchRouter(body, routerPath, req.headers); + await pipeSSE(routerRes, res); + } catch (error) { + err(`[copilot] ${error.message}`); + if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: { message: error.message, type: "mitm_error" } })); + } +} + +module.exports = { intercept }; diff --git a/src/mitm/handlers/cursor.js b/src/mitm/handlers/cursor.js new file mode 100644 index 0000000..0f38663 --- /dev/null +++ b/src/mitm/handlers/cursor.js @@ -0,0 +1,15 @@ +/** + * Cursor MITM handler — coming soon + * This feature is currently under development. + */ +async function intercept(req, res) { + res.writeHead(501, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + error: { + message: "Cursor MITM support is coming soon.", + type: "not_implemented" + } + })); +} + +module.exports = { intercept }; diff --git a/src/mitm/handlers/kiro.js b/src/mitm/handlers/kiro.js new file mode 100644 index 0000000..b453d2c --- /dev/null +++ b/src/mitm/handlers/kiro.js @@ -0,0 +1,526 @@ +const { err } = require("../logger"); +const { IS_DEV } = require("../config"); +const { fetchRouter, pipeTransformedEventStream } = require("./base"); +const fs = require("fs"); +const path = require("path"); + +// Debug trace log — written to data/logs/mitm/kiro-debug.log (dev only) +const DEBUG_LOG = path.join(__dirname, "../../../data/logs/mitm/kiro-debug.log"); +function dbg(msg) { + if (!IS_DEV) return; + try { + fs.appendFileSync(DEBUG_LOG, `${new Date().toISOString()} ${msg}\n`); + } catch {} +} + +// ─── CRC32 (standard, polynomial 0xEDB88320 — same as AWS EventStream) ─────── +const CRC32_TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c; + } + return t; +})(); + +function crc32(buf) { + let crc = 0xffffffff; + for (let i = 0; i < buf.length; i++) { + crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ buf[i]) & 0xff]; + } + return (crc ^ 0xffffffff) >>> 0; +} + +/** + * Initialize state for the Kiro response translator + */ +function initKiroState(modelId) { + return { + modelId: modelId || null, // Model name from first chunk + toolCallInit: {}, // { [index]: { id, name } } — tracks seen tools + hasToolCalls: false, // Whether this response uses tool calls + finishSent: false, // Whether termination has been emitted + usage: null, // Accumulated usage from usage-only chunks + inThink: false, // Whether inside a block + thinkBuf: "" // Buffer for partial thinking content + }; +} + +/** + * Extract thinking blocks from text content. + * Handles both ... and ... tags, + * including partial tags split across SSE chunks. + */ +function extractThinking(text, state) { + if (!text) return { thinking: null, text: null }; + + let working = text; + + // Prepend buffered partial thinking from previous chunk + if (state.inThink && state.thinkBuf) { + working = state.thinkBuf + working; + state.thinkBuf = ""; + state.inThink = false; + } + + // Match or opening tags + const startRe = /|/i; + const startMatch = working.match(startRe); + + if (!startMatch) { + return { thinking: null, text: working }; + } + + const tag = startMatch[0].toLowerCase(); + const closeTag = tag === "" ? "" : ""; + const startIdx = startMatch.index; + const endIdx = working.indexOf(closeTag, startIdx + tag.length); + + if (endIdx === -1) { + // Opening tag without closing — buffer for next chunk + state.inThink = true; + state.thinkBuf = working.slice(startIdx); + const before = working.slice(0, startIdx).trim(); + return { thinking: null, text: before || null }; + } + + // Complete block found + const thinking = working.slice(startIdx + tag.length, endIdx); + const before = working.slice(0, startIdx).trim(); + const after = working.slice(endIdx + closeTag.length).trim(); + const rest = [before, after].filter(Boolean).join(""); + + // Recursively process for more blocks + const recurse = rest + ? extractThinking(rest, { inThink: false, thinkBuf: "" }) + : { thinking: null, text: null }; + + return { + thinking: thinking || null, + text: recurse.text || null + }; +} + +// ─── AWS EventStream frame builder ──────────────────────────────────────────── +/** + * Encode a single string header into the AWS EventStream binary format. + * Header wire format: [nameLen 1B][name][type=7 1B][valueLen 2B][value] + */ +function encodeHeader(name, value) { + const nameBuf = Buffer.from(name, "utf8"); + const valueBuf = Buffer.from(value, "utf8"); + const buf = Buffer.alloc(1 + nameBuf.length + 1 + 2 + valueBuf.length); + let o = 0; + buf[o++] = nameBuf.length; + nameBuf.copy(buf, o); o += nameBuf.length; + buf[o++] = 7; // string type + buf.writeUInt16BE(valueBuf.length, o); o += 2; + valueBuf.copy(buf, o); + return buf; +} + +/** + * Build a single AWS EventStream binary frame with all Smithy-required headers. + * + * Frame layout (big-endian): + * [totalLen 4B][headersLen 4B][preludeCRC 4B] + * [headers ...][payload JSON ...][messageCRC 4B] + * + * The SmithyMessageDecoderStream layer requires three system headers on every frame: + * :message-type = "event" (or "exception" / "error") + * :event-type = e.g. "assistantResponseEvent" + * :content-type = "application/json" + */ +function buildEventStreamFrame(eventType, payload) { + const payloadBuf = Buffer.from( + typeof payload === "string" ? payload : JSON.stringify(payload), + "utf8" + ); + + // All three Smithy system headers are required + const headersBuf = Buffer.concat([ + encodeHeader(":message-type", "event"), + encodeHeader(":event-type", eventType), + encodeHeader(":content-type", "application/json"), + ]); + const headersLen = headersBuf.length; + + const totalLen = 4 + 4 + 4 + headersLen + payloadBuf.length + 4; + const frame = Buffer.alloc(totalLen); + + frame.writeUInt32BE(totalLen, 0); + frame.writeUInt32BE(headersLen, 4); + frame.writeUInt32BE(crc32(frame.slice(0, 8)), 8); // prelude CRC + headersBuf.copy(frame, 12); + payloadBuf.copy(frame, 12 + headersLen); + frame.writeUInt32BE(crc32(frame.slice(0, totalLen - 4)), totalLen - 4); // message CRC + + return frame; +} + +// ─── CodeWhisperer → OpenAI conversion ─────────────────────────────────────── + +/** + * Safely stringify a tool-call input value. + * OpenAI expects `function.arguments` to be a JSON string, never an object. + * If 9router's Anthropic→OpenAI conversion passes the input as a pre-parsed object, + * this prevents the "" + object → "[object Object]" corruption. + */ +function safeArgsString(value) { + if (typeof value === "string") return value; + if (value == null) return "{}"; + try { return JSON.stringify(value); } catch { return "{}"; } +} + +/** + * Convert a CodeWhisperer userInputMessage to one or more OpenAI messages. + * + * A user turn can contain: + * - plain text content → { role:"user", content } + * - toolResults only → one { role:"tool", tool_call_id, content } per result + * - both → tool messages first, then the user text message + */ +function convertUserInputMessage(uim) { + const out = []; + const toolResults = uim.userInputMessageContext?.toolResults || []; + + // Emit one "tool" message per tool result (OpenAI multi-tool format) + for (const tr of toolResults) { + const text = (tr.content || []).map(c => c.text || "").join("\n"); + out.push({ + role: "tool", + tool_call_id: tr.toolUseId || "", + content: text, + }); + } + + // Emit user text only if it exists alongside OR when there are no tool results + const text = (uim.content || "").trim(); + if (text || toolResults.length === 0) { + out.push({ role: "user", content: text }); + } + + return out; +} + +/** + * Convert a CodeWhisperer assistantResponseMessage to an OpenAI assistant message. + * + * The assistant turn can contain: + * - plain text content + * - toolUses (tool calls) → tool_calls[] on the assistant message + */ +function convertAssistantResponseMessage(arm) { + const toolUses = arm.toolUses || []; + + if (toolUses.length > 0) { + return { + role: "assistant", + content: arm.content || null, + tool_calls: toolUses.map(tu => ({ + id: tu.toolUseId || `call_${Date.now()}`, + type: "function", + function: { + name: tu.name || "", + arguments: safeArgsString(tu.input), + }, + })), + }; + } + + return { role: "assistant", content: arm.content || "" }; +} + +/** + * Convert AWS CodeWhisperer conversationState to an OpenAI messages array. + * + * Full multi-turn shape: + * history: [ + * { userInputMessage: { content, userInputMessageContext?: { toolResults?, tools? } } }, + * { assistantResponseMessage: { content, toolUses?: [...] } }, + * ... + * ] + * currentMessage: { userInputMessage: { content, userInputMessageContext?: { toolResults? } } } + */ +function codeWhispererToMessages(body) { + const cs = body.conversationState || {}; + const history = cs.history || []; + const currentMsg = cs.currentMessage; + const messages = []; + + for (const item of history) { + if (item.userInputMessage) { + messages.push(...convertUserInputMessage(item.userInputMessage)); + } else if (item.assistantResponseMessage) { + messages.push(convertAssistantResponseMessage(item.assistantResponseMessage)); + } + } + + // Append the current (latest) turn + if (currentMsg?.userInputMessage) { + messages.push(...convertUserInputMessage(currentMsg.userInputMessage)); + } + + return messages; +} + +/** + * Extract tool definitions from a CodeWhisperer request and convert to OpenAI format. + * + * CodeWhisperer tools live in: + * conversationState.currentMessage.userInputMessage.userInputMessageContext.tools + * OR (in multi-turn) the first history item's userInputMessageContext.tools + * + * CodeWhisperer tool shape: + * { toolSpecification: { name, description, inputSchema: { json: } } } + * + * OpenAI tool shape: + * { type: "function", function: { name, description, parameters: } } + */ +function extractTools(body) { + const cs = body.conversationState || {}; + + // Tools are typically on the currentMessage; may also appear on the first history item + const fromCurrent = cs.currentMessage?.userInputMessage?.userInputMessageContext?.tools || []; + const fromHistory = cs.history?.find(h => h.userInputMessage?.userInputMessageContext?.tools) + ?.userInputMessage?.userInputMessageContext?.tools || []; + const cwTools = fromCurrent.length > 0 ? fromCurrent : fromHistory; + + if (!cwTools.length) return []; + + return cwTools.map(item => { + const spec = item.toolSpecification || item; + return { + type: "function", + function: { + name: spec.name || "", + description: spec.description || `Tool: ${spec.name || "unknown"}`, + parameters: spec.inputSchema?.json || { type: "object", properties: {}, required: [] }, + }, + }; + }); +} + +// ─── OpenAI SSE → EventStream binary conversion ─────────────────────────────── + +/** + * Convert an OpenAI SSE chunk to AWS EventStream binary frame(s) + * This replaces pipeOpenAIasEventStream and works with pipeTransformedEventStream + * + * @param {object|null} chunk - Parsed OpenAI chat.completion.chunk, or null for flush + * @param {object} state - Mutable state object + * @returns {Uint8Array|Uint8Array[]|null} Binary EventStream frame(s) or null to skip + */ +function convertOpenAIToKiro(chunk, state) { + // Flush: ensure clean stream termination + if (!chunk) { + if (state.finishSent) return null; + // Flush any remaining buffered thinking + if (state.inThink && state.thinkBuf) { + state.inThink = false; + const thinking = state.thinkBuf; + state.thinkBuf = ""; + return buildEventStreamFrame("reasoningContentEvent", { + content: thinking, + modelId: state.modelId || "kiro-unknown" + }); + } + return buildEventStreamFrame("messageStopEvent", {}); + } + + const frames = []; + const choice = chunk.choices?.[0]; + const delta = choice?.delta || {}; + + // Capture modelId from first chunk (real API includes it in every content frame) + if (!state.modelId && chunk.model) { + state.modelId = chunk.model; + } + const modelId = state.modelId || "unknown"; + + // Handle usage (may arrive standalone or with other chunks) + if (chunk.usage) { + state.usage = chunk.usage; + } + + // Handle tool calls — stream incrementally, matching real API format + if (delta.tool_calls) { + state.hasToolCalls = true; + for (const tc of delta.tool_calls) { + const idx = tc.index ?? 0; + + if (tc.id && tc.function?.name && !state.toolCallInit[idx]) { + // First appearance: emit frame with name + id, no input + state.toolCallInit[idx] = { id: tc.id, name: tc.function.name }; + dbg(`toolUseEvent init: ${tc.function.name} (${tc.id})`); + frames.push(buildEventStreamFrame("toolUseEvent", { + name: tc.function.name, + toolUseId: tc.id + })); + } + + // Emit incremental input fragment + if (tc.function?.arguments) { + const init = state.toolCallInit[idx]; + dbg(`toolUseEvent fragment: ${tc.function.arguments.slice(0, 100)}`); + frames.push(buildEventStreamFrame("toolUseEvent", { + input: tc.function.arguments, + name: init?.name || tc.function?.name || "", + toolUseId: init?.id || tc.id || "" + })); + } + } + } + + // Handle explicit reasoning_content (type-specific thinking channel) + if (delta.reasoning_content) { + frames.push(buildEventStreamFrame("reasoningContentEvent", { + content: delta.reasoning_content, + modelId + })); + } + + // Handle text content — extract thinking blocks, emit rest as assistantResponseEvent + if (delta.content) { + const { thinking, text } = extractThinking(delta.content, state); + + if (thinking) { + frames.push(buildEventStreamFrame("reasoningContentEvent", { + content: thinking, + modelId + })); + } + + if (text) { + frames.push(buildEventStreamFrame("assistantResponseEvent", { + content: text, + modelId + })); + } + } + + // Handle finish_reason + if (choice?.finish_reason) { + const finishFrames = emitFinish(state); + if (finishFrames) { + frames.push(...(Array.isArray(finishFrames) ? finishFrames : [finishFrames])); + } + } + + if (frames.length === 0) return null; + return frames.length === 1 ? frames[0] : frames; +} + +/** + * Emit termination frames. For tool-call responses, emits stop:true per tool. + * For text-only responses, emits messageStopEvent. + */ +function emitFinish(state) { + const frames = []; + + if (state.hasToolCalls) { + // Tool-call response: emit stop:true for each tool + for (const idx of Object.keys(state.toolCallInit).sort()) { + const tc = state.toolCallInit[idx]; + frames.push(buildEventStreamFrame("toolUseEvent", { + name: tc.name, + stop: true, + toolUseId: tc.id + })); + } + } else { + // Text-only response: emit messageStopEvent + frames.push(buildEventStreamFrame("messageStopEvent", {})); + } + state.finishSent = true; + + // Emit usage if available + if (state.usage) { + frames.push(buildEventStreamFrame("usageEvent", { + inputTokens: state.usage.prompt_tokens || 0, + outputTokens: state.usage.completion_tokens || 0 + })); + } + + state.toolCallInit = {}; + return frames.length > 0 ? frames : null; +} + +// ─── MITM intercept entry point ─────────────────────────────────────────────── +/** + * Intercept Kiro IDE CodeWhisperer request and convert to EventStream response: + * 1. Parse CodeWhisperer JSON body (reject binary EventStream formats) + * 2. Convert CodeWhisperer format to OpenAI messages[] format + * 3. Forward to 9router /v1/chat/completions (OpenAI SSE) + * 4. Convert OpenAI SSE response → AWS EventStream binary frames + * 5. Stream EventStream frames back to Kiro IDE + * + * @param {http.IncomingMessage} req - HTTP request from Kiro IDE + * @param {http.ServerResponse} res - HTTP response to Kiro IDE + * @param {Buffer} bodyBuffer - Request body buffer + * @param {string} mappedModel - Model name after MITM alias mapping + */ +async function intercept(req, res, bodyBuffer, mappedModel) { + try { + // Detect and handle binary data (e.g., continuation requests with EventStream frames) + if (isBinaryEventStream(bodyBuffer)) { + // Binary EventStream requests are typically continuation/streaming frames + // that don't contain model info - pass them through directly to avoid JSON.parse crash + throw new Error(`Binary EventStream format detected (${bodyBuffer.length}B) - request should use passthrough instead of intercept`); + } + + const body = JSON.parse(bodyBuffer.toString()); + + // 1 + 2: CodeWhisperer → OpenAI messages + tools + const messages = codeWhispererToMessages(body); + if (messages.length === 0) { + throw new Error("codeWhispererToMessages produced 0 messages — check request body"); + } + + const tools = extractTools(body); + + const openaiBody = { + model: mappedModel, + messages, + stream: true, + // Forward tools so Claude uses structured tool_calls instead of XML text fallback + ...(tools.length > 0 && { tools, tool_choice: "auto" }), + }; + + // 3: Forward to 9router + const routerRes = await fetchRouter(openaiBody, "/v1/chat/completions", req.headers); + + // 4 + 5: Re-encode response as AWS EventStream binary using standard pipeline + const state = initKiroState(mappedModel); + + await pipeTransformedEventStream(routerRes, res, convertOpenAIToKiro, state); + } catch (error) { + err(`[Kiro MITM] Request processing failed: ${error.message}`); + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "application/json" }); + } + res.end(JSON.stringify({ + error: { + message: error.message, + type: "mitm_error", + handler: "kiro" + } + })); + } +} + +// Detect AWS EventStream binary format +function isBinaryEventStream(buffer) { + if (!buffer || buffer.length < 12) return false; + // AWS EventStream signature: + // - First 4 bytes: total frame length (big-endian) + // - Bytes 4-8: headers length (big-endian) + // - Typical frame length: 100-10000 bytes + const totalLen = buffer.readUInt32BE(0); + const headersLen = buffer.readUInt32BE(4); + // Sanity checks: frame length should be reasonable and headers should fit + return totalLen > 12 && totalLen < 1000000 && headersLen < totalLen - 12; +} + +module.exports = { intercept }; diff --git a/src/mitm/logger.js b/src/mitm/logger.js new file mode 100644 index 0000000..5e53210 --- /dev/null +++ b/src/mitm/logger.js @@ -0,0 +1,106 @@ +const fs = require("fs"); +const path = require("path"); +const zlib = require("zlib"); +const { DATA_DIR } = require("./paths"); +const { LOG_BLACKLIST_URL_PARTS } = require("./config"); + +function time() { + return new Date().toLocaleTimeString("en-US", { hour12: false }); +} + +const log = (msg) => console.log(`[${time()}] [MITM] ${msg}`); +const err = (msg) => console.error(`[${time()}] ❌ [MITM] ${msg}`); + +const DUMP_DIR = path.join(DATA_DIR, "logs", "mitm"); +if (!fs.existsSync(DUMP_DIR)) fs.mkdirSync(DUMP_DIR, { recursive: true }); + +// Clear all files inside DUMP_DIR (called on MITM server start to avoid unbounded growth) +function clearDumpDir() { + try { + if (!fs.existsSync(DUMP_DIR)) return; + for (const f of fs.readdirSync(DUMP_DIR)) { + try { fs.rmSync(path.join(DUMP_DIR, f), { recursive: true, force: true }); } catch { /* ignore */ } + } + } catch { /* ignore */ } +} + +const EMPTY_BODY_RE = /^\s*(\{\s*\}|\[\s*\]|null)?\s*$/; + +function slugify(s, max = 80) { + return String(s).replace(/[^a-zA-Z0-9]/g, "_").substring(0, max); +} + +function isBlacklisted(url) { + if (!url) return false; + return LOG_BLACKLIST_URL_PARTS.some(part => url.includes(part)); +} + +// Decode body buffer based on content-encoding header +function decodeBody(buf, encoding) { + if (!buf || buf.length === 0) return buf; + try { + const enc = (encoding || "").toLowerCase(); + if (enc.includes("gzip")) return zlib.gunzipSync(buf); + if (enc.includes("br")) return zlib.brotliDecompressSync(buf); + if (enc.includes("deflate")) return zlib.inflateSync(buf); + } catch { /* return raw on failure */ } + return buf; +} + +// Save raw request: method + url + headers + body +function dumpRequest(req, bodyBuffer, tag = "raw") { + if (isBlacklisted(req.url)) return null; + try { + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + const slug = slugify((req.headers.host || "") + req.url); + const file = path.join(DUMP_DIR, `${ts}_${tag}_${slug}.req.json`); + let parsed = null; + try { parsed = JSON.parse(bodyBuffer.toString()); } catch { /* not JSON */ } + fs.writeFileSync(file, JSON.stringify({ + method: req.method, + url: req.url, + host: req.headers.host, + headers: req.headers, + body: parsed ?? bodyBuffer.toString("utf8") + }, null, 2)); + return file; + } catch { return null; } +} + +// Buffer-based response dumper — collects chunks then decodes + writes once on end() +// Trade-off: holds response in RAM, but enables gzip/br decoding for readable output. +function createResponseDumper(req, tag = "raw") { + if (isBlacklisted(req.url)) return null; + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + const slug = slugify((req.headers.host || "") + req.url); + const file = path.join(DUMP_DIR, `${ts}_${tag}_${slug}.res.txt`); + let status = 0; + let headers = {}; + const chunks = []; + return { + writeHeader: (s, h) => { status = s; headers = h || {}; }, + writeChunk: (chunk) => { + if (chunk == null) return; + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }, + end: () => { + try { + const raw = Buffer.concat(chunks); + const enc = headers["content-encoding"] || headers["Content-Encoding"]; + const decoded = decodeBody(raw, enc); + const text = decoded.toString("utf8"); + // Skip empty / trivially-empty bodies + if (EMPTY_BODY_RE.test(text)) return; + // Strip content-encoding since body is now decoded + const cleanHeaders = { ...headers }; + delete cleanHeaders["content-encoding"]; + delete cleanHeaders["Content-Encoding"]; + const out = `STATUS: ${status}\nHEADERS: ${JSON.stringify(cleanHeaders, null, 2)}\n---BODY---\n${text}`; + fs.writeFileSync(file, out); + } catch { /* ignore */ } + }, + file + }; +} + +module.exports = { log, err, dumpRequest, createResponseDumper, clearDumpDir }; diff --git a/src/mitm/manager.js b/src/mitm/manager.js new file mode 100644 index 0000000..a9d6e6a --- /dev/null +++ b/src/mitm/manager.js @@ -0,0 +1,883 @@ +const { exec, spawn, execSync } = require("child_process"); +const path = require("path"); +const fs = require("fs"); +const os = require("os"); +const net = require("net"); +const https = require("https"); +const crypto = require("crypto"); +const { addDNSEntry, removeDNSEntry, removeAllDNSEntries, removeAllDNSEntriesSync, checkAllDNSStatus, TOOL_HOSTS, isSudoAvailable, isSudoPasswordRequired } = require("./dns/dnsConfig"); +const { isAdmin } = require("./winElevated.js"); + +const IS_WIN = process.platform === "win32"; +const IS_MAC = process.platform === "darwin"; +const { generateCert } = require("./cert/generate"); +const { installCert, uninstallCert } = require("./cert/install"); +const { isCertExpired } = require("./cert/rootCA"); +const { DATA_DIR, MITM_DIR } = require("./paths"); +const { log, err } = require("./logger"); +const { LSOF_BIN } = require("./config"); + +const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128"; + +function shellQuoteSingle(str) { + if (str == null || str === "") return "''"; + return `'${String(str).replace(/'/g, "'\\''")}'`; +} + +async function resolveMitmRouterBaseUrl() { + if (!_getSettings) return DEFAULT_MITM_ROUTER_BASE; + try { + const s = await _getSettings(); + const raw = s && s.mitmRouterBaseUrl != null ? String(s.mitmRouterBaseUrl).trim() : ""; + if (!raw) return DEFAULT_MITM_ROUTER_BASE; + const u = new URL(raw); + if (u.protocol !== "http:" && u.protocol !== "https:") return DEFAULT_MITM_ROUTER_BASE; + return raw.replace(/\/+$/, ""); + } catch { + return DEFAULT_MITM_ROUTER_BASE; + } +} + +const MITM_PORT = 443; +const MITM_WIN_NODE_PORT = 8443; +const PID_FILE = path.join(MITM_DIR, ".mitm.pid"); +const LOCK_FILE = path.join(MITM_DIR, ".mitm.lock"); + +const MITM_MAX_RESTARTS = 5; +const MITM_RESTART_DELAYS_MS = [5000, 10000, 20000, 30000, 60000]; +const MITM_RESTART_RESET_MS = 60000; + +let mitmRestartCount = 0; +let mitmLastStartTime = 0; +let mitmIsRestarting = false; + +function resolveBundledServerPath() { + if (process.env.MITM_SERVER_PATH) return process.env.MITM_SERVER_PATH; + const sibling = path.join(__dirname, "server.js"); + if (fs.existsSync(sibling)) return sibling; + const fromCwd = path.join(process.cwd(), "src", "mitm", "server.js"); + if (fs.existsSync(fromCwd)) return fromCwd; + const fromNext = path.join(process.cwd(), "..", "src", "mitm", "server.js"); + if (fs.existsSync(fromNext)) return fromNext; + return fromCwd; +} + +// Copy bundled server.js into DATA_DIR so MITM doesn't lock node_modules +// (prevents EBUSY on `npm i -g 9router@latest` while MITM is running). +function ensureRuntimeServer(bundledPath) { + try { + if (!bundledPath || !fs.existsSync(bundledPath)) return bundledPath; + + // Dev mode: source file has relative requires (./logger, ./config...), + // only the bundled file inside node_modules is self-contained + safe to copy. + if (!bundledPath.includes(`${path.sep}node_modules${path.sep}`)) { + return bundledPath; + } + + const runtimeDir = path.join(DATA_DIR, "runtime", "mitm"); + const runtimeServer = path.join(runtimeDir, "server.js"); + + // Skip copy if sizes match (bundle unchanged since last run) + if (fs.existsSync(runtimeServer)) { + try { + if (fs.statSync(bundledPath).size === fs.statSync(runtimeServer).size) return runtimeServer; + } catch { /* recopy */ } + } + + fs.mkdirSync(runtimeDir, { recursive: true }); + fs.copyFileSync(bundledPath, runtimeServer); + return runtimeServer; + } catch (e) { + try { log(`[MITM] runtime copy failed: ${e.message}`); } catch { /* ignore */ } + return bundledPath; + } +} + +const SERVER_PATH = ensureRuntimeServer(resolveBundledServerPath()); +const ENCRYPT_ALGO = "aes-256-gcm"; +const ENCRYPT_SALT = "9router-mitm-pwd"; + +function getProcessUsingPort443() { + try { + if (IS_WIN) { + const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command ` + + `"$c = Get-NetTCPConnection -LocalPort 443 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if ($c) { $c.OwningProcess } else { 0 }"`; + const pidStr = execSync(psCmd, { encoding: "utf8", windowsHide: true }).trim(); + const pid = parseInt(pidStr, 10); + if (pid && pid > 4) { + const tasklistResult = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { encoding: "utf8", windowsHide: true }); + const processMatch = tasklistResult.match(/"([^"]+)"/); + if (processMatch) return processMatch[1].replace(".exe", ""); + } + } else { + const result = execSync(`${LSOF_BIN} -i :443`, { encoding: "utf8", windowsHide: true }); + const lines = result.trim().split("\n"); + if (lines.length > 1) return lines[1].split(/\s+/)[0]; + } + } catch { + return null; + } + return null; +} + +let serverProcess = null; +let serverPid = null; + +function getCachedPassword() { return globalThis.__mitmSudoPassword || null; } +function setCachedPassword(pwd) { globalThis.__mitmSudoPassword = pwd; } + +function isProcessAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (err) { + return err.code === "EACCES"; + } +} + +function killProcess(pid, force = false, sudoPassword = null) { + if (IS_WIN) { + const flag = force ? "/F " : ""; + exec(`taskkill ${flag}/PID ${pid}`, { windowsHide: true }, () => { }); + } else { + const sig = force ? "SIGKILL" : "SIGTERM"; + const cmd = `pkill -${sig} -P ${pid} 2>/dev/null; kill -${sig} ${pid} 2>/dev/null`; + if (sudoPassword || isSudoAvailable()) { + const { execWithPassword } = require("./dns/dnsConfig"); + execWithPassword(cmd, sudoPassword || "").catch(() => exec(cmd, { windowsHide: true }, () => { })); + } else { + exec(cmd, { windowsHide: true }, () => { }); + } + } +} + +function deriveKey() { + try { + const { machineIdSync } = require("node-machine-id"); + const raw = machineIdSync(); + return crypto.createHash("sha256").update(raw + ENCRYPT_SALT).digest(); + } catch { + return crypto.createHash("sha256").update(ENCRYPT_SALT).digest(); + } +} + +function encryptPassword(plaintext) { + const key = deriveKey(); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv(ENCRYPT_ALGO, key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${iv.toString("hex")}:${tag.toString("hex")}:${encrypted.toString("hex")}`; +} + +function decryptPassword(stored) { + try { + const [ivHex, tagHex, dataHex] = stored.split(":"); + if (!ivHex || !tagHex || !dataHex) return null; + const key = deriveKey(); + const decipher = crypto.createDecipheriv(ENCRYPT_ALGO, key, Buffer.from(ivHex, "hex")); + decipher.setAuthTag(Buffer.from(tagHex, "hex")); + return decipher.update(Buffer.from(dataHex, "hex")) + decipher.final("utf8"); + } catch { + return null; + } +} + +let _getSettings = null; +let _updateSettings = null; + +function initDbHooks(getSettingsFn, updateSettingsFn) { + _getSettings = getSettingsFn; + _updateSettings = updateSettingsFn; +} + +async function saveMitmSettings(enabled, password) { + if (!_updateSettings) return; + try { + const updates = { mitmEnabled: enabled }; + if (password) updates.mitmSudoEncrypted = encryptPassword(password); + await _updateSettings(updates); + } catch (e) { + err(`Failed to save settings: ${e.message}`); + } +} + +async function clearEncryptedPassword() { + if (!_updateSettings) return; + try { + await _updateSettings({ mitmSudoEncrypted: null }); + } catch (e) { + err(`Failed to clear encrypted password: ${e.message}`); + } +} + +async function loadEncryptedPassword() { + if (!_getSettings) return null; + try { + const settings = await _getSettings(); + if (!settings.mitmSudoEncrypted) return null; + return decryptPassword(settings.mitmSudoEncrypted); + } catch { + return null; + } +} + +async function saveDnsToolState(tool, enabled) { + if (!_updateSettings || !_getSettings) return; + try { + const s = await _getSettings(); + const next = { ...(s.dnsToolEnabled || {}), [tool]: enabled }; + await _updateSettings({ dnsToolEnabled: next }); + } catch (e) { + err(`Failed to save DNS state: ${e.message}`); + } +} + +async function loadDnsToolState() { + if (!_getSettings) return {}; + try { + const s = await _getSettings(); + return s.dnsToolEnabled || {}; + } catch { + return {}; + } +} + +/** + * Re-apply DNS for tools previously enabled — called on app startup after MITM running. + */ +async function restoreToolDNS(sudoPassword) { + const state = await loadDnsToolState(); + const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword(); + for (const [tool, enabled] of Object.entries(state)) { + if (!enabled || !TOOL_HOSTS[tool]) continue; + try { + await addDNSEntry(tool, password); + } catch (e) { + err(`DNS ${tool}: restore failed — ${e.message}`); + } + } +} + +/** + * Check if user has privilege to mutate hosts file. + * Win: needs admin. Mac/Linux: root OR cached/encrypted sudo password. + */ +async function hasDnsPrivilege() { + if (IS_WIN) return isAdmin(); + if (isAdmin()) return true; + if (!isSudoPasswordRequired()) return true; + const pwd = getCachedPassword() || await loadEncryptedPassword(); + return !!pwd; +} + +function checkPort443Free() { + return new Promise((resolve) => { + const tester = net.createServer(); + tester.once("error", (err) => { + if (err.code === "EADDRINUSE") resolve("in-use"); + else resolve("no-permission"); + }); + tester.once("listening", () => { tester.close(() => resolve("free")); }); + tester.listen(MITM_PORT, "127.0.0.1"); + }); +} + +function getPort443Owner(sudoPassword) { + return new Promise((resolve) => { + if (IS_WIN) { + const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "` + + `$c = Get-NetTCPConnection -LocalPort 443 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; ` + + `if ($c) { $c.OwningProcess } else { 0 }"`; + exec(psCmd, { windowsHide: true }, (err, stdout) => { + if (err) return resolve(null); + const pid = parseInt(stdout.trim(), 10); + if (!pid || pid <= 4) return resolve(null); + exec(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { windowsHide: true }, (e2, out2) => { + const m = out2?.match(/"([^"]+)"/); + resolve({ pid, name: m ? m[1] : "unknown" }); + }); + }); + } else { + // Only find process actually LISTENING on TCP port 443 + exec(`${LSOF_BIN} -nP -iTCP:443 -sTCP:LISTEN -t`, { windowsHide: true }, (err, stdout) => { + if (err || !stdout?.trim()) return resolve(null); + const pid = parseInt(stdout.trim().split("\n")[0], 10); + if (!pid || isNaN(pid)) return resolve(null); + exec(`ps -p ${pid} -o comm=`, { windowsHide: true }, (e2, out2) => { + resolve({ pid, name: (out2?.trim() || "unknown") }); + }); + }); + } + }); +} + +async function killLeftoverMitm(sudoPassword) { + if (serverProcess && !serverProcess.killed) { + try { serverProcess.kill("SIGKILL"); } catch { /* ignore */ } + serverProcess = null; + serverPid = null; + } + try { + if (fs.existsSync(PID_FILE)) { + const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10); + if (savedPid && isProcessAlive(savedPid)) { + killProcess(savedPid, true, sudoPassword); + await new Promise(r => setTimeout(r, 500)); + } + fs.unlinkSync(PID_FILE); + } + } catch { /* ignore */ } + if (!IS_WIN && SERVER_PATH) { + try { + const escaped = SERVER_PATH.replace(/'/g, "'\\''"); + if (sudoPassword || isSudoAvailable()) { + const { execWithPassword } = require("./dns/dnsConfig"); + await execWithPassword(`pkill -SIGKILL -f "${escaped}" 2>/dev/null || true`, sudoPassword || "").catch(() => { }); + } else { + exec(`pkill -SIGKILL -f "${escaped}" 2>/dev/null || true`, { windowsHide: true }, () => { }); + } + await new Promise(r => setTimeout(r, 500)); + } catch { /* ignore */ } + } +} + +function pollMitmHealth(timeoutMs, port = MITM_PORT) { + return new Promise((resolve) => { + const deadline = Date.now() + timeoutMs; + const check = () => { + const req = https.request( + { hostname: "127.0.0.1", port, path: "/_mitm_health", method: "GET", rejectUnauthorized: false }, + (res) => { + let body = ""; + res.on("data", (d) => { body += d; }); + res.on("end", () => { + try { + const json = JSON.parse(body); + resolve(json.ok === true ? { ok: true, pid: json.pid || null } : null); + } catch { resolve(null); } + }); + } + ); + req.on("error", () => { + if (Date.now() < deadline) setTimeout(check, 500); + else resolve(null); + }); + req.end(); + }; + check(); + }); +} + +/** + * Get full MITM status including per-tool DNS status + */ +async function getMitmStatus() { + let running = serverProcess !== null && !serverProcess.killed; + let pid = serverPid; + + if (!running) { + try { + if (fs.existsSync(PID_FILE)) { + const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10); + if (savedPid && isProcessAlive(savedPid)) { + running = true; + pid = savedPid; + } else { + fs.unlinkSync(PID_FILE); + } + } + } catch { /* ignore */ } + } + + const dnsStatus = checkAllDNSStatus(); + const rootCACertPath = path.join(MITM_DIR, "rootCA.crt"); + const certExists = fs.existsSync(rootCACertPath); + const { checkCertInstalled } = require("./cert/install"); + const certTrusted = certExists ? await checkCertInstalled(rootCACertPath) : false; + + return { running, pid, certExists, certTrusted, dnsStatus }; +} + +async function scheduleMitmRestart(apiKey) { + if (mitmIsRestarting) return; + // Set guard synchronously before any await to prevent concurrent calls + // from passing the check above. + mitmIsRestarting = true; + + const aliveMs = Date.now() - mitmLastStartTime; + if (aliveMs >= MITM_RESTART_RESET_MS) mitmRestartCount = 0; + + if (mitmRestartCount >= MITM_MAX_RESTARTS) { + err("Max restart attempts reached. Giving up."); + mitmIsRestarting = false; + return; + } + + const attempt = mitmRestartCount; + const delay = MITM_RESTART_DELAYS_MS[Math.min(attempt, MITM_RESTART_DELAYS_MS.length - 1)]; + mitmRestartCount++; + + log(`Restarting in ${delay / 1000}s... (${mitmRestartCount}/${MITM_MAX_RESTARTS})`); + await new Promise((r) => setTimeout(r, delay)); + + try { + const settings = _getSettings ? await _getSettings() : null; + if (settings && !settings.mitmEnabled) { + log("MITM disabled, skipping restart"); + mitmIsRestarting = false; + return; + } + const password = getCachedPassword() || await loadEncryptedPassword(); + if (!password && !IS_WIN) { + err("No cached password, cannot auto-restart"); + mitmIsRestarting = false; + return; + } + await startServer(apiKey, password); + log("🔄 Restarted successfully"); + mitmRestartCount = 0; + mitmIsRestarting = false; + } catch (e) { + err(`Restart attempt ${mitmRestartCount}/${MITM_MAX_RESTARTS} failed: ${e.message}`); + mitmIsRestarting = false; + // Schedule next retry + scheduleMitmRestart(apiKey); + } +} + +/** + * Start MITM server only (cert + server, no DNS) + */ +async function killPort443Owner(owner, sudoPassword) { + if (!owner || !owner.pid) return; + if (IS_WIN) { + try { + execSync(`powershell -NonInteractive -WindowStyle Hidden -Command "Stop-Process -Id ${owner.pid} -Force -ErrorAction SilentlyContinue"`, { windowsHide: true }); + } catch { /* best effort */ } + } else { + try { + const { execWithPassword } = require("./dns/dnsConfig"); + if (sudoPassword || isSudoAvailable()) { + await execWithPassword(`kill -9 ${owner.pid}`, sudoPassword || ""); + } else { + execSync(`kill -9 ${owner.pid}`, { windowsHide: true }); + } + } catch { /* best effort */ } + } + await new Promise(r => setTimeout(r, 800)); +} + +async function startServer(apiKey, sudoPassword, forceKillPort443 = false) { + if (!serverProcess || serverProcess.killed) { + try { + if (fs.existsSync(PID_FILE)) { + const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10); + if (savedPid && isProcessAlive(savedPid)) { + serverPid = savedPid; + log(`♻️ Reusing existing process (PID: ${savedPid})`); + await saveMitmSettings(true, sudoPassword); + if (sudoPassword) setCachedPassword(sudoPassword); + return { running: true, pid: savedPid }; + } else { + fs.unlinkSync(PID_FILE); + } + } + } catch { /* ignore */ } + } + + if (serverProcess && !serverProcess.killed) { + throw new Error("MITM server is already running"); + } + + // Atomically claim lock to prevent concurrent startServer across processes. + // O_EXCL (flag: "wx") fails with EEXIST if the file already exists. + try { + fs.writeFileSync(LOCK_FILE, String(process.pid), { flag: "wx" }); + } catch (e) { + if (e.code === "EEXIST") { + let stale = false; + try { + const pid = parseInt(fs.readFileSync(LOCK_FILE, "utf-8").trim(), 10); + stale = !pid || !isProcessAlive(pid); + } catch { stale = true; } // unreadable lock → treat as stale + if (!stale) throw new Error("MITM server is already starting (lock contention)"); + try { fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ } + fs.writeFileSync(LOCK_FILE, String(process.pid), { flag: "wx" }); + } else throw e; + } + + try { + await killLeftoverMitm(sudoPassword); + + if (!IS_WIN) { + const portStatus = await checkPort443Free(); + if (portStatus === "in-use" || portStatus === "no-permission") { + const owner = await getPort443Owner(sudoPassword); + if (owner) { + const shortName = owner.name.includes("/") + ? owner.name.split("/").filter(Boolean).pop() + : owner.name; + if (forceKillPort443) { + log(`Killing process on port 443 (PID ${owner.pid}, name=${shortName})...`); + await killPort443Owner(owner, sudoPassword); + } else { + const e = new Error(`Port 443 is already in use by "${shortName}" (PID ${owner.pid}).`); + e.code = "PORT_443_BUSY"; + e.portOwner = { pid: owner.pid, name: shortName }; + throw e; + } + } + } + } + + // Step 1: Generate Root CA if missing or expired + const rootCACertPath = path.join(MITM_DIR, "rootCA.crt"); + const rootCAKeyPath = path.join(MITM_DIR, "rootCA.key"); + const certExists = fs.existsSync(rootCACertPath) && fs.existsSync(rootCAKeyPath); + + if (!certExists || isCertExpired(rootCACertPath)) { + if (certExists) { + // Uninstall expired cert from system store before regenerating + log("🔐 Cert expired — uninstalling old cert..."); + const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword(); + try { await uninstallCert(password, rootCACertPath); } catch { /* best effort */ } + } + log("🔐 Generating Root CA..."); + await generateCert(); + } + + // Step 1.5: Auto-install Root CA if not trusted yet + const { checkCertInstalled } = require("./cert/install"); + const rootCATrusted = await checkCertInstalled(rootCACertPath); + const linuxNoSystemTrust = !IS_WIN && !IS_MAC && !isSudoAvailable(); + if (!rootCATrusted) { + log("🔐 Cert: not trusted → installing..."); + const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword(); + if (linuxNoSystemTrust) { + log(`🔐 Cert: skipping system trust (no sudo). Install ${rootCACertPath} as a trusted CA on machines that use this proxy.`); + } else { + if (!password && isSudoPasswordRequired()) { + throw new Error("Sudo password required to install Root CA certificate"); + } + try { + await installCert(password, rootCACertPath); + log("🔐 Cert: ✅ trusted"); + } catch (e) { + throw new Error(`Failed to trust certificate: ${e.message}`); + } + } + } else { + log("🔐 Cert: already trusted ✅"); + } + + // Step 2: Spawn server (Root CA already installed in Step 1.5) + // Verify server.js exists — recopy if runtime file was deleted (antivirus/cleanup) + let effectiveServerPath = SERVER_PATH; + if (!effectiveServerPath || !fs.existsSync(effectiveServerPath)) { + log(`[MITM] server.js missing at ${effectiveServerPath} → recopying`); + effectiveServerPath = ensureRuntimeServer(resolveBundledServerPath()); + if (!effectiveServerPath || !fs.existsSync(effectiveServerPath)) { + throw new Error(`MITM server.js not found at ${effectiveServerPath}. Reinstall 9router.`); + } + } + const mitmRouterBase = await resolveMitmRouterBaseUrl(); + log(`🚀 Starting server... (router: ${mitmRouterBase})`); + if (IS_WIN) { + // Check port 443 — ask user before killing + const winOwner = await getPort443Owner(sudoPassword); + if (winOwner) { + if (forceKillPort443) { + log(`Killing process on port 443 (PID ${winOwner.pid}, name=${winOwner.name})...`); + await killPort443Owner(winOwner, sudoPassword); + } else { + const e = new Error(`Port 443 is already in use by "${winOwner.name}" (PID ${winOwner.pid}).`); + e.code = "PORT_443_BUSY"; + e.portOwner = { pid: winOwner.pid, name: winOwner.name }; + throw e; + } + } + + // Spawn directly — process already has admin rights + // cwd=tmpdir so process doesn't lock the install dir on Windows (EBUSY on update) + serverProcess = spawn( + process.execPath, + [effectiveServerPath], + { + detached: false, + windowsHide: true, + cwd: os.tmpdir(), + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + ROUTER_API_KEY: apiKey, + NODE_ENV: "production", + MITM_ROUTER_BASE: mitmRouterBase, + }, + } + ); + + if (_updateSettings) await _updateSettings({ mitmCertInstalled: true }).catch(() => { }); + } else if (isSudoAvailable()) { + // Pass HOME explicitly so os.homedir() resolves to the unprivileged user's home + // instead of /root when sudo resets the environment. + const inlineCmd = [ + `HOME=${shellQuoteSingle(os.homedir())}`, + `ROUTER_API_KEY=${shellQuoteSingle(apiKey)}`, + `MITM_ROUTER_BASE=${shellQuoteSingle(mitmRouterBase)}`, + "NODE_ENV=production", + shellQuoteSingle(process.execPath), + shellQuoteSingle(effectiveServerPath), + ].join(" "); + serverProcess = spawn( + "sudo", ["-S", "-E", "sh", "-c", inlineCmd], + { detached: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] } + ); + serverProcess.stdin.write(`${sudoPassword}\n`); + serverProcess.stdin.end(); + } else { + // Docker/minimal images: no sudo — same as Windows-style direct spawn + serverProcess = spawn(process.execPath, [effectiveServerPath], { + detached: false, + windowsHide: true, + cwd: os.tmpdir(), + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + ROUTER_API_KEY: apiKey, + NODE_ENV: "production", + MITM_ROUTER_BASE: mitmRouterBase, + }, + }); + } + + if (serverProcess) { + serverPid = serverProcess.pid; + fs.writeFileSync(PID_FILE, String(serverPid)); + mitmLastStartTime = Date.now(); + } + + // Set NODE_EXTRA_CA_CERTS so Node-based GUI apps (Electron/AG language_server) trust MITM cert + if (IS_MAC) { + const rootCAPath = path.join(MITM_DIR, "rootCA.crt"); + if (fs.existsSync(rootCAPath)) { + exec(`launchctl setenv NODE_EXTRA_CA_CERTS "${rootCAPath}"`, { windowsHide: true }, (e) => { + if (e) log(`[launchctl] Failed to set NODE_EXTRA_CA_CERTS: ${e.message}`); + else log(`[launchctl] NODE_EXTRA_CA_CERTS set to ${rootCAPath}`); + }); + } + } else if (IS_WIN) { + const rootCAPath = path.join(MITM_DIR, "rootCA.crt"); + if (fs.existsSync(rootCAPath)) { + exec(`setx NODE_EXTRA_CA_CERTS "${rootCAPath}"`, { windowsHide: true }, (e) => { + if (e) log(`[setx] Failed to set NODE_EXTRA_CA_CERTS: ${e.message}`); + else log(`[setx] NODE_EXTRA_CA_CERTS set for current user`); + }); + } + } + + let startError = null; + if (serverProcess) { + serverProcess.stdout.on("data", (data) => { + // server.js already formats its own logs — print as-is + process.stdout.write(data); + }); + serverProcess.stderr.on("data", (data) => { + const msg = data.toString().trim(); + // Mac/Linux: filter sudo password prompt noise + if (msg && (IS_WIN || (!msg.includes("Password:") && !msg.includes("password for")))) { + err(msg); + startError = msg; + } + // Detect wrong/missing password — clear cache and stop retry loop + if (!IS_WIN && (msg.includes("incorrect password") || msg.includes("no password was provided"))) { + setCachedPassword(null); + clearEncryptedPassword(); + mitmIsRestarting = true; // prevent scheduleMitmRestart from firing + } + }); + serverProcess.on("exit", (code) => { + log(`Server exited (code: ${code})`); + serverProcess = null; + serverPid = null; + try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ } + try { fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ } + // Auto-restart on unexpected exit + if (code !== 0 && !mitmIsRestarting) scheduleMitmRestart(apiKey); + }); + } + + const health = await pollMitmHealth(8000, MITM_PORT); + if (!health) { + if (serverProcess && !serverProcess.killed) { try { serverProcess.kill(); } catch { /* ignore */ } serverProcess = null; } + const processUsing443 = getProcessUsingPort443(); + const portInfo = processUsing443 ? ` Port 443 already in use by ${processUsing443}.` : ""; + const reason = startError || `Check sudo password or port 443 access.${portInfo}`; + throw new Error(`MITM server failed to start. ${reason}`); + } + + if (_updateSettings) await _updateSettings({ mitmCertInstalled: true }).catch(() => { }); + + log(`✅ Server healthy (PID: ${serverPid || health.pid})`); + + // Log DNS status per tool + const dnsStatus = checkAllDNSStatus(); + for (const [tool, active] of Object.entries(dnsStatus)) { + log(`🌐 DNS ${tool}: ${active ? "✅ active" : "❌ inactive"}`); + } + + await saveMitmSettings(true, sudoPassword); + if (sudoPassword) setCachedPassword(sudoPassword); + + // Server is healthy — remove lock file (PID file persists as the marker) + try { fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ } + + return { running: true, pid: serverPid }; + } catch (e) { + // Clean up lock on any failure + try { fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ } + throw e; + } +} + +/** + * Stop MITM server — removes ALL tool DNS entries first, then kills server + */ +async function stopServer(sudoPassword) { + // Prevent auto-restart from triggering on intentional stop + mitmIsRestarting = true; + mitmRestartCount = 0; + log("⏹ Stopping server..."); + + // Kill server process + const proc = serverProcess; + const pidToKill = proc && !proc.killed + ? proc.pid + : (() => { try { return parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10); } catch { return null; } })(); + + if (pidToKill && isProcessAlive(pidToKill)) { + log(`Killing server (PID: ${pidToKill})...`); + killProcess(pidToKill, false, sudoPassword); + await new Promise(r => setTimeout(r, 1000)); + if (isProcessAlive(pidToKill)) killProcess(pidToKill, true, sudoPassword); + } + serverProcess = null; + serverPid = null; + + if (IS_WIN) { + const hostsFile = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "drivers", "etc", "hosts"); + const allHosts = Object.values(TOOL_HOSTS).flat(); + try { + const { isAdmin, runElevatedPowerShell, quotePs } = require("./winElevated.js"); + if (isAdmin()) { + // Direct fs write — bypass PowerShell to avoid parser pitfalls + const content = fs.readFileSync(hostsFile, "utf8"); + const filtered = content.split(/\r?\n/).filter(l => !allHosts.some(h => l.includes(h))).join("\r\n"); + const next = filtered.replace(/[\r\n\s]+$/g, "") + "\r\n"; + if (next !== content) fs.writeFileSync(hostsFile, next, "utf8"); + try { require("child_process").execSync("ipconfig /flushdns", { windowsHide: true, stdio: "ignore" }); } catch { /* ignore */ } + log("🌐 DNS: ✅ all tool hosts removed"); + } else { + const hostsList = allHosts.map(quotePs).join(","); + const script = ` + $hosts = @(${hostsList}) + $lines = Get-Content -LiteralPath ${quotePs(hostsFile)} + $filtered = $lines | Where-Object { + $line = $_ + -not ($hosts | Where-Object { $line -match [regex]::Escape($_) }) + } + Set-Content -LiteralPath ${quotePs(hostsFile)} -Value $filtered + ipconfig /flushdns | Out-Null + `; + await runElevatedPowerShell(script); + } + } catch (e) { err(`Failed to clean hosts: ${e.message}`); } + } else { + await removeAllDNSEntries(sudoPassword); + } + + // Unset NODE_EXTRA_CA_CERTS so apps don't keep trusting stale MITM cert + if (IS_MAC) { + exec(`launchctl unsetenv NODE_EXTRA_CA_CERTS`, { windowsHide: true }, (e) => { + if (e) log(`[launchctl] Failed to unset NODE_EXTRA_CA_CERTS: ${e.message}`); + else log(`[launchctl] NODE_EXTRA_CA_CERTS unset`); + }); + } else if (IS_WIN) { + exec(`reg delete HKCU\\Environment /F /V NODE_EXTRA_CA_CERTS`, { windowsHide: true }, (e) => { + if (e) log(`[reg] Failed to unset NODE_EXTRA_CA_CERTS: ${e.message}`); + else log(`[reg] NODE_EXTRA_CA_CERTS unset`); + }); + } + + try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ } + try { fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ } + await saveMitmSettings(false, null); + mitmIsRestarting = false; + + return { running: false, pid: null }; +} + +/** + * Enable DNS for a specific tool (requires server running) + */ +async function enableToolDNS(tool, sudoPassword) { + const status = await getMitmStatus(); + if (!status.running) throw new Error("MITM server is not running. Start the server first."); + + const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword(); + await addDNSEntry(tool, password); + await saveDnsToolState(tool, true); + return { success: true }; +} + +/** + * Disable DNS for a specific tool + */ +async function disableToolDNS(tool, sudoPassword) { + const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword(); + await removeDNSEntry(tool, password); + await saveDnsToolState(tool, false); + return { success: true }; +} + +/** + * Install Root CA to system trust store (standalone, no server start) + */ +async function trustCert(sudoPassword) { + const rootCACertPath = path.join(MITM_DIR, "rootCA.crt"); + if (!fs.existsSync(rootCACertPath)) throw new Error("Root CA not found. Start server first to generate it."); + const { installCert } = require("./cert/install"); + if (!IS_WIN && !IS_MAC && !isSudoAvailable()) { + log(`🔐 Cert: system trust unavailable (no sudo). Use file: ${rootCACertPath}`); + return; + } + const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword(); + if (!password && isSudoPasswordRequired()) throw new Error("Sudo password required to trust certificate"); + await installCert(password, rootCACertPath); + if (password) setCachedPassword(password); +} + +// Legacy aliases for backward compatibility +const startMitm = startServer; +const stopMitm = stopServer; + +module.exports = { + getMitmStatus, + startServer, + stopServer, + enableToolDNS, + disableToolDNS, + trustCert, + // Legacy + startMitm, + stopMitm, + getCachedPassword, + setCachedPassword, + loadEncryptedPassword, + clearEncryptedPassword, + isSudoPasswordRequired, + initDbHooks, + restoreToolDNS, + hasDnsPrivilege, + removeAllDNSEntriesSync, +}; diff --git a/src/mitm/paths.js b/src/mitm/paths.js new file mode 100644 index 0000000..892c225 --- /dev/null +++ b/src/mitm/paths.js @@ -0,0 +1,32 @@ +const fs = require("fs"); +const path = require("path"); +const os = require("os"); + +const APP_NAME = "9router"; + +function defaultDir() { + if (process.platform === "win32") { + return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), APP_NAME); + } + return path.join(os.homedir(), `.${APP_NAME}`); +} + +function getDataDir() { + const configured = process.env.DATA_DIR; + if (!configured) return defaultDir(); + try { + fs.mkdirSync(configured, { recursive: true }); + return configured; + } catch (e) { + if (e?.code === "EACCES" || e?.code === "EPERM") { + console.warn(`[DATA_DIR] '${configured}' not writable → fallback ~/.${APP_NAME}`); + return defaultDir(); + } + throw e; + } +} + +const DATA_DIR = getDataDir(); +const MITM_DIR = path.join(DATA_DIR, "mitm"); + +module.exports = { DATA_DIR, MITM_DIR }; diff --git a/src/mitm/server.js b/src/mitm/server.js new file mode 100644 index 0000000..d675e6a --- /dev/null +++ b/src/mitm/server.js @@ -0,0 +1,440 @@ +const https = require("https"); +const http2 = require("http2"); +const tls = require("tls"); +const fs = require("fs"); +const path = require("path"); +const dns = require("dns"); +const { promisify } = require("util"); +const { execSync } = require("child_process"); +const { log, err, dumpRequest, createResponseDumper, clearDumpDir } = require("./logger"); +const { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, getToolForHost } = require("./config"); +const { DATA_DIR, MITM_DIR } = require("./paths"); +const { generateCert, getCertForDomain } = require("./cert/generate"); +const { getMitmAlias } = require("./dbReader"); +const { applyAntigravityIdeVersionOverride } = require("./antigravityIdeVersion"); +const LOCAL_PORT = 443; +const IS_WIN = process.platform === "win32"; +const ENABLE_FILE_LOG = IS_DEV; + +// Clear stale dump files on every MITM start (prevents unbounded disk usage) +clearDumpDir(); +const INTERNAL_REQUEST_HEADER = { name: "x-request-source", value: "local" }; + +// Host rewrite for upstream forward: PROD cloudcode-pa is rate-limited (429), +// daily-cloudcode-pa (dev endpoint) accepts same body+token. Same trick as open-sse. +const HOST_REWRITE = { + "cloudcode-pa.googleapis.com": "daily-cloudcode-pa.googleapis.com", +}; + +const handlers = { + antigravity: require("./handlers/antigravity"), + copilot: require("./handlers/copilot"), + kiro: require("./handlers/kiro"), + cursor: require("./handlers/cursor"), +}; + +// ── SSL / SNI ───────────────────────────────────────────────── + +const certCache = new Map(); +let rootCAPem; + +function sniCallback(servername, cb) { + try { + if (certCache.has(servername)) return cb(null, certCache.get(servername)); + const certData = getCertForDomain(servername); + if (!certData) return cb(new Error(`Failed to generate cert for ${servername}`)); + const ctx = require("tls").createSecureContext({ + key: certData.key, + cert: `${certData.cert}\n${rootCAPem}` + }); + certCache.set(servername, ctx); + cb(null, ctx); + } catch (e) { + err(`SNI error for ${servername}: ${e.message}`); + cb(e); + } +} + +let sslOptions; +try { + if (!fs.existsSync(path.join(MITM_DIR, "rootCA.key")) || !fs.existsSync(path.join(MITM_DIR, "rootCA.crt"))) { + log("Root CA missing, generating..."); + generateCert(); + } + + const rootKey = fs.readFileSync(path.join(MITM_DIR, "rootCA.key")); + const rootCert = fs.readFileSync(path.join(MITM_DIR, "rootCA.crt")); + rootCAPem = rootCert.toString("utf8"); + sslOptions = { key: rootKey, cert: rootCert, SNICallback: sniCallback }; +} catch (e) { + err(`Root CA not found: ${e.message}`); + process.exit(1); +} + +// ── Helpers ─────────────────────────────────────────────────── + +const cachedTargetIPs = {}; +const CACHE_TTL_MS = 5 * 60 * 1000; + +async function resolveTargetIP(hostname) { + const cached = cachedTargetIPs[hostname]; + if (cached && Date.now() - cached.ts < CACHE_TTL_MS) return cached.ip; + const resolver = new dns.Resolver(); + resolver.setServers(["8.8.8.8"]); + const resolve4 = promisify(resolver.resolve4.bind(resolver)); + const addresses = await resolve4(hostname); + cachedTargetIPs[hostname] = { ip: addresses[0], ts: Date.now() }; + return cachedTargetIPs[hostname].ip; +} + +function collectBodyRaw(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", chunk => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", reject); + }); +} + +// Extract model from URL path (Gemini), body (OpenAI/Anthropic), or Kiro conversationState +function extractModel(url, body) { + const urlMatch = url.match(/\/models\/([^/:]+)/); + if (urlMatch) return urlMatch[1]; + + // Skip parsing if body is binary (AWS EventStream, Protocol Buffers, etc.) + if (isBinaryData(body)) return null; + + try { + const parsed = JSON.parse(body.toString()); + if (parsed.conversationState) { + return parsed.conversationState.currentMessage?.userInputMessage?.modelId || null; + } + return parsed.model || null; + } catch { return null; } +} + +// Detect binary data vs JSON text +function isBinaryData(buffer) { + if (!buffer || buffer.length === 0) return false; + // AWS EventStream signature: first 4 bytes = frame length (big-endian uint32) + // Check for non-printable chars in first 100 bytes (common in binary protocols) + const sample = buffer.slice(0, Math.min(100, buffer.length)); + let nonPrintable = 0; + for (let i = 0; i < sample.length; i++) { + const byte = sample[i]; + // Count non-ASCII printable chars (excluding whitespace) + if (byte < 0x20 && byte !== 0x09 && byte !== 0x0A && byte !== 0x0D) { + nonPrintable++; + } + if (byte > 0x7E) nonPrintable++; + } + // If >30% non-printable, treat as binary + return (nonPrintable / sample.length) > 0.3; +} + +function getMappedModel(tool, model) { + if (!model) return null; + try { + const aliases = getMitmAlias(tool); + if (!aliases) return null; + // Normalize via synonym map (e.g., public AG names -> backend model ids) + const normalizedModel = String(model).replace(/^models\//, ""); + const lookup = MODEL_SYNONYMS?.[tool]?.[normalizedModel] || normalizedModel; + if (aliases[lookup]) return aliases[lookup]; + // Prefix match fallback + const prefixKey = Object.keys(aliases).find(k => k && aliases[k] && (lookup.startsWith(k) || k.startsWith(lookup))); + if (prefixKey) return aliases[prefixKey]; + // Pattern fallback: catches AG renamed variants (e.g. deprecated pro IDs → gemini-pro-agent) + const patterns = MODEL_PATTERNS?.[tool] || []; + for (const { match, alias } of patterns) { + if (match.test(lookup) && aliases[alias]) return aliases[alias]; + } + return null; + } catch { return null; } +} + +/** + * Forward request to real upstream. + * Optional onResponse(rawBuffer) callback — if provided, tees the response + * so it's both forwarded to client AND passed to the callback for inspection. + * Also tees full stream into a dump file when ENABLE_FILE_LOG is on. + */ +async function passthrough(req, res, bodyBuffer, onResponse) { + const originalHost = (req.headers.host || TARGET_HOSTS[0]).split(":")[0]; + // Only rewrite host for chat endpoints — daily-cloudcode-pa rejects auth/login requests + const isChatEndpoint = req.url.includes(":generateContent") || req.url.includes(":streamGenerateContent"); + const targetHost = isChatEndpoint ? (HOST_REWRITE[originalHost] || originalHost) : originalHost; + const dumper = ENABLE_FILE_LOG ? createResponseDumper(req, "passthrough") : null; + + const tool = getToolForHost(req.headers.host); + const versionOverride = tool === "antigravity" + ? applyAntigravityIdeVersionOverride(bodyBuffer, req.headers) + : { bodyBuffer, headers: req.headers }; + const bodyForForwarding = versionOverride.bodyBuffer; + const headersForForwarding = { ...versionOverride.headers, host: targetHost }; + if (bodyForForwarding !== bodyBuffer) { + headersForForwarding["content-length"] = String(bodyForForwarding.length); + } + + // ALPN negotiate: try HTTP/2 first (like browsers/mitmweb), fallback HTTP/1.1 + try { + const proto = await negotiateAlpn(targetHost); + if (proto === "h2") { + return await passthroughHttp2(req, res, bodyForForwarding, headersForForwarding, targetHost, onResponse, dumper); + } + } catch (e) { + err(`[mitm] ALPN negotiate failed: ${e.message}, fallback to HTTP/1.1`); + } + + return passthroughHttps(req, res, bodyForForwarding, headersForForwarding, targetHost, onResponse, dumper); +} + +// ── ALPN negotiation cache ──────────────────────────────────── +const alpnCache = new Map(); // host → "h2" | "http/1.1" +async function negotiateAlpn(host) { + if (alpnCache.has(host)) return alpnCache.get(host); + const ip = await resolveTargetIP(host); + return new Promise((resolve, reject) => { + const socket = tls.connect({ + host: ip, port: 443, servername: host, + ALPNProtocols: ["h2", "http/1.1"], rejectUnauthorized: false, + }, () => { + const proto = socket.alpnProtocol || "http/1.1"; + alpnCache.set(host, proto); + log(`🔗 [mitm] ALPN ${host} → ${proto}`); + socket.end(); + resolve(proto); + }); + socket.once("error", reject); + socket.setTimeout(5000, () => { socket.destroy(new Error("ALPN timeout")); }); + }); +} + +// HTTP/2 passthrough using node:http2 native +async function passthroughHttp2(req, res, bodyBuffer, headers, targetHost, onResponse, dumper) { + const targetIP = await resolveTargetIP(targetHost); + // HTTP/2 pseudo-headers required; strip HTTP/1.1-only headers + const h2Headers = {}; + for (const [k, v] of Object.entries(headers)) { + const lk = k.toLowerCase(); + if (lk === "host" || lk === "connection" || lk === "keep-alive" || + lk === "transfer-encoding" || lk === "upgrade" || lk === "proxy-connection") continue; + h2Headers[lk] = v; + } + h2Headers[":method"] = req.method; + h2Headers[":path"] = req.url; + h2Headers[":scheme"] = "https"; + h2Headers[":authority"] = targetHost; + + return new Promise((resolve) => { + const client = http2.connect(`https://${targetHost}`, { + createConnection: () => tls.connect({ + host: targetIP, port: 443, servername: targetHost, + ALPNProtocols: ["h2"], rejectUnauthorized: false, + }), + }); + client.once("error", (e) => { + err(`[mitm] http2 client error: ${e.message}`); + if (dumper) { dumper.writeChunk(`\n[ERROR h2] ${e.message}\n`); dumper.end(); } + if (!res.headersSent) res.writeHead(502); + if (!res.writableEnded) res.end("Bad Gateway"); + try { client.close(); } catch {} + resolve(); + }); + + const stream = client.request(h2Headers, { endStream: bodyBuffer.length === 0 }); + if (bodyBuffer.length > 0) stream.end(bodyBuffer); + + stream.once("response", (responseHeaders) => { + const status = responseHeaders[":status"]; + // Filter pseudo-headers + connection-specific + const outHeaders = {}; + for (const [k, v] of Object.entries(responseHeaders)) { + if (k.startsWith(":")) continue; + if (k === "connection" || k === "keep-alive" || k === "transfer-encoding") continue; + outHeaders[k] = v; + } + res.writeHead(status, outHeaders); + if (dumper) dumper.writeHeader(status, outHeaders); + + const chunks = []; + stream.on("data", chunk => { + if (dumper) dumper.writeChunk(chunk); + if (onResponse) chunks.push(chunk); + res.write(chunk); + }); + stream.on("end", () => { + if (dumper) dumper.end(); + if (!res.writableEnded) res.end(); + if (onResponse) try { onResponse(Buffer.concat(chunks), outHeaders); } catch {} + try { client.close(); } catch {} + resolve(); + }); + }); + stream.once("error", (e) => { + err(`[mitm] http2 stream error: ${e.message}`); + if (dumper) { dumper.writeChunk(`\n[ERROR h2-stream] ${e.message}\n`); dumper.end(); } + if (!res.headersSent) res.writeHead(502); + if (!res.writableEnded) res.end(); + try { client.close(); } catch {} + resolve(); + }); + }); +} + +// Fallback: raw https.request HTTP/1.1 with custom DNS (bypasses /etc/hosts MITM loop) +async function passthroughHttps(req, res, bodyBuffer, headers, targetHost, onResponse, dumper) { + const targetIP = await resolveTargetIP(targetHost); + const forwardReq = https.request({ + hostname: targetIP, + port: 443, + path: req.url, + method: req.method, + headers, + servername: targetHost, + rejectUnauthorized: false + }, (forwardRes) => { + res.writeHead(forwardRes.statusCode, forwardRes.headers); + if (dumper) dumper.writeHeader(forwardRes.statusCode, forwardRes.headers); + + if (!onResponse && !dumper) { + forwardRes.pipe(res); + return; + } + + const chunks = []; + forwardRes.on("data", chunk => { + if (dumper) dumper.writeChunk(chunk); + if (onResponse) chunks.push(chunk); + res.write(chunk); + }); + forwardRes.on("end", () => { + if (dumper) dumper.end(); + res.end(); + if (onResponse) try { onResponse(Buffer.concat(chunks), forwardRes.headers); } catch { /* ignore */ } + }); + }); + + forwardReq.on("error", (e) => { + err(`Passthrough error: ${e.message}`); + if (dumper) { dumper.writeChunk(`\n[ERROR] ${e.message}\n`); dumper.end(); } + if (!res.headersSent) res.writeHead(502); + res.end("Bad Gateway"); + }); + + if (bodyBuffer.length > 0) forwardReq.write(bodyBuffer); + forwardReq.end(); +} + +// ── Request handler ─────────────────────────────────────────── + +const server = https.createServer(sslOptions, async (req, res) => { + try { + if (req.url === "/_mitm_health") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, pid: process.pid })); + return; + } + + const bodyBuffer = await collectBodyRaw(req); + if (ENABLE_FILE_LOG) dumpRequest(req, bodyBuffer, "raw"); + + // Anti-loop: skip requests from 9Router + if (req.headers[INTERNAL_REQUEST_HEADER.name] === INTERNAL_REQUEST_HEADER.value) { + return passthrough(req, res, bodyBuffer); + } + + const tool = getToolForHost(req.headers.host); + if (!tool) return passthrough(req, res, bodyBuffer); + + const patterns = URL_PATTERNS[tool] || []; + const isChat = patterns.some(p => req.url.includes(p)); + if (!isChat) return passthrough(req, res, bodyBuffer); + + // Cursor uses binary proto — model extraction not possible at this layer. + // Delegate directly to handler which decodes proto internally. + if (tool === "cursor") { + return handlers[tool].intercept(req, res, bodyBuffer, null, passthrough); + } + + const model = extractModel(req.url, bodyBuffer); + + // Intentional passthrough: some models must never be re-routed (e.g. Antigravity + // tab-autocomplete) so latency-critical inline completion stays native. Silent — this + // is by design, not a leak, and fires per keystroke. See MODEL_NO_MAP in config.js. + if (model && (MODEL_NO_MAP[tool] || []).some((re) => re.test(model))) { + return passthrough(req, res, bodyBuffer); + } + + const mappedModel = getMappedModel(tool, model); + if (!mappedModel) { + return passthrough(req, res, bodyBuffer); + } + + return handlers[tool].intercept(req, res, bodyBuffer, mappedModel, passthrough); + } catch (e) { + err(`Unhandled error: ${e.message}`); + if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: { message: e.message, type: "mitm_error" } })); + } +}); + +// Kill only processes LISTENING on LOCAL_PORT (not outbound connections) +function killPort(port) { + try { + let pidList = []; + if (IS_WIN) { + const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command ` + + `"Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess"`; + const out = execSync(psCmd, { encoding: "utf-8", windowsHide: true }).trim(); + if (!out) return; + pidList = out.split(/\r?\n/).map(s => s.trim()).filter(p => p && Number(p) !== process.pid && Number(p) > 4); + } else { + const out = execSync(`${LSOF_BIN} -nP -iTCP:${port} -sTCP:LISTEN -t`, { encoding: "utf-8", windowsHide: true }).trim(); + if (!out) return; + pidList = out.split("\n").filter(p => p && Number(p) !== process.pid); + } + if (pidList.length === 0) return; + pidList.forEach(pid => { + try { + if (IS_WIN) execSync(`taskkill /F /PID ${pid}`, { windowsHide: true }); + else process.kill(Number(pid), "SIGKILL"); + } catch (e) { + err(`Failed to kill PID ${pid}: ${e.message}`); + } + }); + log(`Killed ${pidList.length} process(es) on port ${port}`); + } catch (e) { + if (e.status !== 1) throw e; + } +} + +try { + killPort(LOCAL_PORT); +} catch (e) { + err(`Cannot kill process on port ${LOCAL_PORT}: ${e.message}`); + process.exit(1); +} + +server.listen(LOCAL_PORT, () => log(`🚀 Server ready on :${LOCAL_PORT}`)); + +server.on("error", (e) => { + if (e.code === "EADDRINUSE") err(`Port ${LOCAL_PORT} already in use`); + else if (e.code === "EACCES") err(`Permission denied for port ${LOCAL_PORT}`); + else err(e.message); + process.exit(1); +}); + +const { removeAllDNSEntriesSync } = require("./dns/dnsConfig"); +let isShuttingDown = false; +const shutdown = () => { + if (isShuttingDown) return; + isShuttingDown = true; + // Strip tool hosts from /etc/hosts so other apps aren't broken after exit + removeAllDNSEntriesSync(); + const forceExit = setTimeout(() => process.exit(0), 1500); + server.close(() => { clearTimeout(forceExit); process.exit(0); }); +}; +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); +if (process.platform === "win32") process.on("SIGBREAK", shutdown); diff --git a/src/mitm/winElevated.js b/src/mitm/winElevated.js new file mode 100644 index 0000000..0ff6aa5 --- /dev/null +++ b/src/mitm/winElevated.js @@ -0,0 +1,81 @@ +const { exec, execSync } = require("child_process"); + +const IS_WIN = process.platform === "win32"; + +/** + * Detect if current Windows process has admin rights (no UAC popup needed). + * Uses `net session` which only succeeds when elevated. + */ +function isAdmin() { + if (IS_WIN) { + try { + execSync("net session >nul 2>&1", { windowsHide: true, stdio: "ignore" }); + return true; + } catch { + return false; + } + } + return typeof process.getuid === "function" && process.getuid() === 0; +} + +/** + * Quote a string safely for PowerShell single-quoted literal. + */ +function quotePs(value) { + return `'${String(value).replace(/'/g, "''")}'`; +} + +/** + * Run PowerShell script — escalated via UAC popup if not already admin. + * Returns Promise resolving on exit code 0, rejecting otherwise. + * + * IMPORTANT: each call triggers ONE UAC popup. Batch multiple admin tasks + * into a single script string to minimize popups. + */ +function runElevatedPowerShell(script) { + if (!IS_WIN) return Promise.reject(new Error("Windows-only")); + + const encoded = Buffer.from(script, "utf16le").toString("base64"); + + // If already admin, run directly — zero popup + if (isAdmin()) { + return new Promise((resolve, reject) => { + exec( + `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encoded}`, + { windowsHide: true }, + (error, stdout, stderr) => { + if (error) reject(new Error(stderr || error.message)); + else resolve(stdout); + } + ); + }); + } + + // Not admin — wrap with Start-Process -Verb RunAs (UAC popup) + const wrapper = ` + $proc = Start-Process powershell -ArgumentList @( + '-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass', + '-WindowStyle','Hidden','-EncodedCommand','${encoded}' + ) -Verb RunAs -Wait -PassThru -WindowStyle Hidden; + if ($proc.ExitCode -ne 0) { throw "Elevated command exited with code $($proc.ExitCode)" } + `; + + return new Promise((resolve, reject) => { + exec( + `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command ${quotePs(wrapper)}`, + { windowsHide: true }, + (error, stdout, stderr) => { + if (error) { + const msg = stderr || error.message; + if (msg.includes("canceled by the user") || msg.includes("operation was canceled")) { + reject(new Error("User canceled UAC prompt")); + } else { + reject(new Error(msg)); + } + } else resolve(stdout); + } + ); + }); +} + +module.exports = { isAdmin, runElevatedPowerShell, quotePs }; diff --git a/src/models/index.js b/src/models/index.js new file mode 100644 index 0000000..99444f6 --- /dev/null +++ b/src/models/index.js @@ -0,0 +1,38 @@ +// Database Models - Export all from localDb +export { + getProviderConnections, + getProviderConnectionById, + createProviderConnection, + updateProviderConnection, + deleteProviderConnection, + getProviderNodes, + getProviderNodeById, + createProviderNode, + updateProviderNode, + deleteProviderNode, + getProxyPools, + getProxyPoolById, + createProxyPool, + updateProxyPool, + deleteProxyPool, + deleteProviderConnectionsByProvider, + getCombos, + getComboById, + getComboByName, + createCombo, + updateCombo, + deleteCombo, + getModelAliases, + setModelAlias, + deleteModelAlias, + getCustomModels, + addCustomModel, + deleteCustomModel, + getMitmAlias, + setMitmAliasAll, + getApiKeys, + createApiKey, + deleteApiKey, + validateApiKey, + isCloudEnabled, +} from "@/lib/localDb"; diff --git a/src/proxy.js b/src/proxy.js new file mode 100644 index 0000000..566dd14 --- /dev/null +++ b/src/proxy.js @@ -0,0 +1,9 @@ +import { proxy as dashboardProxy } from "./dashboardGuard"; + +export default async function proxy(request) { + return dashboardProxy(request); +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon\\.ico).*)"], +}; diff --git a/src/shared/components/AddCustomEmbeddingModal.js b/src/shared/components/AddCustomEmbeddingModal.js new file mode 100644 index 0000000..db17fe7 --- /dev/null +++ b/src/shared/components/AddCustomEmbeddingModal.js @@ -0,0 +1,183 @@ +"use client"; + +import { useState, useEffect } from "react"; +import PropTypes from "prop-types"; +import { Modal, Input, Button, Badge } from "@/shared/components"; + +const DEFAULT_BASE_URL = "https://api.openai.com/v1"; + +// Dual-mode modal: edit when `node` provided, add otherwise +export default function AddCustomEmbeddingModal({ isOpen, onClose, onCreated, onSaved, node }) { + const isEdit = !!node; + const [formData, setFormData] = useState({ + name: "", + prefix: "", + baseUrl: DEFAULT_BASE_URL, + }); + const [submitting, setSubmitting] = useState(false); + const [checkKey, setCheckKey] = useState(""); + const [checkModelId, setCheckModelId] = useState(""); + const [validating, setValidating] = useState(false); + const [validationResult, setValidationResult] = useState(null); + + useEffect(() => { + if (!isOpen) return; + setValidationResult(null); + setCheckKey(""); + setCheckModelId(""); + if (isEdit) { + setFormData({ + name: node.name || "", + prefix: node.prefix || "", + baseUrl: node.baseUrl || DEFAULT_BASE_URL, + }); + } else { + setFormData({ name: "", prefix: "", baseUrl: DEFAULT_BASE_URL }); + } + }, [isOpen, isEdit, node]); + + const handleSubmit = async () => { + if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return; + setSubmitting(true); + try { + const url = isEdit ? `/api/provider-nodes/${node.id}` : "/api/provider-nodes"; + const method = isEdit ? "PUT" : "POST"; + const payload = { + name: formData.name, + prefix: formData.prefix, + baseUrl: formData.baseUrl, + }; + if (!isEdit) payload.type = "custom-embedding"; + + const res = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (res.ok) { + if (isEdit) onSaved?.(data.node); + else onCreated?.(data.node); + } + } catch (error) { + console.log("Error saving custom embedding node:", error); + } finally { + setSubmitting(false); + } + }; + + const handleValidate = async () => { + setValidating(true); + try { + const res = await fetch("/api/provider-nodes/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: formData.baseUrl, + apiKey: checkKey, + type: "custom-embedding", + modelId: checkModelId.trim() || undefined, + }), + }); + const data = await res.json(); + setValidationResult(data); + } catch { + setValidationResult({ valid: false, error: "Network error" }); + } finally { + setValidating(false); + } + }; + + const renderValidationResult = () => { + if (!validationResult) return null; + const { valid, error, dimensions } = validationResult; + if (valid) { + return ( + <> + Valid + {dimensions && {dimensions} dims} + + ); + } + return ( +
+ Invalid + {error && {error}} +
+ ); + }; + + return ( + +
+ setFormData({ ...formData, name: e.target.value })} + placeholder="Voyage AI" + hint="Required. A friendly label for this embedding provider." + /> + setFormData({ ...formData, prefix: e.target.value })} + placeholder="voyage" + hint="Required. Used as the provider prefix for model IDs (e.g. voyage/voyage-3)." + /> + setFormData({ ...formData, baseUrl: e.target.value })} + placeholder="https://api.voyageai.com/v1" + hint="Most embedding APIs are OpenAI-compatible: Voyage, Cohere, Jina, Mistral, Together..." + /> + setCheckKey(e.target.value)} + /> + setCheckModelId(e.target.value)} + placeholder="e.g. voyage-3, embed-english-v3.0, text-embedding-3-small" + hint="Required for validation. Will send a test embeddings request." + /> +
+ + {renderValidationResult()} +
+
+ + +
+
+
+ ); +} + +AddCustomEmbeddingModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onCreated: PropTypes.func, + onSaved: PropTypes.func, + node: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + prefix: PropTypes.string, + baseUrl: PropTypes.string, + }), +}; diff --git a/src/shared/components/Avatar.js b/src/shared/components/Avatar.js new file mode 100644 index 0000000..ae0a755 --- /dev/null +++ b/src/shared/components/Avatar.js @@ -0,0 +1,88 @@ +"use client"; + +import { cn } from "@/shared/utils/cn"; + +export default function Avatar({ + src, + alt = "Avatar", + name, + size = "md", + className, +}) { + const sizes = { + xs: "size-6 text-xs", + sm: "size-8 text-sm", + md: "size-10 text-base", + lg: "size-12 text-lg", + xl: "size-16 text-xl", + }; + + // Get initials from name + const getInitials = (name) => { + if (!name) return "?"; + const parts = name.split(" "); + if (parts.length >= 2) { + return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); + } + return name.substring(0, 2).toUpperCase(); + }; + + // Generate color from name + const getColorFromName = (name) => { + if (!name) return "bg-primary"; + const colors = [ + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-emerald-500", + "bg-teal-500", + "bg-cyan-500", + "bg-sky-500", + "bg-blue-500", + "bg-indigo-500", + "bg-violet-500", + "bg-purple-500", + "bg-fuchsia-500", + "bg-pink-500", + "bg-rose-500", + ]; + const index = name.charCodeAt(0) % colors.length; + return colors[index]; + }; + + if (src) { + return ( +
+ ); + } + + return ( +
+ {getInitials(name)} +
+ ); +} + diff --git a/src/shared/components/Badge.js b/src/shared/components/Badge.js new file mode 100644 index 0000000..6188cf5 --- /dev/null +++ b/src/shared/components/Badge.js @@ -0,0 +1,54 @@ +"use client"; + +import { cn } from "@/shared/utils/cn"; + +const variants = { + default: "bg-surface-2 text-text-muted", + primary: "bg-brand-500/10 text-brand-600 dark:text-brand-300", + success: "bg-green-500/10 text-green-600 dark:text-green-400", + warning: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400", + error: "bg-red-500/10 text-red-600 dark:text-red-400", + info: "bg-blue-500/10 text-blue-600 dark:text-blue-400", +}; + +const sizes = { + sm: "px-2 py-0.5 text-[10px]", + md: "px-2.5 py-1 text-xs", + lg: "px-3 py-1.5 text-sm", +}; + +export default function Badge({ + children, + variant = "default", + size = "md", + dot = false, + icon, + className, +}) { + return ( + + {dot && ( + + )} + {icon && {icon}} + {children} + + ); +} diff --git a/src/shared/components/Button.js b/src/shared/components/Button.js new file mode 100644 index 0000000..38ab4df --- /dev/null +++ b/src/shared/components/Button.js @@ -0,0 +1,56 @@ +"use client"; + +import { cn } from "@/shared/utils/cn"; + +const variants = { + primary: "bg-brand-500 hover:bg-brand-600 text-white shadow-sm disabled:bg-surface-3 disabled:text-text-muted", + secondary: "bg-surface-2 hover:bg-surface-3 text-text-main border border-border disabled:opacity-50", + outline: "border border-border text-text-main hover:bg-surface-2 hover:border-brand-500/40", + ghost: "text-text-muted hover:bg-surface-2 hover:text-text-main", + danger: "bg-red-500 hover:bg-red-600 text-white shadow-sm disabled:bg-surface-3 disabled:text-text-muted", + success: "bg-green-600 hover:bg-green-700 text-white shadow-sm disabled:bg-surface-3 disabled:text-text-muted", +}; + +const sizes = { + sm: "h-7 px-3 text-xs rounded-[8px]", + md: "h-9 px-4 text-sm rounded-[10px]", + lg: "h-11 px-6 text-sm rounded-[10px]", +}; + +export default function Button({ + children, + variant = "primary", + size = "md", + icon, + iconRight, + disabled = false, + loading = false, + fullWidth = false, + className, + ...props +}) { + return ( + + ); +} diff --git a/src/shared/components/CapacityBadges.js b/src/shared/components/CapacityBadges.js new file mode 100644 index 0000000..81c3418 --- /dev/null +++ b/src/shared/components/CapacityBadges.js @@ -0,0 +1,28 @@ +"use client"; + +import { CAPACITY_META } from "@/shared/constants/models"; +import Tooltip from "./Tooltip"; + +// Render small icon badges for a model's capabilities (only those set true). +// colorOverride: force a single color class for all badges (default: per-cap color). +// size: icon font-size in px (default 16). +export default function CapacityBadges({ caps, className = "", colorOverride, size = 16 }) { + if (!caps) return null; + const active = Object.keys(CAPACITY_META).filter((k) => caps[k]); + if (active.length === 0) return null; + + return ( + + {active.map((k) => ( + + + {CAPACITY_META[k].icon} + + + ))} + + ); +} diff --git a/src/shared/components/Card.js b/src/shared/components/Card.js new file mode 100644 index 0000000..765e622 --- /dev/null +++ b/src/shared/components/Card.js @@ -0,0 +1,116 @@ +"use client"; + +import { cn } from "@/shared/utils/cn"; + +export default function Card({ + children, + title, + subtitle, + icon, + action, + padding = "md", + hover = false, + elev = false, + className, + ...props +}) { + const paddings = { + none: "", + xs: "p-3", + sm: "p-4", + md: "p-6", + lg: "p-8", + }; + + return ( +
+ {(title || action) && ( +
+
+ {icon && ( +
+ {icon} +
+ )} +
+ {title && ( +

{title}

+ )} + {subtitle && ( +

{subtitle}

+ )} +
+
+ {action} +
+ )} + {children} +
+ ); +} + +Card.Section = function CardSection({ children, className, ...props }) { + return ( +
+ {children} +
+ ); +}; + +Card.Row = function CardRow({ children, className, ...props }) { + return ( +
+ {children} +
+ ); +}; + +Card.ListItem = function CardListItem({ + children, + actions, + className, + ...props +}) { + return ( +
+
{children}
+ {actions && ( +
+ {actions} +
+ )} +
+ ); +}; diff --git a/src/shared/components/ChangelogModal.js b/src/shared/components/ChangelogModal.js new file mode 100644 index 0000000..591f6a9 --- /dev/null +++ b/src/shared/components/ChangelogModal.js @@ -0,0 +1,97 @@ +"use client"; + +import { useEffect, useState, useRef } from "react"; +import { createPortal } from "react-dom"; +import PropTypes from "prop-types"; +import { marked } from "marked"; +import { GITHUB_CONFIG } from "@/shared/constants/config"; + +marked.setOptions({ gfm: true, breaks: true }); + +export default function ChangelogModal({ isOpen, onClose }) { + const [html, setHtml] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const modalRef = useRef(null); + + useEffect(() => { + if (!isOpen || html) return; + setLoading(true); + setError(""); + fetch(GITHUB_CONFIG.changelogUrl) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.text(); + }) + .then((md) => setHtml(marked.parse(md))) + .catch((err) => setError(err.message || "Failed to load")) + .finally(() => setLoading(false)); + }, [isOpen, html]); + + useEffect(() => { + const handleClickOutside = (e) => { + if (modalRef.current && !modalRef.current.contains(e.target)) { + onClose(); + } + }; + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + } + }, [isOpen, onClose]); + + if (!isOpen || typeof document === "undefined") return null; + + return createPortal( +
+ {/* Overlay */} +
+ + {/* Modal content */} +
+ {/* Header */} +
+

Change Log

+ +
+ + {/* Body */} +
+ {loading && ( +
+ progress_activity + Loading... +
+ )} + {error && ( +
Failed to load changelog: {error}
+ )} + {!loading && !error && html && ( +
+ )} +
+
+
, + document.body + ); +} + +ChangelogModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, +}; diff --git a/src/shared/components/ComboFormModal.js b/src/shared/components/ComboFormModal.js new file mode 100644 index 0000000..d80732d --- /dev/null +++ b/src/shared/components/ComboFormModal.js @@ -0,0 +1,176 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Modal from "./Modal"; +import Input from "./Input"; +import Button from "./Button"; +import ModelSelectModal from "./ModelSelectModal"; + +const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/; + +// Inline editable model item +function ModelItem({ index, model, isFirst, isLast, onEdit, onMoveUp, onMoveDown, onRemove }) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(model); + const commit = () => { + const trimmed = draft.trim(); + if (trimmed && trimmed !== model) onEdit(trimmed); + else setDraft(model); + setEditing(false); + }; + const handleKeyDown = (e) => { + if (e.key === "Enter") commit(); + if (e.key === "Escape") { setDraft(model); setEditing(false); } + }; + return ( +
+ {index + 1} + {editing ? ( + setDraft(e.target.value)} onBlur={commit} onKeyDown={handleKeyDown} + className="min-w-0 flex-1 rounded border border-primary/40 bg-white px-1.5 py-0.5 font-mono text-xs text-text-main outline-none dark:bg-black/20" /> + ) : ( +
setEditing(true)} title="Click to edit">{model}
+ )} +
+ + +
+ +
+ ); +} + +// Reusable Combo create/edit modal. forcePrefix auto-prepends to name. +export default function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindFilter = null, forcePrefix = "", title }) { + // Strip prefix when editing existing combo so user only edits suffix + const initialName = combo?.name + ? (forcePrefix && combo.name.startsWith(forcePrefix) ? combo.name.slice(forcePrefix.length) : combo.name) + : ""; + const [name, setName] = useState(initialName); + const [models, setModels] = useState(combo?.models || []); + const [showModelSelect, setShowModelSelect] = useState(false); + const [saving, setSaving] = useState(false); + const [nameError, setNameError] = useState(""); + const [modelAliases, setModelAliases] = useState({}); + + useEffect(() => { + if (!isOpen) return; + fetch("/api/models/alias").then((r) => r.ok ? r.json() : null).then((d) => d && setModelAliases(d.aliases || {})).catch(() => {}); + }, [isOpen]); + + const validateName = (value) => { + if (!value.trim()) { setNameError("Name is required"); return false; } + const full = forcePrefix + value; + if (!VALID_NAME_REGEX.test(full)) { setNameError("Only letters, numbers, -, _ and . allowed"); return false; } + setNameError(""); + return true; + }; + + const handleNameChange = (e) => { + let value = e.target.value; + // If user types prefix manually, strip it (we always prepend) + if (forcePrefix && value.startsWith(forcePrefix)) value = value.slice(forcePrefix.length); + setName(value); + if (value) validateName(value); else setNameError(""); + }; + + const handleAddModel = (model) => { + if (!models.includes(model.value)) setModels([...models, model.value]); + }; + const handleDeselectModel = (model) => { + setModels(models.filter((m) => m !== model.value)); + }; + const handleRemoveModel = (i) => setModels(models.filter((_, idx) => idx !== i)); + const handleMoveUp = (i) => { + if (i === 0) return; + const a = [...models]; [a[i - 1], a[i]] = [a[i], a[i - 1]]; setModels(a); + }; + const handleMoveDown = (i) => { + if (i === models.length - 1) return; + const a = [...models]; [a[i], a[i + 1]] = [a[i + 1], a[i]]; setModels(a); + }; + + const handleSave = async () => { + if (!validateName(name)) return; + setSaving(true); + await onSave({ name: forcePrefix + name.trim(), models }); + setSaving(false); + }; + + const isEdit = !!combo; + + return ( + <> + +
+
+ {forcePrefix ? ( + <> + +
+ {forcePrefix} + +
+ {nameError &&

{nameError}

} + + ) : ( + + )} +

+ {forcePrefix ? `Auto-prefixed with "${forcePrefix}". ` : ""}Only letters, numbers, -, _ and . allowed +

+
+ +
+ + {models.length === 0 ? ( +
+ layers +

No models added yet

+
+ ) : ( +
+ {models.map((model, index) => ( + { const a = [...models]; a[index] = v; setModels(a); }} + onMoveUp={() => handleMoveUp(index)} + onMoveDown={() => handleMoveDown(index)} + onRemove={() => handleRemoveModel(index)} /> + ))} +
+ )} + +
+ +
+ + +
+
+
+ + setShowModelSelect(false)} + onSelect={handleAddModel} onDeselect={handleDeselectModel} + activeProviders={activeProviders} modelAliases={modelAliases} + title="Add Model to Combo" kindFilter={kindFilter} + addedModelValues={models} closeOnSelect={false} /> + + ); +} diff --git a/src/shared/components/CursorAuthModal.js b/src/shared/components/CursorAuthModal.js new file mode 100644 index 0000000..704db9b --- /dev/null +++ b/src/shared/components/CursorAuthModal.js @@ -0,0 +1,212 @@ +"use client"; + +import { useState, useEffect } from "react"; +import PropTypes from "prop-types"; +import { Modal, Button, Input } from "@/shared/components"; + +/** + * Cursor Auth Modal + * Auto-detect and import token from Cursor IDE's local SQLite database + */ +export default function CursorAuthModal({ isOpen, onSuccess, onClose }) { + const [accessToken, setAccessToken] = useState(""); + const [machineId, setMachineId] = useState(""); + const [error, setError] = useState(null); + const [importing, setImporting] = useState(false); + const [autoDetecting, setAutoDetecting] = useState(false); + const [autoDetected, setAutoDetected] = useState(false); + const [windowsManual, setWindowsManual] = useState(false); + + const runAutoDetect = async () => { + setAutoDetecting(true); + setError(null); + setAutoDetected(false); + setWindowsManual(false); + + try { + const res = await fetch("/api/oauth/cursor/auto-import"); + const data = await res.json(); + + if (data.found) { + setAccessToken(data.accessToken); + setMachineId(data.machineId); + setAutoDetected(true); + } else if (data.windowsManual) { + setWindowsManual(true); + } else { + setError(data.error || "Could not auto-detect tokens"); + } + } catch (err) { + setError("Failed to auto-detect tokens"); + } finally { + setAutoDetecting(false); + } + }; + + // Auto-detect tokens when modal opens + useEffect(() => { + if (!isOpen) return; + runAutoDetect(); + }, [isOpen]); + + const handleImportToken = async () => { + if (!accessToken.trim()) { + setError("Please enter an access token"); + return; + } + + if (!machineId.trim()) { + setError("Please enter a machine ID"); + return; + } + + setImporting(true); + setError(null); + + try { + const res = await fetch("/api/oauth/cursor/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + accessToken: accessToken.trim(), + machineId: machineId.trim(), + }), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "Import failed"); + } + + onSuccess?.(); + onClose(); + } catch (err) { + setError(err.message); + } finally { + setImporting(false); + } + }; + + return ( + +
+ {/* Auto-detecting state */} + {autoDetecting && ( +
+
+ + progress_activity + +
+

Auto-detecting tokens...

+

+ Reading from Cursor IDE database +

+
+ )} + + {/* Form (shown after auto-detect completes) */} + {!autoDetecting && ( + <> + {/* Success message if auto-detected */} + {autoDetected && ( +
+
+ check_circle +

+ Tokens auto-detected from Cursor IDE successfully! +

+
+
+ )} + + {/* Windows manual instructions */} + {windowsManual && ( +
+
+ info +

+ Could not read Cursor database automatically. +

+
+

+ Make sure Cursor IDE has been opened at least once, then click Retry. If the problem persists, paste your tokens manually below. +

+ +
+ )} + + {/* Info message if not auto-detected */} + {!autoDetected && !windowsManual && !error && ( +
+
+ info +

+ Cursor IDE not detected. Please paste your tokens manually. +

+
+
+ )} + + {/* Access Token Input */} +
+ +