chore: import upstream snapshot with attribution
Deploy GitBook to 9router.github.io / build-deploy (push) Failing after 2s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:21:01 +08:00
commit 05fcd08057
1298 changed files with 194683 additions and 0 deletions
+32
View File
@@ -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*
+41
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
version: 2
updates: []
+60
View File
@@ -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
+50
View File
@@ -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 }}"
+80
View File
@@ -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/*
+31
View File
@@ -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/
+20
View File
@@ -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"
}
}
}
+380
View File
@@ -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 <thinking> 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
+91
View File
@@ -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(...)`).
+132
View File
@@ -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`
+53
View File
@@ -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"]
+21
View File
@@ -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.
+1444
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`decolua/9router`
- 原始仓库:https://github.com/decolua/9router
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+1311
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
{
"schemaVersion": 2,
"dockerfilePath": "./Dockerfile"
}
+2
View File
@@ -0,0 +1,2 @@
app/*
node_modules/*
+9
View File
@@ -0,0 +1,9 @@
# Ignore everything except what's in package.json "files"
*
!cli.js
!hooks/
!app/
!package.json
!README.md
!LICENSE
+42
View File
@@ -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.
+125
View File
@@ -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)
<a href="https://trendshift.io/repositories/22628" target="_blank"><img src="https://trendshift.io/api/badge/repositories/22628" alt="decolua%2F9router | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[🌐 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.
Executable
+852
View File
@@ -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> Port to run the server (default: ${DEFAULT_PORT})
-H, --host <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();
}
+22
View File
@@ -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);
+156
View File
@@ -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,
};
+107
View File
@@ -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: <npm_prefix>/node_modules/9router/node_modules/systray
// __dirname here = <pkg root>/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 };
+48
View File
@@ -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"
}
}
+281
View File
@@ -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
}
+71
View File
@@ -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); });
+556
View File
@@ -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<Object>} 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<Object>} { success, data: { connections } }
*/
async function getProviders() {
return makeRequest("GET", "/api/providers");
}
/**
* Get provider by ID
* @param {string} id - Provider ID
* @returns {Promise<Object>} { success, data: { connection } }
*/
async function getProviderById(id) {
return makeRequest("GET", `/api/providers/${id}`);
}
/**
* Test provider connection
* @param {string} id - Provider ID
* @returns {Promise<Object>} { success, data: { valid, error } }
*/
async function testProvider(id) {
return makeRequest("POST", `/api/providers/${id}/test`);
}
/**
* Delete provider
* @param {string} id - Provider ID
* @returns {Promise<Object>} { success, data: { message } }
*/
async function deleteProvider(id) {
return makeRequest("DELETE", `/api/providers/${id}`);
}
/**
* Get provider models
* @param {string} id - Provider ID
* @returns {Promise<Object>} { 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<Object>} { 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<Object>} { 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<Object>} { 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<Object>} { 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<Object>} { 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<Object>} { success, data: { connection } }
*/
async function updateConnection(id, data) {
return makeRequest("PUT", `/api/providers/${id}`, data);
}
// ============================================================================
// API KEYS API
// ============================================================================
/**
* Get all API keys
* @returns {Promise<Object>} { success, data: { keys } }
*/
async function getApiKeys() {
return makeRequest("GET", "/api/keys");
}
/**
* Create new API key
* @param {string} name - Key name
* @returns {Promise<Object>} { 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<Object>} { success, data: { success } }
*/
async function deleteApiKey(id) {
return makeRequest("DELETE", `/api/keys/${id}`);
}
// ============================================================================
// COMBOS API
// ============================================================================
/**
* Get all combos
* @returns {Promise<Object>} { success, data: { combos } }
*/
async function getCombos() {
return makeRequest("GET", "/api/combos");
}
/**
* Get combo by ID
* @param {string} id - Combo ID
* @returns {Promise<Object>} { 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<Object>} { 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<Object>} { success, data: combo }
*/
async function updateCombo(id, data) {
return makeRequest("PUT", `/api/combos/${id}`, data);
}
/**
* Delete combo
* @param {string} id - Combo ID
* @returns {Promise<Object>} { 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<Object>} { 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<Object>} { 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<Object>} { success, data }
*/
async function resetCliToolSettings(tool) {
return makeRequest("DELETE", `/api/cli-tools/${tool}-settings`);
}
// ============================================================================
// SETTINGS API
// ============================================================================
/**
* Get settings
* @returns {Promise<Object>} { success, data: settings }
*/
async function getSettings() {
return makeRequest("GET", "/api/settings");
}
/**
* Update settings
* @param {Object} data - Settings data
* @returns {Promise<Object>} { 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<Object>} { success }
*/
async function resetPassword() {
return makeRequest("POST", "/api/auth/reset-password");
}
// ============================================================================
// MODELS API
// ============================================================================
/**
* Get all models (internal API)
* @returns {Promise<Object>} { success, data: { models } }
*/
async function getModels() {
return makeRequest("GET", "/api/models");
}
/**
* Get available models from active providers + combos (OpenAI compatible)
* @returns {Promise<Object>} { 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<Object>} { success, data: { enabled, tunnelUrl, shortId, running } }
*/
async function getTunnelStatus() {
return makeRequest("GET", "/api/tunnel/status");
}
/**
* Enable tunnel
* @returns {Promise<Object>} { success, data: { tunnelUrl, shortId } }
*/
async function enableTunnel() {
return makeRequest("POST", "/api/tunnel/enable");
}
/**
* Disable tunnel
* @returns {Promise<Object>} { 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,
};
+233
View File
@@ -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<boolean>} 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<boolean>} 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<string>} 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<string>} 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
};
+618
View File
@@ -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<string|null>}
*/
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<string>}
*/
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<string>}
*/
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<string>} 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<string>}
*/
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<string>} 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<string>}
*/
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<string>} 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<string>}
*/
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<string>} 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<string>} 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 };
+477
View File
@@ -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<string>} 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<string>} 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 };
+847
View File
@@ -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<string>} 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<string>} 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<string>} 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<string>} 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 };
+204
View File
@@ -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<string>} 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 };
+121
View File
@@ -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 };
+306
View File
@@ -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
* `<pkg>/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 <plist>` (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 = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${APP_LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>${nodePath}</string>
<string>${routerScript}</string>
<string>--tray</string>
<string>--skip-update</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>${launchPath}</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<false/>
<key>StandardOutPath</key>
<string>/tmp/9router.log</string>
<key>StandardErrorPath</key>
<string>/tmp/9router.error.log</string>
</dict>
</plist>`;
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
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 969 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 B

+322
View File
@@ -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
};
+130
View File
@@ -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()
+89
View File
@@ -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 };
+30
View File
@@ -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 };
+156
View File
@@ -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<Array<string|number>>} 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
};
+32
View File
@@ -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<string>} 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 };
+125
View File
@@ -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
};
+156
View File
@@ -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
};
+156
View File
@@ -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<string>} config.breadcrumb - Optional breadcrumb path
* @returns {Promise<void>}
*/
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<string>} config.breadcrumb - Optional breadcrumb path
* @returns {Promise<void>}
*/
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
};
+136
View File
@@ -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<string|null>} 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
};
+32
View File
@@ -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");
+30
View File
@@ -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
+557
View File
@@ -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: `<repo>/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://<host>:20128/v1` when `PORT=20128`
+16
View File
@@ -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;
+44
View File
@@ -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
+36
View File
@@ -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 (
<DocsLayout headings={headings} lang={lang}>
<DocsContent content={content} />
</DocsLayout>
);
}
+26
View File
@@ -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 (
<DocsLayout headings={headings} lang={lang}>
<DocsContent content={content} />
</DocsLayout>
);
}
+190
View File
@@ -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);
}
}
+21
View File
@@ -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 (
<html lang="en">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
</head>
<body className="bg-[#FCFBF9] text-[#6B7280]">
{children}
</body>
</html>
);
}
+26
View File
@@ -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 (
<>
<script
dangerouslySetInnerHTML={{
__html: `window.location.replace("${target}");`
}}
/>
<meta httpEquiv="refresh" content={`0; url=${target}`} />
<p style={{ padding: "2rem", textAlign: "center" }}>
Redirecting to <a href={target}>{target}</a>...
</p>
</>
);
}
+13
View File
@@ -0,0 +1,13 @@
"use client";
import { MarkdownRenderer } from "@/utils/markdown";
export default function DocsContent({ content }) {
return (
<main className="flex-1 min-w-0 overflow-x-hidden overflow-y-auto">
<article className="max-w-4xl mx-auto px-4 sm:px-6 py-8">
<MarkdownRenderer content={content} />
</article>
</main>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { DOCS_CONFIG, t } from "@/constants/docsConfig";
import { DEFAULT_LANG } from "@/constants/languages";
import { ExternalLink, Menu, X } from "lucide-react";
import DocsSidebar from "./DocsSidebar";
import LanguageSwitcher from "./LanguageSwitcher";
export default function DocsHeader({ lang = DEFAULT_LANG }) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
return (
<>
<header className="sticky top-0 z-50 w-full border-b bg-white/80 backdrop-blur-sm border-gray-200">
<div className=" mx-auto px-4 h-16 flex items-center justify-between">
{/* Mobile menu button */}
<button
onClick={() => setMobileMenuOpen(true)}
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 transition-colors"
aria-label="Open menu"
>
<Menu className="w-6 h-6 text-gray-600" />
</button>
{/* Logo */}
<Link href={`/${lang}`} className="flex items-center gap-2 font-bold text-2xl text-black hover:opacity-80 transition-opacity">
<span>9</span>
<span className="text-[#E68A6E]">{DOCS_CONFIG.logo} Docs</span>
</Link>
{/* Right side */}
<div className="flex items-center gap-2 sm:gap-3">
<LanguageSwitcher currentLang={lang} />
{/* Go to App */}
<Link
href={DOCS_CONFIG.appUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 sm:px-4 py-2 bg-[#E68A6E] text-white rounded-lg font-medium hover:bg-[#d67a5e] transition-colors text-sm"
>
<span className="hidden sm:inline">{t(lang, "goToApp")}</span>
<ExternalLink className="w-4 h-4" />
</Link>
</div>
</div>
</header>
{/* Mobile menu */}
{mobileMenuOpen && (
<>
<div
className="mobile-menu-overlay lg:hidden"
onClick={() => setMobileMenuOpen(false)}
/>
<div className="mobile-menu-drawer lg:hidden">
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<span className="font-bold text-lg text-black">
<span className="text-[#E68A6E]">9</span>{DOCS_CONFIG.logo} Docs
</span>
<button
onClick={() => setMobileMenuOpen(false)}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
aria-label="Close menu"
>
<X className="w-5 h-5 text-gray-600" />
</button>
</div>
<DocsSidebar isMobile onClose={() => setMobileMenuOpen(false)} lang={lang} />
</div>
</>
)}
</>
);
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import DocsHeader from "./DocsHeader";
import DocsSidebar from "./DocsSidebar";
import DocsToc from "./DocsToc";
import { DEFAULT_LANG } from "@/constants/languages";
export default function DocsLayout({ children, headings = [], lang = DEFAULT_LANG }) {
return (
<div className="min-h-screen flex flex-col bg-[#FCFBF9]">
<DocsHeader lang={lang} />
<div className="flex-1 flex">
<div className="hidden lg:block">
<DocsSidebar lang={lang} />
</div>
<div className="flex-1 flex min-w-0">
{children}
<div className="hidden lg:block">
<DocsToc headings={headings} lang={lang} />
</div>
</div>
</div>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { getNavigation } from "@/constants/docsConfig";
import { DEFAULT_LANG } from "@/constants/languages";
import { ChevronDown, ChevronRight, BookOpen, Rocket, Terminal, Monitor, HelpCircle, MessageCircle, Layers, Plug, Cloud, Zap, Wallet, Gift, GitBranch, BarChart3, Code2, Sparkles, Server } from "lucide-react";
// Icons keyed by structural key (language-independent)
const SECTION_ICONS = {
gettingStarted: Rocket,
providers: Layers,
features: Zap,
integration: Plug,
deployment: Cloud,
help: HelpCircle
};
const ITEM_ICONS = {
introduction: BookOpen,
quickStart: Rocket,
installation: Terminal,
subscription: Sparkles,
cheap: Wallet,
free: Gift,
smartRouting: GitBranch,
combos: Layers,
quotaTracking: BarChart3,
claudeCode: Code2,
codex: Code2,
cursor: Code2,
cline: Code2,
roo: Code2,
continue: Code2,
otherTools: Plug,
localhost: Monitor,
cloud: Server,
troubleshooting: HelpCircle,
faq: MessageCircle
};
export default function DocsSidebar({ isMobile = false, onClose, lang = DEFAULT_LANG }) {
const pathname = usePathname();
const navigation = getNavigation(lang);
const [openSections, setOpenSections] = useState(() => {
if (typeof window === "undefined") return [];
try {
return JSON.parse(sessionStorage.getItem("sidebarOpen") || "[]");
} catch { return []; }
});
useEffect(() => {
sessionStorage.setItem("sidebarOpen", JSON.stringify(openSections));
}, [openSections]);
const toggleSection = (index) => {
setOpenSections(prev =>
prev.includes(index)
? prev.filter(i => i !== index)
: [...prev, index]
);
};
const buildHref = (slug) => (slug ? `/${lang}/${slug}` : `/${lang}`);
const isActive = (slug) => pathname === buildHref(slug);
const handleLinkClick = () => {
if (isMobile && onClose) onClose();
};
return (
<aside className={`${isMobile ? 'w-full' : 'w-64'} border-r bg-white border-gray-200 ${isMobile ? 'h-full' : 'h-[calc(100vh-4rem)] sticky top-16'} overflow-y-auto`}>
<nav className="p-4 space-y-6">
{navigation.map((section, sectionIndex) => {
const SectionIcon = SECTION_ICONS[section.key] || BookOpen;
return (
<div key={section.key}>
<button
onClick={() => toggleSection(sectionIndex)}
className="flex items-center justify-between w-full text-sm font-semibold text-gray-900 mb-2 hover:text-[#E68A6E] transition-colors"
>
<span className="flex items-center gap-2">
<SectionIcon className="w-4 h-4" />
{section.title}
</span>
{openSections.includes(sectionIndex) ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
{openSections.includes(sectionIndex) && (
<ul className="space-y-1">
{section.items.map((item) => {
const ItemIcon = ITEM_ICONS[item.key] || BookOpen;
return (
<li key={item.key}>
<Link
href={buildHref(item.slug)}
onClick={handleLinkClick}
className={`flex items-center gap-2 px-3 py-2 text-sm rounded-lg transition-colors ${
isActive(item.slug)
? "bg-[#E68A6E]/10 text-[#E68A6E] font-medium"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
}`}
>
<ItemIcon className="w-4 h-4" />
{item.title}
</Link>
</li>
);
})}
</ul>
)}
</div>
);
})}
</nav>
</aside>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useEffect, useState } from "react";
import { List } from "lucide-react";
import { t } from "@/constants/docsConfig";
import { DEFAULT_LANG } from "@/constants/languages";
export default function DocsToc({ headings, lang = DEFAULT_LANG }) {
const [activeId, setActiveId] = useState("");
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
});
},
{ rootMargin: "-80px 0px -80% 0px" }
);
headings.forEach(({ id }) => {
const element = document.getElementById(id);
if (element) observer.observe(element);
});
return () => observer.disconnect();
}, [headings]);
if (!headings || headings.length === 0) return null;
return (
<aside className="hidden xl:block w-64 border-l bg-white border-gray-200 h-[calc(100vh-4rem)] sticky top-16 overflow-y-auto">
<nav className="p-4">
<h3 className="flex items-center gap-2 text-sm font-semibold text-gray-900 mb-3">
<List className="w-4 h-4" />
{t(lang, "onThisPage")}
</h3>
<ul className="space-y-2">
{headings.map((heading, idx) => (
<li key={`${heading.id}-${idx}`}>
<a
href={`#${heading.id}`}
className={`block text-sm transition-colors ${
heading.level === 3 ? "pl-4" : ""
} ${
activeId === heading.id
? "text-[#E68A6E] font-medium"
: "text-gray-600 hover:text-gray-900"
}`}
>
{heading.text}
</a>
</li>
))}
</ul>
</nav>
</aside>
);
}
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useState, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
import { useRouter, usePathname } from "next/navigation";
import { Globe, X } from "lucide-react";
import { LANGUAGES, getLanguage, DEFAULT_LANG } from "@/constants/languages";
import { t } from "@/constants/docsConfig";
function extractLangFromPath(pathname) {
const match = pathname.match(/^\/([^/]+)(?:\/(.*))?$/);
if (!match) return { lang: DEFAULT_LANG, rest: "" };
return { lang: match[1], rest: match[2] || "" };
}
export default function LanguageSwitcher({ currentLang }) {
const [open, setOpen] = useState(false);
const router = useRouter();
const pathname = usePathname();
const current = getLanguage(currentLang);
useLayoutEffect(() => {
setMounted(true);
}, []);
// Lock body scroll when modal is open
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = ""; };
}
}, [open]);
const switchTo = (code) => {
const { rest } = extractLangFromPath(pathname);
const target = rest ? `/${code}/${rest}` : `/${code}`;
setOpen(false);
router.push(target);
};
const modal = open && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 p-4" onClick={() => setOpen(false)}>
<div
className="bg-white rounded-xl shadow-2xl max-w-md w-full max-h-[80vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<h2 className="font-bold text-lg text-gray-900">{t(currentLang, "selectLanguage")}</h2>
<button
onClick={() => setOpen(false)}
className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors"
aria-label="Close"
>
<X className="w-5 h-5 text-gray-600" />
</button>
</div>
<div className="p-2 overflow-y-auto max-h-[60vh]">
{LANGUAGES.map((lang) => (
<button
key={lang.code}
onClick={() => switchTo(lang.code)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors ${
lang.code === currentLang
? "bg-[#E68A6E]/10 text-[#E68A6E] font-medium"
: "text-gray-700 hover:bg-gray-100"
}`}
>
<span className="text-2xl">{lang.flag}</span>
<div className="flex-1">
<div className="font-medium">{lang.native}</div>
<div className="text-xs text-gray-500">{lang.name}</div>
</div>
{lang.code === currentLang && <span className="text-xs"></span>}
</button>
))}
</div>
</div>
</div>
);
return (
<>
<button
onClick={() => setOpen(true)}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-sm text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
aria-label="Switch language"
>
<Globe className="w-4 h-4" />
<span className="hidden sm:inline">{current.flag} {current.native}</span>
<span className="sm:hidden">{current.flag}</span>
</button>
{open && createPortal(modal, document.body)}
</>
);
}
+242
View File
@@ -0,0 +1,242 @@
import { DEFAULT_LANG } from "./languages";
// Navigation structure (slugs are shared). Labels are per-language.
const NAV_STRUCTURE = [
{
key: "gettingStarted",
items: [
{ key: "introduction", slug: "" },
{ key: "quickStart", slug: "getting-started/quick-start" },
{ key: "installation", slug: "getting-started/installation" }
]
},
{
key: "providers",
items: [
{ key: "subscription", slug: "providers/subscription" },
{ key: "cheap", slug: "providers/cheap" },
{ key: "free", slug: "providers/free" }
]
},
{
key: "features",
items: [
{ key: "smartRouting", slug: "features/smart-routing" },
{ key: "combos", slug: "features/combos" },
{ key: "quotaTracking", slug: "features/quota-tracking" }
]
},
{
key: "integration",
items: [
{ key: "claudeCode", slug: "integration/claude-code" },
{ key: "codex", slug: "integration/codex" },
{ key: "cursor", slug: "integration/cursor" },
{ key: "cline", slug: "integration/cline" },
{ key: "roo", slug: "integration/roo" },
{ key: "continue", slug: "integration/continue" },
{ key: "otherTools", slug: "integration/other-tools" }
]
},
{
key: "deployment",
items: [
{ key: "localhost", slug: "deployment/localhost" },
{ key: "cloud", slug: "deployment/cloud" }
]
},
{
key: "help",
items: [
{ key: "troubleshooting", slug: "troubleshooting" },
{ key: "faq", slug: "faq" }
]
}
];
// Translations for section/item titles (5 langs).
const TRANSLATIONS = {
en: {
gettingStarted: "Getting Started",
introduction: "Introduction",
quickStart: "Quick Start",
installation: "Installation",
providers: "Providers",
subscription: "Subscription (Maximize)",
cheap: "Cheap (Backup)",
free: "Free (Fallback)",
features: "Features",
smartRouting: "Smart Routing",
combos: "Combos & Fallback",
quotaTracking: "Quota Tracking",
integration: "Integration",
claudeCode: "Claude Code",
codex: "OpenAI Codex",
cursor: "Cursor",
cline: "Cline",
roo: "Roo",
continue: "Continue",
otherTools: "Other Tools",
deployment: "Deployment",
localhost: "Localhost",
cloud: "Cloud (VPS/Docker)",
help: "Help",
troubleshooting: "Troubleshooting",
faq: "FAQ",
goToApp: "Go to App",
selectLanguage: "Select Language",
onThisPage: "On this page"
},
vi: {
gettingStarted: "Bắt đầu",
introduction: "Giới thiệu",
quickStart: "Bắt đầu nhanh",
installation: "Cài đặt",
providers: "Nhà cung cấp",
subscription: "Subscription (Tối đa hóa)",
cheap: "Giá rẻ (Dự phòng)",
free: "Miễn phí (Phương án cuối)",
features: "Tính năng",
smartRouting: "Định tuyến thông minh",
combos: "Combo & Fallback",
quotaTracking: "Theo dõi Quota",
integration: "Tích hợp",
claudeCode: "Claude Code",
codex: "OpenAI Codex",
cursor: "Cursor",
cline: "Cline",
roo: "Roo",
continue: "Continue",
otherTools: "Công cụ khác",
deployment: "Triển khai",
localhost: "Localhost",
cloud: "Cloud (VPS/Docker)",
help: "Trợ giúp",
troubleshooting: "Khắc phục sự cố",
faq: "Câu hỏi thường gặp",
goToApp: "Vào ứng dụng",
selectLanguage: "Chọn ngôn ngữ",
onThisPage: "Trên trang này"
},
"zh-CN": {
gettingStarted: "开始使用",
introduction: "简介",
quickStart: "快速开始",
installation: "安装",
providers: "提供商",
subscription: "订阅 (最大化)",
cheap: "低价 (备用)",
free: "免费 (兜底)",
features: "功能",
smartRouting: "智能路由",
combos: "组合与回退",
quotaTracking: "配额跟踪",
integration: "集成",
claudeCode: "Claude Code",
codex: "OpenAI Codex",
cursor: "Cursor",
cline: "Cline",
roo: "Roo",
continue: "Continue",
otherTools: "其他工具",
deployment: "部署",
localhost: "本地",
cloud: "云端 (VPS/Docker)",
help: "帮助",
troubleshooting: "故障排查",
faq: "常见问题",
goToApp: "前往应用",
selectLanguage: "选择语言",
onThisPage: "本页内容"
},
es: {
gettingStarted: "Comenzar",
introduction: "Introducción",
quickStart: "Inicio rápido",
installation: "Instalación",
providers: "Proveedores",
subscription: "Suscripción (Maximizar)",
cheap: "Económico (Respaldo)",
free: "Gratis (Alternativa)",
features: "Funciones",
smartRouting: "Enrutamiento inteligente",
combos: "Combos y Fallback",
quotaTracking: "Seguimiento de cuota",
integration: "Integración",
claudeCode: "Claude Code",
codex: "OpenAI Codex",
cursor: "Cursor",
cline: "Cline",
roo: "Roo",
continue: "Continue",
otherTools: "Otras herramientas",
deployment: "Despliegue",
localhost: "Localhost",
cloud: "Nube (VPS/Docker)",
help: "Ayuda",
troubleshooting: "Solución de problemas",
faq: "Preguntas frecuentes",
goToApp: "Ir a la app",
selectLanguage: "Seleccionar idioma",
onThisPage: "En esta página"
},
ja: {
gettingStarted: "はじめに",
introduction: "概要",
quickStart: "クイックスタート",
installation: "インストール",
providers: "プロバイダー",
subscription: "サブスクリプション (最大化)",
cheap: "格安 (バックアップ)",
free: "無料 (フォールバック)",
features: "機能",
smartRouting: "スマートルーティング",
combos: "コンボとフォールバック",
quotaTracking: "クォータ追跡",
integration: "連携",
claudeCode: "Claude Code",
codex: "OpenAI Codex",
cursor: "Cursor",
cline: "Cline",
roo: "Roo",
continue: "Continue",
otherTools: "その他のツール",
deployment: "デプロイ",
localhost: "ローカル",
cloud: "クラウド (VPS/Docker)",
help: "ヘルプ",
troubleshooting: "トラブルシューティング",
faq: "よくある質問",
goToApp: "アプリへ",
selectLanguage: "言語を選択",
onThisPage: "このページ"
}
};
// Translate one key for given language with fallback to default.
export function t(lang, key) {
return TRANSLATIONS[lang]?.[key] || TRANSLATIONS[DEFAULT_LANG][key] || key;
}
// Build localized navigation for sidebar.
export function getNavigation(lang) {
return NAV_STRUCTURE.map(section => ({
key: section.key,
title: t(lang, section.key),
items: section.items.map(item => ({
key: item.key,
slug: item.slug,
title: t(lang, item.key)
}))
}));
}
// Static config (logo, urls, default English nav for backward compatibility).
export const DOCS_CONFIG = {
title: "9Router Documentation",
description: "Smart AI model router - Maximize subscriptions, minimize costs",
logo: "9Router",
appUrl: "https://9router.com",
githubUrl: "https://github.com/decolua/9router",
navigation: getNavigation(DEFAULT_LANG)
};
+19
View File
@@ -0,0 +1,19 @@
export const DEFAULT_LANG = "en";
export const LANGUAGES = [
{ code: "en", name: "English", native: "English", flag: "🇺🇸" },
{ code: "vi", name: "Vietnamese", native: "Tiếng Việt", flag: "🇻🇳" },
{ code: "zh-CN", name: "Chinese (Simplified)", native: "简体中文", flag: "🇨🇳" },
{ code: "es", name: "Spanish", native: "Español", flag: "🇪🇸" },
{ code: "ja", name: "Japanese", native: "日本語", flag: "🇯🇵" }
];
export const LANG_CODES = LANGUAGES.map(l => l.code);
export function isValidLang(code) {
return LANG_CODES.includes(code);
}
export function getLanguage(code) {
return LANGUAGES.find(l => l.code === code) || LANGUAGES[0];
}
+473
View File
@@ -0,0 +1,473 @@
# ☁️ Cloud Deployment
Deploy 9Router on VPS or Docker for remote access and production use.
---
## 🖥️ VPS Deployment
### Prerequisites
- Ubuntu 20.04+ or similar Linux distribution
- Node.js 20+
- Git
- Root or sudo access
### Step 1: Clone Repository
```bash
git clone https://github.com/decolua/9router.git
cd 9router/app
```
### Step 2: Install Dependencies
```bash
npm install
```
### Step 3: Build Application
```bash
npm run build
```
### Step 4: Configure Environment Variables
Create a `.env` file or export variables:
```bash
export JWT_SECRET="your-secure-secret-change-this-to-random-string"
export INITIAL_PASSWORD="your-secure-password"
export DATA_DIR="/var/lib/9router"
export NODE_ENV="production"
```
**Environment Variables:**
| Variable | Default | Description |
|----------|---------|-------------|
| `JWT_SECRET` | Auto-generated | **MUST change in production!** Used for JWT token signing |
| `INITIAL_PASSWORD` | `123456` | Dashboard login password |
| `DATA_DIR` | `~/.9router` | Database and data storage path |
| `NODE_ENV` | `development` | Set to `production` for deployment |
| `ENABLE_REQUEST_LOGS` | `false` | Enable debug request/response logs |
### Step 5: Create Data Directory
```bash
sudo mkdir -p /var/lib/9router
sudo chown $USER:$USER /var/lib/9router
```
### Step 6: Start Application
```bash
npm run start
```
### Step 7: Setup PM2 for Production
PM2 keeps your application running and restarts it on crashes:
```bash
# Install PM2 globally
npm install -g pm2
# Start 9Router with PM2
pm2 start npm --name 9router -- start
# Save PM2 configuration
pm2 save
# Setup PM2 to start on system boot
pm2 startup
# Follow the instructions printed by the command above
```
**PM2 Management Commands:**
```bash
# View logs
pm2 logs 9router
# Restart application
pm2 restart 9router
# Stop application
pm2 stop 9router
# View status
pm2 status
# Monitor resources
pm2 monit
```
---
## 🐳 Docker Deployment
### Option 1: Using Dockerfile
Create a `Dockerfile` in the `app` directory:
```dockerfile
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy application files
COPY . .
# Build application
RUN npm run build
# Expose ports
EXPOSE 3000 20128
# Set environment variables
ENV NODE_ENV=production
ENV DATA_DIR=/app/data
# Create data directory
RUN mkdir -p /app/data
# Start application
CMD ["npm", "run", "start"]
```
**Build and Run:**
```bash
# Build image
docker build -t 9router .
# Run container
docker run -d \
--name 9router \
-p 3000:3000 \
-p 20128:20128 \
-e JWT_SECRET="your-secure-secret-change-this" \
-e INITIAL_PASSWORD="your-secure-password" \
-v 9router-data:/app/data \
9router
```
### Option 2: Docker Compose
Create `docker-compose.yml`:
```yaml
version: '3.8'
services:
9router:
build: .
container_name: 9router
ports:
- "3000:3000"
- "20128:20128"
environment:
- NODE_ENV=production
- JWT_SECRET=your-secure-secret-change-this
- INITIAL_PASSWORD=your-secure-password
- DATA_DIR=/app/data
volumes:
- 9router-data:/app/data
restart: unless-stopped
volumes:
9router-data:
```
**Run with Docker Compose:**
```bash
# Start services
docker-compose up -d
# View logs
docker-compose logs -f
# Stop services
docker-compose down
# Rebuild and restart
docker-compose up -d --build
```
---
## 🌐 Reverse Proxy with Nginx
### Why Use Nginx?
- SSL/TLS termination
- Domain name mapping
- Load balancing
- Better security
### Step 1: Install Nginx
```bash
sudo apt update
sudo apt install nginx
```
### Step 2: Configure Nginx
Create `/etc/nginx/sites-available/9router`:
```nginx
server {
listen 80;
server_name your-domain.com;
# Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name your-domain.com;
# SSL certificates (use certbot to generate)
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
# SSL configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# Proxy to 9Router
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# SSE support - CRITICAL for streaming
proxy_buffering off;
proxy_read_timeout 86400;
}
# API endpoint
location /v1 {
proxy_pass http://localhost:20128;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE support - CRITICAL for streaming
proxy_buffering off;
proxy_read_timeout 86400;
}
}
```
### Step 3: Enable Site
```bash
# Create symbolic link
sudo ln -s /etc/nginx/sites-available/9router /etc/nginx/sites-enabled/
# Test configuration
sudo nginx -t
# Reload Nginx
sudo systemctl reload nginx
```
### Step 4: Setup SSL with Let's Encrypt
```bash
# Install certbot
sudo apt install certbot python3-certbot-nginx
# Obtain SSL certificate
sudo certbot --nginx -d your-domain.com
# Auto-renewal is configured automatically
# Test renewal
sudo certbot renew --dry-run
```
---
## 🔒 Security Considerations
### 1. Change Default Credentials
**CRITICAL:** Change `JWT_SECRET` and `INITIAL_PASSWORD` before deployment:
```bash
# Generate secure JWT secret
openssl rand -base64 32
# Use this value for JWT_SECRET
export JWT_SECRET="generated-secret-here"
```
### 2. Firewall Configuration
```bash
# Allow SSH
sudo ufw allow 22/tcp
# Allow HTTP/HTTPS (if using Nginx)
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# If NOT using reverse proxy, allow 9Router ports
sudo ufw allow 3000/tcp
sudo ufw allow 20128/tcp
# Enable firewall
sudo ufw enable
```
### 3. Restrict Dashboard Access
If you only need API access, restrict dashboard port:
```bash
# Only allow localhost access to dashboard
sudo ufw deny 3000/tcp
```
Access dashboard via SSH tunnel:
```bash
ssh -L 3000:localhost:3000 user@your-server.com
# Then open http://localhost:3000 in your browser
```
### 4. Regular Updates
```bash
# Update system packages
sudo apt update && sudo apt upgrade -y
# Update 9Router
cd /path/to/9router/app
git pull
npm install
npm run build
pm2 restart 9router
```
### 5. Backup Strategy
```bash
# Backup data directory
tar -czf 9router-backup-$(date +%Y%m%d).tar.gz /var/lib/9router
# Automated daily backup (add to crontab)
0 2 * * * tar -czf /backups/9router-$(date +\%Y\%m\%d).tar.gz /var/lib/9router
```
---
## 📊 Monitoring
### Check Application Status
```bash
# PM2 status
pm2 status
# View logs
pm2 logs 9router --lines 100
# Monitor resources
pm2 monit
```
### Nginx Logs
```bash
# Access logs
sudo tail -f /var/log/nginx/access.log
# Error logs
sudo tail -f /var/log/nginx/error.log
```
### System Resources
```bash
# CPU and memory usage
htop
# Disk usage
df -h
# Network connections
netstat -tulpn | grep -E '3000|20128'
```
---
## 🚨 Troubleshooting
### Application Won't Start
```bash
# Check logs
pm2 logs 9router
# Check if ports are in use
sudo lsof -i :3000
sudo lsof -i :20128
# Check environment variables
pm2 env 9router
```
### Nginx 502 Bad Gateway
```bash
# Check if 9Router is running
pm2 status
# Check Nginx error logs
sudo tail -f /var/log/nginx/error.log
# Test Nginx configuration
sudo nginx -t
```
### SSE Streaming Not Working
Ensure `proxy_buffering off` is set in Nginx configuration for SSE support.
### Permission Denied Errors
```bash
# Fix data directory permissions
sudo chown -R $USER:$USER /var/lib/9router
chmod 755 /var/lib/9router
```
---
## 🔗 Next Steps
- [Connect Providers](/providers/subscription.md)
- [Setup Combos](/features/combos.md)
- [Integrate with Tools](/integration/cursor.md)
+164
View File
@@ -0,0 +1,164 @@
# 🏠 Localhost Deployment
Run 9Router on your local machine for development and personal use.
---
## 📦 Installation
Install 9Router globally via npm:
```bash
npm install -g 9router
```
**Requirements:**
- Node.js 20 or higher
- npm 9 or higher
---
## 🚀 Starting the Server
Start 9Router with a single command:
```bash
9router
```
The dashboard will automatically open in your browser at `http://localhost:3000`
**Default Configuration:**
- **Dashboard**: `http://localhost:3000`
- **API Endpoint**: `http://localhost:20128/v1`
- **Data Directory**: `~/.9router`
---
## 🔧 Configuration
### Custom Data Directory
Set a custom data directory using environment variable:
```bash
DATA_DIR=/path/to/data 9router
```
### Custom Port
The API port (20128) and dashboard port (3000) are configured in the application. To change them, you'll need to modify the source code or use environment variables if supported.
---
## 🛑 Stopping the Server
Press `Ctrl+C` in the terminal where 9Router is running.
```bash
# In the terminal running 9router
^C # Press Ctrl+C
```
The server will gracefully shut down and save all data.
---
## 🔄 Restarting the Server
Simply run the start command again:
```bash
9router
```
All your configurations, API keys, and combos are preserved in the data directory.
---
## 📊 Updating 9Router
Update to the latest version:
```bash
npm update -g 9router
```
Check your current version:
```bash
npm list -g 9router
```
---
## 🔍 Troubleshooting
### Port Already in Use
If port 20128 or 3000 is already in use:
```bash
# Find process using the port (macOS/Linux)
lsof -i :20128
lsof -i :3000
# Kill the process
kill -9 <PID>
```
### Permission Errors
If you encounter permission errors during installation:
```bash
# Use sudo (not recommended)
sudo npm install -g 9router
# Or fix npm permissions (recommended)
mkdir ~/.npm-global
npm config set prefix '~/.npm-global'
echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.bashrc
source ~/.bashrc
```
### Data Directory Issues
If the data directory is not accessible:
```bash
# Check permissions
ls -la ~/.9router
# Fix permissions
chmod 755 ~/.9router
```
---
## 📁 Data Directory Structure
```
~/.9router/
├── db.json # Main database (providers, combos, settings)
├── logs/ # Application logs
└── cache/ # Temporary cache files
```
**Backup Your Data:**
```bash
# Backup
cp -r ~/.9router ~/.9router.backup
# Restore
cp -r ~/.9router.backup ~/.9router
```
---
## 🔗 Next Steps
- [Connect Providers](/providers/subscription.md)
- [Create Combos](/features/combos.md)
- [Integrate with CLI Tools](/integration/cursor.md)
+387
View File
@@ -0,0 +1,387 @@
# Frequently Asked Questions
Common questions about 9Router.
---
## What is 9Router?
**9Router is an AI model router that maximizes your subscription value and minimizes costs.**
It intelligently routes requests across multiple AI providers using a 3-tier fallback system:
1. **Subscription tier** - Maximize Claude Code, Codex, Gemini quotas you already pay for
2. **Cheap tier** - Ultra-cheap alternatives ($0.20-$0.60 per 1M tokens)
3. **Free tier** - Emergency backup with unlimited free models
**Key benefits:**
- Never waste subscription quota
- Automatic fallback when quota exhausted
- Real-time quota tracking
- 90% cost savings vs direct API usage
---
## How does pricing work?
**9Router uses a 3-tier pricing strategy:**
### Tier 1: Subscription (Maximize First)
- **Claude Code** (Pro/Max): $20-100/month - 5-hour + weekly quota
- **OpenAI Codex** (Plus/Pro): $20-200/month - 5-hour + weekly quota
- **Gemini CLI**: FREE - 180K completions/month + 1K/day
- **GitHub Copilot**: $10-19/month - Monthly reset
- **Antigravity**: FREE - Similar to Gemini
**Goal:** Use every bit of quota before it resets!
### Tier 2: Cheap (Backup)
- **GLM-4.7**: $0.60/$2.20 per 1M tokens - Daily reset 10AM
- **MiniMax M2.1**: $0.20/$1.00 per 1M tokens - 5-hour rolling
- **Kimi K2**: $9/month flat (10M tokens)
**Goal:** 90% cheaper than ChatGPT API ($20/1M)!
### Tier 3: Free (Emergency)
- **iFlow**: 8 models FREE (Kimi K2, Qwen3, GLM, MiniMax...)
- **Qwen**: 3 models FREE (Qwen3 Coder Plus/Flash, Vision)
- **Kiro**: 2 models FREE (Claude Sonnet 4.5, Haiku 4.5)
**Goal:** Zero cost fallback when everything else is quota-limited!
---
## Is 9Router free?
**Yes, 9Router itself is 100% free and open source.**
**Free tier providers available:**
- **Gemini CLI** - 180K completions/month (FREE Google account)
- **iFlow** - 8 models unlimited (FREE OAuth)
- **Qwen** - 3 models unlimited (FREE OAuth)
- **Kiro** - Claude Sonnet/Haiku (FREE AWS Builder ID)
**You can code for FREE forever using only free tier providers!**
**Optional paid providers:**
- Subscription services you may already have (Claude Code, Codex, Copilot)
- Ultra-cheap alternatives ($0.20-$0.60 per 1M tokens)
---
## Which providers are supported?
### Subscription Providers
- **Claude Code** (Pro/Max) - Claude 4.5 Opus/Sonnet/Haiku
- **OpenAI Codex** (Plus/Pro) - GPT 5.2 Codex, GPT 5.1 Codex Max
- **Gemini CLI** (FREE) - Gemini 3 Flash/Pro, 2.5 Pro/Flash
- **GitHub Copilot** - GPT-5, Claude 4.5, Gemini 3
- **Antigravity** (Google) - Gemini 3 Pro, Claude Sonnet 4.5
### Cheap Providers
- **GLM** (Zhipu AI) - GLM 4.7, GLM 4.6V Vision
- **MiniMax** - MiniMax M2.1
- **Kimi** (Moonshot AI) - Kimi Latest
- **OpenRouter** - Passthrough to any OpenRouter model
### Free Providers
- **iFlow** - 8 models (Kimi K2, Qwen3, GLM, MiniMax, DeepSeek...)
- **Qwen** - 3 models (Qwen3 Coder Plus/Flash, Vision)
- **Kiro** - 2 models (Claude Sonnet 4.5, Haiku 4.5)
**Total: 15+ providers, 50+ models**
See [providers documentation](providers/subscription.md) for details.
---
## Can I use multiple providers?
**Yes! This is 9Router's core feature.**
**Combos allow you to chain multiple providers with automatic fallback:**
```
Example combo: "premium-coding"
1. cc/claude-opus-4-5 (Subscription primary)
2. glm/glm-4.7 (Cheap backup)
3. if/kimi-k2 (Free emergency)
→ Auto-switches when quota exhausted
→ Never stops coding
→ Minimal extra cost
```
**How to create combos:**
```
Dashboard → Combos → Create New
→ Add models in priority order
→ Use combo name in CLI: "premium-coding"
```
**Benefits:**
- Zero downtime when quota runs out
- Automatic cost optimization
- Single model name for all tools
See [combos documentation](features/combos.md) for examples.
---
## How does quota tracking work?
**9Router tracks quota in real-time for all providers:**
**Features:**
- **Token consumption** - Input/output tokens per request
- **Reset countdown** - Time until quota refreshes
- **Usage stats** - Daily/weekly/monthly reports
- **Cost estimation** - Projected spending (paid tiers)
- **Quota alerts** - Notifications when quota low
**Quota types:**
- **5-hour rolling** - Claude Code, Codex, MiniMax
- **Daily reset** - Gemini CLI (1K/day), GLM (10AM)
- **Weekly reset** - Claude Code, Codex (additional quota)
- **Monthly reset** - Gemini CLI (180K), GitHub Copilot (1st)
**View quota:**
```
Dashboard → Providers → Quota Tracking
→ Real-time usage + reset countdown
```
See [quota tracking documentation](features/quota-tracking.md) for details.
---
## Does 9Router work with Cursor?
**Yes, but Cursor requires a cloud endpoint.**
**Problem:** Cursor IDE doesn't support localhost endpoints.
**Solution:** Use 9Router cloud deployment:
```
Cursor Settings → Models → Advanced:
OpenAI API Base URL: https://9router.com/v1
OpenAI API Key: [from dashboard]
Model: cc/claude-opus-4-5-20251101
```
**Alternative:** Self-host on VPS with public domain:
```bash
# Deploy to VPS
git clone https://github.com/decolua/9router.git
cd 9router/app
npm install && npm run build
npm start
# Configure Nginx reverse proxy
# Point Cursor to: https://your-domain.com/v1
```
**Other CLI tools work with localhost:**
- Cline ✅
- Claude Desktop ✅
- Codex CLI ✅
- Continue ✅
- RooCode ✅
See [Cursor integration guide](integration/cursor.md) for details.
---
## Can I self-host 9Router?
**Yes! 9Router supports multiple deployment options:**
### Localhost (Default)
```bash
npm install -g 9router
9router
→ Dashboard: http://localhost:3000
→ API: http://localhost:20128/v1
```
### VPS/Cloud
```bash
git clone https://github.com/decolua/9router.git
cd 9router/app
npm install && npm run build
export JWT_SECRET="your-secure-secret"
export INITIAL_PASSWORD="your-password"
export NODE_ENV="production"
npm start
```
### Docker
```bash
docker build -t 9router .
docker run -d \
-p 3000:3000 \
-e JWT_SECRET="your-secret" \
-v 9router-data:/app/data \
9router
```
### Cloudflare Workers
```bash
cd 9router/app
npm run deploy:cloudflare
```
**Environment variables:**
- `JWT_SECRET` - **MUST change in production!**
- `DATA_DIR` - Database storage path (default: `~/.9router`)
- `INITIAL_PASSWORD` - Dashboard login (default: `123456`)
- `NODE_ENV` - Set to `production` for deploy
See [deployment guide](getting-started/installation.md#deployment) for details.
---
## Is my data secure?
**Yes, 9Router prioritizes security and privacy:**
**Local storage:**
- All data stored locally in `~/.9router` (or custom `DATA_DIR`)
- No data sent to 9Router servers
- OAuth tokens encrypted with JWT
**No telemetry:**
- No usage tracking
- No analytics
- No phone-home
**Open source:**
- Full source code available on GitHub
- Audit security yourself
- Community-reviewed
**Best practices:**
- Change `JWT_SECRET` in production
- Use strong `INITIAL_PASSWORD`
- Enable HTTPS for cloud deployments
- Rotate API keys regularly
**What 9Router stores:**
- Provider OAuth tokens (encrypted)
- API keys (encrypted)
- Usage statistics (local only)
- Combo configurations
**What 9Router does NOT store:**
- Your prompts or responses
- Code you generate
- Personal information
---
## How do I update 9Router?
**Update methods depend on installation type:**
### Global NPM Install
```bash
npm update -g 9router
```
### Local Install
```bash
cd 9router/app
git pull origin main
npm install
npm run build
npm start
```
### Docker
```bash
docker pull 9router:latest
docker stop 9router
docker rm 9router
docker run -d \
-p 3000:3000 \
-v 9router-data:/app/data \
9router:latest
```
**Check version:**
```bash
9router --version
```
**Breaking changes:**
- Check [CHANGELOG.md](https://github.com/decolua/9router/blob/main/CHANGELOG.md)
- Backup `~/.9router` before major updates
- Review migration guides for major versions
---
## How can I contribute?
**We welcome contributions!**
### Ways to contribute:
1. **Report bugs:**
- [GitHub Issues](https://github.com/decolua/9router/issues)
- Include error logs, steps to reproduce
2. **Request features:**
- [GitHub Discussions](https://github.com/decolua/9router/discussions)
- Describe use case and benefits
3. **Submit code:**
```bash
# Fork repo
git clone https://github.com/YOUR_USERNAME/9router.git
cd 9router
# Create branch
git checkout -b feature/your-feature
# Make changes
npm install
npm run dev
# Test
npm test
# Commit and push
git add .
git commit -m "Add your feature"
git push origin feature/your-feature
# Create Pull Request on GitHub
```
4. **Improve docs:**
- Fix typos, add examples
- Translate to other languages
- Write tutorials
5. **Add providers:**
- Implement new provider adapters
- See `app/lib/providers/` for examples
**Contribution guidelines:**
- Follow existing code style
- Add tests for new features
- Update documentation
- Keep commits atomic and descriptive
See [CONTRIBUTING.md](https://github.com/decolua/9router/blob/main/CONTRIBUTING.md) for details.
---
## Need More Help?
- **Documentation:** [9router.com/docs](https://9router.com/docs)
- **GitHub:** [github.com/decolua/9router](https://github.com/decolua/9router)
- **Issues:** [github.com/decolua/9router/issues](https://github.com/decolua/9router/issues)
- **Troubleshooting:** [troubleshooting.md](troubleshooting.md)
+537
View File
@@ -0,0 +1,537 @@
# Combos - Custom Fallback Chains
Create custom model combinations with automatic fallback. Combos let you define your own routing strategy based on cost, quality, and availability.
---
## What Are Combos?
Combos are **custom fallback chains** that you create in the dashboard. Instead of using a single model, you define a sequence of models that 9Router tries in order.
**Example:**
```
Combo name: premium-coding
Models:
1. cc/claude-opus-4-5-20251101 (try first)
2. glm/glm-4.7 (if #1 quota exhausted)
3. minimax/MiniMax-M2.1 (if #2 quota exhausted)
```
**Usage in CLI:**
```
Model: premium-coding
```
9Router automatically tries each model in sequence until one succeeds.
---
## Why Use Combos?
### 1. Maximize Subscription Value
```
cc/claude-opus → glm/glm-4.7 → if/kimi-k2-thinking
→ Use subscription first, cheap backup, free emergency
→ Get full value from subscriptions you already pay for
```
### 2. Minimize Costs
```
glm/glm-4.7 → minimax/MiniMax-M2.1 → if/kimi-k2-thinking
→ Start with cheapest paid option ($0.60/1M)
→ Fallback to even cheaper ($0.20/1M)
→ Emergency free tier
→ Total cost: ~$5-10/month vs $2000 on ChatGPT API
```
### 3. Ensure 24/7 Availability
```
cc/claude-opus → cx/gpt-5.2-codex → glm/glm-4.7 → if/kimi-k2-thinking
→ Always include free tier at the end
→ Never run out of quota
→ Code anytime, anywhere
```
### 4. Optimize for Quality
```
cc/claude-opus-4-5 → cx/gpt-5.2-codex → gc/gemini-3-pro
→ Best models first
→ Fallback to other premium models
→ Maintain high quality across fallback chain
```
---
## How to Create Combos
### Step 1: Open Dashboard
```
http://localhost:20128
→ Login with your password
```
### Step 2: Navigate to Combos
```
Dashboard → Combos → Create New Combo
```
### Step 3: Configure Combo
**Combo Name:**
```
premium-coding
```
**Description (optional):**
```
Subscription first, cheap backup, free emergency
```
**Select Models:**
```
1. cc/claude-opus-4-5-20251101
2. glm/glm-4.7
3. minimax/MiniMax-M2.1
```
**Drag to reorder** - Priority from top to bottom.
### Step 4: Save
```
Click "Save Combo"
→ Combo appears in model list
```
### Step 5: Use in CLI
```
Cursor/Cline/Any tool:
Model: premium-coding
```
---
## Example Combos
### Example 1: Premium Coding (Subscription → Cheap → Free)
**Goal**: Maximize subscription value, minimize extra costs.
```
Dashboard → Combos → Create New
Name: premium-coding
Models:
1. cc/claude-opus-4-5-20251101
2. glm/glm-4.7
3. minimax/MiniMax-M2.1
```
**Usage:**
```
Cursor IDE:
Model: premium-coding
```
**Behavior:**
```
Morning (fresh quota):
Request → cc/claude-opus-4-5 ✅
Afternoon (Claude quota out):
Request → glm/glm-4.7 ✅ (auto switched)
Evening (GLM quota out):
Request → minimax/MiniMax-M2.1 ✅ (auto switched)
```
**Monthly cost (100M tokens):**
```
80M via Claude Code: $0 (subscription)
15M via GLM: $9
5M via MiniMax: $1
Total: $10 + your subscription
```
**Savings**: ~99% vs ChatGPT API ($2000).
---
### Example 2: Budget Combo (Cheap → Free)
**Goal**: Minimize costs, use free tier as backup.
```
Dashboard → Combos → Create New
Name: budget-combo
Models:
1. glm/glm-4.7
2. minimax/MiniMax-M2.1
3. if/kimi-k2-thinking
```
**Usage:**
```
Cline:
Provider: OpenAI Compatible
Base URL: http://localhost:20128/v1
Model: budget-combo
```
**Behavior:**
```
Request → glm/glm-4.7
✅ Daily quota available → Use GLM ($0.60/1M)
❌ Quota exhausted → Try MiniMax ($0.20/1M)
❌ MiniMax quota out → Use iFlow (FREE)
```
**Monthly cost (100M tokens):**
```
70M via GLM: $42
20M via MiniMax: $4
10M via iFlow: $0
Total: $46 vs $2000 on ChatGPT API
```
**Savings**: 97%.
---
### Example 3: Free Combo (Zero Cost)
**Goal**: 100% free, no costs ever.
```
Dashboard → Combos → Create New
Name: free-combo
Models:
1. if/kimi-k2-thinking
2. qw/qwen3-coder-plus
3. kr/claude-sonnet-4.5
```
**Usage:**
```
Claude Desktop:
Model: free-combo
```
**Behavior:**
```
Request → if/kimi-k2-thinking
✅ Available → Use iFlow
❌ Error → Try Qwen
❌ Error → Try Kiro
```
**Monthly cost:**
```
100M tokens via free providers: $0
Total: $0 forever
```
**Use case**: Personal projects, learning, experimentation.
---
### Example 4: Quality First (Premium Models Only)
**Goal**: Best quality, no cheap fallback.
```
Dashboard → Combos → Create New
Name: quality-first
Models:
1. cc/claude-opus-4-5-20251101
2. cx/gpt-5.2-codex
3. gc/gemini-3-pro-preview
```
**Usage:**
```
Codex CLI:
export OPENAI_BASE_URL="http://localhost:20128"
Model: quality-first
```
**Behavior:**
```
Request → cc/claude-opus-4-5
❌ Quota out → cx/gpt-5.2-codex
❌ Quota out → gc/gemini-3-pro-preview
❌ All out → Return error (no cheap fallback)
```
**Use case**: Critical production code, complex refactoring.
---
### Example 5: Multi-Subscription (Maximize All)
**Goal**: Use all subscriptions before paying extra.
```
Dashboard → Combos → Create New
Name: multi-sub
Models:
1. gc/gemini-3-flash-preview (FREE 180K/month)
2. cc/claude-opus-4-5-20251101 (Pro subscription)
3. cx/gpt-5.2-codex (Plus subscription)
4. gh/gpt-5 (Copilot subscription)
5. glm/glm-4.7 (Cheap backup)
6. if/kimi-k2-thinking (Free emergency)
```
**Monthly cost (200M tokens):**
```
50M via Gemini CLI: $0 (free tier)
80M via Claude Code: $0 (subscription)
40M via Codex: $0 (subscription)
20M via Copilot: $0 (subscription)
8M via GLM: $4.80
2M via iFlow: $0
Total: $4.80 + existing subscriptions
```
**Result**: Use 190M tokens from subscriptions, only $4.80 extra.
---
### Example 6: Quota Reset Optimization
**Goal**: Distribute usage based on reset times.
```
Dashboard → Combos → Create New
Name: reset-optimized
Models:
1. cc/claude-opus-4-5 (5h reset, use morning)
2. gc/gemini-3-flash (1K/day, use afternoon)
3. glm/glm-4.7 (daily 10AM reset, use evening)
4. minimax/MiniMax-M2.1 (5h rolling, use night)
5. if/kimi-k2-thinking (unlimited, emergency)
```
**Daily routine:**
```
08:00 - 13:00: Claude Code (fresh 5h quota)
13:00 - 18:00: Gemini CLI (1K/day quota)
18:00 - 22:00: GLM (resets 10AM next day)
22:00 - 08:00: MiniMax (5h rolling) or iFlow
```
**Result**: Code 24/7 with minimal costs.
---
## Use Combos in CLI Tools
### Cursor IDE
```
Settings → Models → Advanced:
OpenAI API Base URL: http://localhost:20128/v1
OpenAI API Key: [from dashboard]
Model: premium-coding
```
### Claude Desktop
Edit `~/.claude/config.json`:
```json
{
"anthropic_api_base": "http://localhost:20128/v1",
"anthropic_api_key": "your-9router-api-key",
"model": "budget-combo"
}
```
### Codex CLI
```bash
export OPENAI_BASE_URL="http://localhost:20128"
export OPENAI_API_KEY="your-9router-api-key"
codex --model quality-first "your prompt"
```
### Cline / Continue / RooCode
```
Provider: OpenAI Compatible
Base URL: http://localhost:20128/v1
API Key: [from dashboard]
Model: free-combo
```
### API Request
```bash
curl http://localhost:20128/v1/chat/completions \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"model": "premium-coding",
"messages": [
{"role": "user", "content": "Write a function to..."}
],
"stream": true
}'
```
---
## Best Practices
### 1. Always Include Free Tier
```
✅ Good:
cc/claude-opus → glm/glm-4.7 → if/kimi-k2-thinking
❌ Bad:
cc/claude-opus → glm/glm-4.7
(no free fallback, can run out of quota)
```
**Why**: Ensures 24/7 availability, never blocked by quota.
### 2. Order by Cost (Cheap to Expensive)
```
✅ Good:
glm/glm-4.7 → minimax/MiniMax-M2.1 → cc/claude-opus
❌ Bad:
cc/claude-opus → glm/glm-4.7
(wastes subscription quota on simple tasks)
```
**Exception**: If you want to maximize subscription value, put subscription first.
### 3. Match Quality Requirements
```
For production code:
cc/claude-opus → cx/gpt-5.2-codex → glm/glm-4.7
For quick tasks:
glm/glm-4.7 → if/kimi-k2-thinking
For experimentation:
if/kimi-k2-thinking → qw/qwen3-coder-plus
```
### 4. Consider Quota Reset Times
```
Morning combo (fresh quotas):
cc/claude-opus → cx/gpt-5.2-codex
Evening combo (quotas likely exhausted):
glm/glm-4.7 → minimax/MiniMax-M2.1 → if/kimi-k2-thinking
```
### 5. Create Multiple Combos for Different Use Cases
```
premium-coding: For complex tasks
budget-combo: For simple tasks
free-combo: For experimentation
quality-first: For production code
```
**Switch between combos** based on task requirements.
### 6. Monitor Combo Performance
```
Dashboard → Analytics → Combo Usage:
premium-coding:
80% via cc/claude-opus (good, using subscription)
15% via glm/glm-4.7 (acceptable backup)
5% via minimax (rare fallback)
```
**Optimize**: If too much fallback usage, increase primary quota or reorder models.
---
## Advanced Configuration
### Set Budget Limits per Combo
```
Dashboard → Combos → Edit → Budget:
Daily limit: $5
Monthly limit: $50
```
When limit reached, 9Router skips paid models and uses free tier only.
### Enable/Disable Models in Combo
```
Dashboard → Combos → Edit → Models:
✅ cc/claude-opus-4-5 (enabled)
❌ glm/glm-4.7 (temporarily disabled)
✅ if/kimi-k2-thinking (enabled)
```
**Use case**: Temporarily disable expensive models without deleting combo.
### Clone Existing Combo
```
Dashboard → Combos → Clone "premium-coding"
→ Creates copy with "-copy" suffix
→ Modify and save as new combo
```
**Use case**: Create variations for different scenarios.
---
## Troubleshooting
**Issue: Combo not appearing in model list**
**Solution:**
1. Refresh dashboard
2. Check combo is saved (green checkmark)
3. Restart CLI tool to refresh model list
**Issue: Combo always uses last model (free tier)**
**Solution:**
1. Check quota for primary models (Dashboard → Quota)
2. Verify API keys are valid (Dashboard → Providers)
3. Check budget limits not exceeded
**Issue: Combo costs more than expected**
**Solution:**
1. Dashboard → Analytics → Review combo usage
2. Check if primary models are quota-exhausted
3. Reorder models (put cheaper first)
4. Set budget limits
---
## Related
- [Smart Routing](./smart-routing.md) - How auto fallback works
- [Quota Tracking](./quota-tracking.md) - Monitor usage and costs
@@ -0,0 +1,687 @@
# Quota Tracking & Usage Monitoring
Track real-time token consumption, monitor quota limits, estimate costs, and get alerts before running out. Never waste subscription quota or exceed budget limits.
---
## Overview
9Router provides comprehensive quota tracking for all providers:
- **Real-time token consumption** - See tokens used per request
- **Quota limits & remaining** - Track usage vs limits
- **Reset countdown** - Know when quota refreshes
- **Cost estimation** - Calculate spending for paid tiers
- **Monthly reports** - Analyze usage patterns
- **Alerts & notifications** - Get warned before limits
---
## Dashboard Overview
### Quota Summary
```
Dashboard → Home → Quota Overview
┌─────────────────────────────────────────────┐
│ Claude Code (cc/) │
│ ████████████░░░░░░░░ 2.5h / 5h (50%) │
│ Resets in: 2h 30m │
│ Cost: $0 (subscription) │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ Gemini CLI (gc/) │
│ ████████░░░░░░░░░░░░ 450 / 1000 (45%) │
│ Daily reset in: 18h 30m │
│ Monthly: 45K / 180K (25%) │
│ Cost: $0 (free tier) │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ GLM-4.7 (glm/) │
│ ██████████████░░░░░░ 7M / 10M tokens (70%) │
│ Resets: Daily 10:00 AM (in 5h 35m) │
│ Cost today: $4.20 │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ MiniMax M2.1 (minimax/) │
│ ████████████████░░░░ 4M / 5M tokens (80%) │
│ Rolling 5h window │
│ Cost (5h): $0.80 │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ iFlow (if/) │
│ ████████████████████ Unlimited │
│ Cost: $0 (free forever) │
└─────────────────────────────────────────────┘
```
---
## Real-Time Token Consumption
### Per-Request Tracking
Every request shows detailed token usage:
```
Dashboard → Activity → Recent Requests
Request #1234
Model: cc/claude-opus-4-5-20251101
Timestamp: 2026-02-04 04:15:32
Tokens:
Input: 1,250 tokens
Output: 850 tokens
Total: 2,100 tokens
Cost: $0 (subscription quota)
Duration: 3.2s
Status: ✅ Success
```
### Live Usage Monitor
```
Dashboard → Live Monitor
Current request:
Model: glm/glm-4.7
Tokens streamed: 450 / ~800 estimated
Cost so far: $0.0009
Duration: 1.8s
```
### Token Breakdown by Model
```
Dashboard → Analytics → Token Usage
Today (Feb 4, 2026):
cc/claude-opus-4-5: 15M tokens ($0, subscription)
glm/glm-4.7: 8M tokens ($4.80)
if/kimi-k2-thinking: 3M tokens ($0, free)
Total: 26M tokens
Cost: $4.80
```
---
## Quota Limits & Reset Times
### Subscription Providers
**Claude Code (Pro/Max)**
```
Quota type: Time-based (5-hour rolling)
Limit: 5 hours of usage
Reset: Rolling 5-hour window + Weekly refresh
Tracking: Usage time per model
Dashboard shows:
Opus: 2.5h / 5h used
Sonnet: 1.2h / 5h used
Haiku: 0.8h / 5h used
Weekly reset: Every Monday 00:00 UTC
```
**OpenAI Codex (Plus/Pro)**
```
Quota type: Time-based (5-hour rolling)
Limit: 5 hours (Plus) / 10 hours (Pro)
Reset: Rolling 5-hour window + Weekly refresh
Dashboard shows:
GPT-5.2 Codex: 3.5h / 5h used
Resets in: 1h 30m
```
**Gemini CLI (FREE)**
```
Quota type: Request count + Monthly tokens
Daily limit: 1,000 requests
Monthly limit: 180,000 completions
Reset: Daily 00:00 UTC + Monthly 1st
Dashboard shows:
Today: 450 / 1,000 requests (45%)
This month: 45K / 180K completions (25%)
Daily reset in: 18h 30m
Monthly reset in: 26 days
```
**GitHub Copilot**
```
Quota type: Monthly usage
Limit: Varies by plan
Reset: 1st of each month
Dashboard shows:
Usage: 60% of monthly quota
Resets: March 1, 2026 (in 25 days)
```
### Cheap Providers
**GLM-4.7**
```
Quota type: Daily token limit
Limit: 10M tokens/day (Coding Plan)
Reset: Daily 10:00 AM Beijing Time (UTC+8)
Dashboard shows:
Used: 7M / 10M tokens (70%)
Remaining: 3M tokens
Resets in: 5h 35m
Cost today: $4.20
```
**MiniMax M2.1**
```
Quota type: Rolling 5-hour window
Limit: 5M tokens per 5 hours
Reset: Continuous rolling window
Dashboard shows:
Used (5h): 4M / 5M tokens (80%)
Oldest usage expires in: 45m
Cost (5h): $0.80
```
**Kimi K2**
```
Quota type: Monthly subscription
Limit: 10M tokens/month ($9 flat)
Reset: Monthly on subscription date
Dashboard shows:
Used: 6M / 10M tokens (60%)
Resets: Feb 15, 2026 (in 11 days)
Cost: $9/month (prepaid)
```
### Free Providers
**iFlow / Qwen / Kiro**
```
Quota type: Unlimited (rate-limited)
Limit: No hard limit
Reset: N/A
Dashboard shows:
Used today: 5M tokens
Cost: $0 (free forever)
Status: ✅ Available
```
---
## Cost Estimation
### Real-Time Cost Tracking
```
Dashboard → Costs → Today
Subscription providers: $0
Claude Code: 15M tokens ($0, included)
Gemini CLI: 3M tokens ($0, free tier)
Paid providers: $4.80
GLM-4.7: 8M tokens ($4.80)
Input: 6M × $0.60/1M = $3.60
Output: 2M × $2.20/1M = $4.40
Total: $4.80
Free providers: $0
iFlow: 3M tokens ($0)
Total today: $4.80
```
### Monthly Spending Report
```
Dashboard → Costs → This Month (February 2026)
Week 1 (Feb 1-7):
Subscription: $0 (80M tokens)
Paid: $15.20 (25M tokens)
Free: $0 (10M tokens)
Total: $15.20
Week 2 (Feb 8-14):
Subscription: $0 (75M tokens)
Paid: $12.80 (20M tokens)
Free: $0 (8M tokens)
Total: $12.80
Month to date: $28.00
Projected (30 days): ~$120
Breakdown by provider:
GLM-4.7: $22.00 (78%)
MiniMax M2.1: $6.00 (22%)
Average cost per 1M tokens: $0.62
Savings vs ChatGPT API: 97% ($4,000 → $120)
```
### Cost Projection
```
Dashboard → Costs → Projections
Based on last 7 days usage:
Daily average: 50M tokens
Daily cost: $4.50
Monthly projection:
Tokens: 1,500M (1.5B)
Cost: $135
Breakdown:
Subscription: 900M tokens ($0)
GLM-4.7: 450M tokens ($90)
MiniMax: 120M tokens ($24)
Free: 30M tokens ($0)
Budget status:
Daily limit: $5 → 90% used today
Monthly limit: $150 → 90% projected
⚠️ Warning: May exceed monthly budget
```
---
## Usage Dashboard
### Overview Stats
```
Dashboard → Analytics → Overview
Today (Feb 4, 2026):
Requests: 1,234
Tokens: 26M
Cost: $4.80
Avg response time: 2.1s
This week:
Requests: 8,456
Tokens: 180M
Cost: $28.00
Success rate: 99.2%
This month:
Requests: 15,234
Tokens: 320M
Cost: $52.00
Top model: cc/claude-opus-4-5 (45%)
```
### Usage by Model
```
Dashboard → Analytics → Models
Top models (this month):
1. cc/claude-opus-4-5: 145M tokens (45%)
2. glm/glm-4.7: 95M tokens (30%)
3. if/kimi-k2-thinking: 50M tokens (16%)
4. minimax/MiniMax-M2.1: 20M tokens (6%)
5. gc/gemini-3-flash: 10M tokens (3%)
Cost breakdown:
cc/claude-opus: $0 (subscription)
glm/glm-4.7: $45.00
if/kimi-k2-thinking: $0 (free)
minimax/MiniMax-M2.1: $7.00
gc/gemini-3-flash: $0 (free)
```
### Usage by Time
```
Dashboard → Analytics → Timeline
Hourly usage (today):
00:00 - 01:00: 0.5M tokens
01:00 - 02:00: 0.2M tokens
...
08:00 - 09:00: 3.2M tokens (peak)
09:00 - 10:00: 2.8M tokens
...
23:00 - 00:00: 0.8M tokens
Peak hours: 08:00 - 12:00 (morning coding)
Low hours: 00:00 - 06:00 (night)
```
### Usage by Combo
```
Dashboard → Analytics → Combos
premium-coding:
Requests: 456
Tokens: 12M
Cost: $2.40
Breakdown:
cc/claude-opus: 8M tokens (67%, $0)
glm/glm-4.7: 3M tokens (25%, $1.80)
minimax/MiniMax-M2.1: 1M tokens (8%, $0.20)
budget-combo:
Requests: 234
Tokens: 6M
Cost: $1.20
Breakdown:
glm/glm-4.7: 4M tokens (67%, $2.40)
if/kimi-k2-thinking: 2M tokens (33%, $0)
```
---
## Alerts & Notifications
### Quota Alerts
```
Dashboard → Settings → Alerts
Quota warnings:
✅ Alert at 80% quota used
✅ Alert at 90% quota used
✅ Alert when quota exhausted
✅ Notify when quota resets
Delivery:
✅ Dashboard notification
✅ Email (optional)
✅ Webhook (optional)
```
**Example notifications:**
```
⚠️ Claude Code quota 80% used
2.5h remaining (resets in 1h 30m)
⚠️ GLM-4.7 quota 90% used
1M tokens remaining (resets in 5h)
✅ Gemini CLI quota reset
1,000 requests available (daily limit)
```
### Budget Alerts
```
Dashboard → Settings → Budget Alerts
Daily budget: $5
✅ Alert at 80% ($4)
✅ Alert at 100% ($5)
✅ Auto-switch to free tier when exceeded
Monthly budget: $150
✅ Alert at 50% ($75)
✅ Alert at 80% ($120)
✅ Alert at 100% ($150)
```
**Example notifications:**
```
⚠️ Daily budget 80% used
$4.00 / $5.00 spent today
⚠️ Monthly budget 50% reached
$75 / $150 spent this month
Projected: $135 (within budget)
🚨 Daily budget exceeded
$5.20 / $5.00 spent today
Auto-switched to free tier
```
### Cost Anomaly Detection
```
Dashboard → Settings → Anomaly Detection
✅ Detect unusual spending patterns
✅ Alert on cost spikes (>2× daily average)
✅ Warn on quota exhaustion patterns
Example alert:
⚠️ Cost spike detected
Today: $12.50 (2.5× daily average)
Reason: High GLM-4.7 usage (20M tokens)
Suggestion: Check if primary models quota-exhausted
```
---
## Best Practices
### 1. Monitor Quota Daily
```
Daily routine:
1. Check dashboard quota overview (30 seconds)
2. Review reset times
3. Plan usage around quota availability
```
**Example:**
```
Morning check:
✅ Claude Code: 5h available (fresh reset)
✅ Gemini CLI: 1K requests available
⚠️ GLM-4.7: 2M tokens left (resets 10AM)
Action: Use Claude Code for morning work
```
### 2. Set Budget Limits
```
Dashboard → Settings → Budget:
Daily: $5 (prevents overspending)
Monthly: $150 (aligns with budget)
```
**Result**: Auto-switch to free tier when limit reached.
### 3. Optimize Combo Usage
```
Dashboard → Analytics → Combos:
Review which models are used most
Adjust combo order to minimize costs
```
**Example:**
```
Current: cc/claude-opus → glm/glm-4.7
80% via Claude (good)
20% via GLM ($12/month)
Optimized: gc/gemini-3-flash → cc/claude-opus → glm/glm-4.7
50% via Gemini (free)
40% via Claude (subscription)
10% via GLM ($6/month)
Savings: $6/month
```
### 4. Track Reset Times
```
Dashboard → Quota → Reset Schedule:
Claude Code: 5h rolling + Weekly Monday
Gemini CLI: Daily 00:00 UTC + Monthly 1st
GLM-4.7: Daily 10:00 AM Beijing Time
MiniMax: Rolling 5h window
```
**Strategy**: Use providers when quota is fresh.
### 5. Review Monthly Reports
```
Dashboard → Analytics → Monthly Report:
Total tokens: 1.5B
Total cost: $120
Savings: 97% vs ChatGPT API
Insights:
- 60% usage via subscriptions ($0)
- 30% via GLM ($90)
- 10% via free tier ($0)
Optimization:
- Increase Gemini CLI usage (free)
- Reduce GLM usage (expensive)
```
---
## API Access
### Get Quota Status
```bash
GET http://localhost:20128/api/quota
Authorization: Bearer your-api-key
Response:
{
"providers": [
{
"id": "cc",
"name": "Claude Code",
"quota": {
"used": 2.5,
"limit": 5,
"unit": "hours",
"percentage": 50
},
"reset": {
"type": "rolling",
"window": "5h",
"nextReset": "2026-02-04T06:45:00Z"
},
"cost": {
"today": 0,
"month": 0,
"currency": "USD"
}
},
{
"id": "glm",
"name": "GLM-4.7",
"quota": {
"used": 7000000,
"limit": 10000000,
"unit": "tokens",
"percentage": 70
},
"reset": {
"type": "daily",
"time": "10:00 AM UTC+8",
"nextReset": "2026-02-04T10:00:00+08:00"
},
"cost": {
"today": 4.20,
"month": 52.00,
"currency": "USD"
}
}
]
}
```
### Get Usage Stats
```bash
GET http://localhost:20128/api/usage?period=today
Authorization: Bearer your-api-key
Response:
{
"period": "today",
"date": "2026-02-04",
"summary": {
"requests": 1234,
"tokens": 26000000,
"cost": 4.80
},
"byModel": [
{
"model": "cc/claude-opus-4-5",
"requests": 456,
"tokens": 15000000,
"cost": 0
},
{
"model": "glm/glm-4.7",
"requests": 234,
"tokens": 8000000,
"cost": 4.80
}
]
}
```
---
## Troubleshooting
**Issue: Quota shows 0% but requests failing**
**Solution:**
1. Check provider connection (Dashboard → Providers)
2. Verify API keys are valid
3. Check if provider is down (status page)
4. Try reconnecting OAuth providers
**Issue: Cost estimation incorrect**
**Solution:**
1. Dashboard → Settings → Pricing
2. Verify pricing per provider matches current rates
3. Update pricing if provider changed rates
4. Contact support if discrepancy persists
**Issue: Reset time not updating**
**Solution:**
1. Refresh dashboard (F5)
2. Check system time is correct
3. Verify timezone settings
4. Restart 9Router if issue persists
**Issue: Alerts not received**
**Solution:**
1. Dashboard → Settings → Alerts
2. Verify email address is correct
3. Check spam folder
4. Test notification (Send Test button)
---
## Related
- [Smart Routing](./smart-routing.md) - Auto fallback based on quota
- [Combos](./combos.md) - Create custom fallback chains
@@ -0,0 +1,407 @@
# Smart Routing & Auto Fallback
9Router automatically routes your requests through the best available provider using a 3-tier fallback system. Never stop coding due to quota limits or rate limiting.
---
## How It Works
9Router uses intelligent routing to maximize your existing subscriptions, minimize costs, and ensure 24/7 availability:
```
Request → 9Router → Check Tier 1 (Subscription)
↓ quota exhausted
Check Tier 2 (Cheap)
↓ budget limit
Check Tier 3 (Free)
Response
```
### 3-Tier Fallback System
**Tier 1: SUBSCRIPTION (Primary)**
- Claude Code (Pro/Max)
- OpenAI Codex (Plus/Pro)
- Gemini CLI (FREE 180K/month)
- GitHub Copilot
- Antigravity (Google)
**Goal**: Maximize value from subscriptions you already pay for.
**Tier 2: CHEAP (Backup)**
- GLM-4.7 ($0.60/1M input)
- MiniMax M2.1 ($0.20/1M input)
- Kimi K2 ($9/month flat)
**Goal**: Ultra-cheap backup when subscription quota runs out (~90% cheaper than ChatGPT API).
**Tier 3: FREE (Emergency)**
- iFlow (8 models)
- Qwen (3 models)
- Kiro (Claude FREE)
**Goal**: Zero-cost fallback for unlimited coding.
---
## Automatic Switching
9Router monitors quota in real-time and switches providers automatically:
### Scenario 1: Subscription Quota Exhausted
```
User request → cc/claude-opus-4-5
↓ quota exhausted (5-hour limit reached)
Auto switch → glm/glm-4.7
↓ daily quota exhausted
Auto switch → minimax/MiniMax-M2.1
↓ 5-hour quota exhausted
Auto switch → if/kimi-k2-thinking (FREE)
Response delivered ✅
```
**Result**: Zero downtime, seamless experience.
### Scenario 2: Rate Limiting
```
User request → cx/gpt-5.2-codex
↓ rate limited (too many requests)
Auto switch → glm/glm-4.7
Response delivered ✅
```
### Scenario 3: Provider Unavailable
```
User request → cc/claude-opus-4-5
↓ provider error (503)
Auto switch → next available model
Response delivered ✅
```
---
## Model Selection Logic
9Router selects the best model based on:
1. **Quota availability** - Check if provider has remaining quota
2. **Cost tier** - Prefer subscription → cheap → free
3. **Reset timing** - Consider when quota resets
4. **Provider health** - Skip providers with errors
### Priority Order Example
For a request to `cc/claude-opus-4-5`:
```
1. Check Claude Code quota
✅ Available → Use cc/claude-opus-4-5
❌ Exhausted → Continue to step 2
2. Check fallback tier (if configured)
✅ GLM quota available → Use glm/glm-4.7
❌ Exhausted → Continue to step 3
3. Check free tier
✅ iFlow available → Use if/kimi-k2-thinking
❌ All exhausted → Return quota error
```
---
## Configuration Options
### Dashboard Settings
**1. Enable/Disable Auto Fallback**
```
Dashboard → Settings → Smart Routing
→ Toggle "Auto Fallback" ON/OFF
```
- **ON** (default): Automatic tier switching
- **OFF**: Strict mode, return error if primary model unavailable
**2. Set Budget Limits**
```
Dashboard → Settings → Budget Control
→ Daily limit: $5
→ Monthly limit: $50
```
When budget reached, 9Router automatically switches to free tier.
**3. Configure Fallback Order**
```
Dashboard → Settings → Fallback Priority
→ Drag to reorder providers within each tier
```
Example custom order:
```
Tier 1: Gemini CLI → Claude Code → Codex
Tier 2: MiniMax → GLM → Kimi
Tier 3: iFlow → Kiro → Qwen
```
**4. Quota Reset Notifications**
```
Dashboard → Settings → Notifications
→ Email when quota resets
→ Alert when 80% quota used
```
---
## Examples
### Example 1: Basic Auto Fallback
**Setup:**
```
Model: cc/claude-opus-4-5-20251101
Fallback: Auto (default 3-tier)
```
**Behavior:**
```
Morning (fresh quota):
Request → cc/claude-opus-4-5 ✅
Afternoon (quota exhausted):
Request → glm/glm-4.7 ✅ (auto switched)
Evening (GLM quota out):
Request → minimax/MiniMax-M2.1 ✅ (auto switched)
Late night (all paid quota out):
Request → if/kimi-k2-thinking ✅ (free tier)
```
**Cost**: ~$5-10/month extra (mostly covered by subscription).
### Example 2: Budget-Conscious Routing
**Setup:**
```
Dashboard → Settings:
Daily budget: $2
Monthly budget: $20
Fallback: Enabled
```
**Behavior:**
```
Day 1-15 (within budget):
Requests → glm/glm-4.7 (cheap tier)
Cost: $1.50/day
Day 16 (budget reached):
Requests → if/kimi-k2-thinking (free tier)
Cost: $0
Next month (budget resets):
Requests → glm/glm-4.7 again
```
**Result**: Never exceed $20/month, always available.
### Example 3: Subscription-Only Mode
**Setup:**
```
Dashboard → Settings:
Auto Fallback: OFF
Strict mode: ON
```
**Behavior:**
```
Request → cc/claude-opus-4-5
✅ Quota available → Success
❌ Quota exhausted → Return error (no fallback)
```
**Use case**: When you only want to use paid subscriptions, no extra costs.
### Example 4: Free-Only Mode
**Setup:**
```
Model: if/kimi-k2-thinking
Fallback: qw/qwen3-coder-plus → kr/claude-sonnet-4.5
```
**Behavior:**
```
All requests → Free tier only
Cost: $0 forever
```
**Use case**: Personal projects, learning, experimentation.
---
## Best Practices
### 1. Maximize Subscription Value
```
Strategy:
- Set subscription models as Tier 1
- Monitor quota usage in dashboard
- Use cheap tier only when subscription exhausted
```
**Example combo:**
```
cc/claude-opus-4-5 → glm/glm-4.7 → if/kimi-k2-thinking
```
### 2. Optimize for Cost
```
Strategy:
- Use Gemini CLI free tier first (180K/month)
- Fallback to GLM/MiniMax (ultra-cheap)
- Emergency: iFlow (free)
```
**Example combo:**
```
gc/gemini-3-flash-preview → glm/glm-4.7 → if/kimi-k2-thinking
```
### 3. Optimize for Quality
```
Strategy:
- Use best models (Claude Opus, GPT-5.2)
- Fallback to good cheap models (GLM-4.7)
- Last resort: Free tier
```
**Example combo:**
```
cc/claude-opus-4-5 → cx/gpt-5.2-codex → glm/glm-4.7
```
### 4. 24/7 Availability
```
Strategy:
- Always include free tier in fallback
- Monitor quota reset times
- Distribute usage across providers
```
**Example combo:**
```
cc/claude-opus-4-5 → glm/glm-4.7 → minimax/MiniMax-M2.1 → if/kimi-k2-thinking
```
**Result**: Never run out of quota, code anytime.
---
## Quota Reset Strategy
Plan your usage around quota reset times:
| Provider | Quota Reset | Strategy |
|----------|-------------|----------|
| **Claude Code** | 5-hour + weekly | Use in morning, fresh quota |
| **Codex** | 5-hour + weekly | Use after Claude quota out |
| **Gemini CLI** | Daily (1K) + Monthly (180K) | Use throughout day |
| **GLM-4.7** | Daily 10:00 AM | Use evening, resets next morning |
| **MiniMax M2.1** | 5-hour rolling | Use anytime, tracks rolling window |
| **iFlow/Qwen/Kiro** | No limit | Emergency backup |
**Daily routine example:**
```
08:00 - 13:00: Claude Code (fresh 5h quota)
13:00 - 18:00: Gemini CLI (1K/day quota)
18:00 - 22:00: GLM-4.7 (cheap, resets 10AM)
22:00 - 08:00: MiniMax or iFlow (5h rolling or free)
```
---
## Monitoring & Alerts
### Dashboard Quota Tracker
```
Dashboard → Quota Overview:
Claude Code: 2.5h / 5h remaining (50%)
Gemini CLI: 450 / 1000 requests today
GLM-4.7: 5M / 10M tokens (resets in 8h)
MiniMax: 3M / 5M tokens (rolling 5h)
```
### Real-Time Notifications
```
Dashboard → Notifications:
⚠️ Claude Code quota 80% used (1h remaining)
✅ GLM-4.7 quota reset (10M tokens available)
💰 Daily budget 50% used ($2.50 / $5)
```
### Usage Analytics
```
Dashboard → Analytics:
Today: 50M tokens
- 30M via Claude Code (subscription)
- 15M via GLM-4.7 ($9)
- 5M via iFlow (free)
Cost: $9 (vs $1000 on ChatGPT API)
Savings: 99%
```
---
## Troubleshooting
**Issue: "All providers quota exhausted"**
**Solution:**
1. Check dashboard quota tracker
2. Wait for quota reset (see countdown)
3. Add free tier to fallback chain
4. Or increase budget limit
**Issue: "Too many fallback switches"**
**Solution:**
1. Check if primary provider is down
2. Increase quota limits (upgrade subscription)
3. Use cheaper primary model (GLM instead of Claude)
**Issue: "Unexpected costs"**
**Solution:**
1. Dashboard → Analytics → Review usage
2. Set daily/monthly budget limits
3. Switch to free tier for non-critical tasks
4. Use combos with free fallback
---
## Related
- [Combos](./combos.md) - Create custom fallback chains
- [Quota Tracking](./quota-tracking.md) - Monitor usage and costs
@@ -0,0 +1,478 @@
# Installation
Detailed installation guide for 9Router with troubleshooting tips.
---
## Requirements
### System Requirements
- **Node.js**: Version 20.0.0 or higher
- **npm**: Version 10.0.0 or higher (comes with Node.js)
- **OS**: macOS, Linux, Windows (WSL recommended)
- **Disk Space**: ~200MB for installation
### Check Your Version
```bash
node --version
# Should show v20.x.x or higher
npm --version
# Should show 10.x.x or higher
```
**Don't have Node.js?** Install from [nodejs.org](https://nodejs.org/)
---
## Installation Methods
### Method 1: Global Installation (Recommended)
Install 9Router globally to use from anywhere:
```bash
npm install -g 9router
```
**Start 9Router:**
```bash
9router
```
**Benefits:**
- ✅ Run from any directory
- ✅ Simple command: `9router`
- ✅ Auto-updates with `npm update -g 9router`
### Method 2: Local Installation
Install in a specific project:
```bash
mkdir my-9router
cd my-9router
npm install 9router
```
**Start 9Router:**
```bash
npx 9router
```
**Benefits:**
- ✅ Isolated per project
- ✅ Version control per project
- ✅ No global namespace pollution
### Method 3: From Source (Development)
Clone and build from GitHub:
```bash
git clone https://github.com/decolua/9router.git
cd 9router/app
npm install
npm run build
npm start
```
**Benefits:**
- ✅ Latest development features
- ✅ Contribute to development
- ✅ Custom modifications
---
## First Run
### Start the Server
```bash
9router
```
**What happens:**
1. Server starts on `http://localhost:20128`
2. Dashboard opens automatically in browser
3. Data directory created at `~/.9router`
4. API key generated automatically
### Dashboard Login
**Default credentials:**
- Password: `123456`
**⚠️ Change password immediately:**
1. Login to dashboard
2. Settings → Change Password
3. Use strong password
### Get Your API Key
```
Dashboard → Settings → API Keys
→ Copy your API key
→ Use in CLI tools
```
**Example API key format:**
```
9r_1234567890abcdef1234567890abcdef
```
---
## Verify Installation
### Check Server Status
```bash
curl http://localhost:20128/health
```
**Expected response:**
```json
{
"status": "ok",
"version": "1.0.0"
}
```
### List Available Models
```bash
curl http://localhost:20128/v1/models \
-H "Authorization: Bearer your-api-key"
```
**Expected response:**
```json
{
"object": "list",
"data": [
{
"id": "cc/claude-opus-4-5-20251101",
"object": "model",
"created": 1234567890,
"owned_by": "claude-code"
}
]
}
```
### Test Chat Completion
```bash
curl http://localhost:20128/v1/chat/completions \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"model": "cc/claude-opus-4-5-20251101",
"messages": [
{"role": "user", "content": "Hello!"}
]
}'
```
---
## Configuration
### Environment Variables
Create `.env` file or set environment variables:
```bash
# Security (REQUIRED in production)
export JWT_SECRET="your-secure-secret-change-this"
export INITIAL_PASSWORD="your-password"
# Storage
export DATA_DIR="~/.9router"
# Server
export PORT="20128"
export NODE_ENV="production"
# Logging
export ENABLE_REQUEST_LOGS="false"
```
### Data Directory
**Default location:** `~/.9router`
**Contents:**
```
~/.9router/
├── db.json # Database (providers, combos, usage)
├── api-keys.json # API keys
└── logs/ # Request logs (if enabled)
```
**Change location:**
```bash
export DATA_DIR="/custom/path"
9router
```
### Port Configuration
**Default port:** `20128`
**Change port:**
```bash
export PORT="3000"
9router
```
**Or use command line:**
```bash
9router --port 3000
```
---
## Troubleshooting
### Port Already in Use
**Error:**
```
Error: listen EADDRINUSE: address already in use :::20128
```
**Solution 1: Kill existing process**
```bash
# Find process using port 20128
lsof -i :20128
# Kill process
kill -9 <PID>
```
**Solution 2: Use different port**
```bash
9router --port 3000
```
### Permission Denied
**Error:**
```
Error: EACCES: permission denied, mkdir '/usr/local/lib/node_modules/9router'
```
**Solution: Use sudo (not recommended) or fix npm permissions**
```bash
# Fix npm permissions (recommended)
mkdir ~/.npm-global
npm config set prefix '~/.npm-global'
echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.bashrc
source ~/.bashrc
# Then install again
npm install -g 9router
```
### Node.js Version Too Old
**Error:**
```
Error: The engine "node" is incompatible with this module
```
**Solution: Update Node.js**
```bash
# Using nvm (recommended)
nvm install 20
nvm use 20
# Or download from nodejs.org
```
### Dashboard Not Opening
**Issue:** Dashboard doesn't open automatically
**Solution 1: Open manually**
```
http://localhost:20128
```
**Solution 2: Check firewall**
```bash
# macOS: Allow Node.js in System Preferences → Security
# Linux: Check iptables
# Windows: Check Windows Firewall
```
### Cannot Connect to Providers
**Issue:** OAuth login fails or API key invalid
**Solution 1: Check internet connection**
```bash
ping google.com
```
**Solution 2: Check provider status**
- Claude Code: [status.anthropic.com](https://status.anthropic.com)
- OpenAI: [status.openai.com](https://status.openai.com)
- Gemini: [status.cloud.google.com](https://status.cloud.google.com)
**Solution 3: Regenerate API key**
```
Dashboard → Provider → Disconnect → Reconnect
```
### High Memory Usage
**Issue:** 9Router using too much RAM
**Solution: Restart server**
```bash
# Stop
pkill -f 9router
# Start
9router
```
**Or use PM2 for auto-restart:**
```bash
npm install -g pm2
pm2 start 9router --name 9router
pm2 save
```
---
## Deployment Options
### Local Development
```bash
npm install -g 9router
9router
```
**Use case:** Personal coding, testing
### VPS/Cloud Server
```bash
# Install
npm install -g 9router
# Configure
export JWT_SECRET="your-secure-secret"
export INITIAL_PASSWORD="your-password"
export NODE_ENV="production"
# Start with PM2
npm install -g pm2
pm2 start 9router --name 9router
pm2 save
pm2 startup
```
**Use case:** Team access, remote coding
### Docker
```bash
docker pull 9router/9router:latest
docker run -d \
-p 20128:20128 \
-e JWT_SECRET="your-secure-secret" \
-e INITIAL_PASSWORD="your-password" \
-v 9router-data:/root/.9router \
--name 9router \
9router/9router:latest
```
**Use case:** Containerized deployment, Kubernetes
### Reverse Proxy (Nginx)
```nginx
server {
listen 80;
server_name your-domain.com;
location / {
proxy_pass http://localhost:20128;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
# SSE support for streaming
proxy_buffering off;
proxy_read_timeout 86400;
}
}
```
**Use case:** HTTPS, custom domain, load balancing
---
## Uninstallation
### Remove Global Installation
```bash
npm uninstall -g 9router
```
### Remove Data Directory
```bash
rm -rf ~/.9router
```
### Remove Configuration
```bash
# Remove environment variables from shell config
nano ~/.bashrc # or ~/.zshrc
# Delete 9router-related exports
```
---
## Next Steps
- [Getting Started Guide](../getting-started.md) - Connect providers and start coding
- [Features](../features/) - Explore quota tracking, combos, deployment
- [Troubleshooting](../troubleshooting.md) - Fix common issues
---
## Need Help?
- **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)
@@ -0,0 +1,247 @@
# Getting Started
Get 9Router running in 5 minutes and start routing AI requests intelligently.
---
## Quick Start
### 1. Install
```bash
npm install -g 9router
```
**Requirements:** Node.js 20+ ([Installation details](getting-started/installation.md))
### 2. Start
```bash
9router
```
🎉 **Dashboard opens automatically** at `http://localhost:20128`
- Default password: `123456` (change in dashboard)
- API key generated automatically
- Ready to connect providers
### 3. Connect Providers
You have 3 ways to connect providers:
#### Option A: OAuth (Subscription Providers)
**Best for:** Claude Code, Codex, Gemini CLI, GitHub Copilot
```
Dashboard → Providers → Connect [Provider]
→ OAuth login → Auto token refresh
→ Quota tracking enabled
```
**Example: Claude Code**
1. Click "Connect Claude Code"
2. Login with your Claude account
3. Authorize 9Router
4. ✅ Done! Use model: `cc/claude-opus-4-5-20251101`
#### Option B: API Key (Cheap Providers)
**Best for:** GLM, MiniMax, Kimi, OpenRouter
```
Dashboard → Providers → Add API Key
→ Select provider
→ Paste API key
→ Save
```
**Example: GLM-4.7**
1. Sign up at [Zhipu AI](https://open.bigmodel.cn/)
2. Get API key from Coding Plan
3. Dashboard → Add API Key → Provider: `glm` → Paste key
4. ✅ Done! Use model: `glm/glm-4.7`
#### Option C: Free Providers (No Cost)
**Best for:** iFlow, Qwen, Kiro
```
Dashboard → Providers → Connect [Free Provider]
→ Device code or OAuth
→ Unlimited usage
```
**Example: iFlow**
1. Click "Connect iFlow"
2. Login with iFlow account
3. Authorize
4. ✅ Done! Use 8 models: `if/kimi-k2-thinking`, `if/qwen3-coder-plus`, etc.
---
## 4. Use in CLI Tools
Point your coding tool to 9Router:
### Cursor IDE
```
Settings → Models → Advanced:
OpenAI API Base URL: http://localhost:20128/v1
OpenAI API Key: [from 9router dashboard]
Model: cc/claude-opus-4-5-20251101
```
### Claude Desktop
Edit `~/.claude/config.json`:
```json
{
"anthropic_api_base": "http://localhost:20128/v1",
"anthropic_api_key": "your-9router-api-key"
}
```
### Cline / Continue / RooCode
```
Provider: OpenAI Compatible
Base URL: http://localhost:20128/v1
API Key: [from dashboard]
Model: cc/claude-opus-4-5-20251101
```
### Codex CLI
```bash
export OPENAI_BASE_URL="http://localhost:20128"
export OPENAI_API_KEY="your-9router-api-key"
codex "your prompt"
```
---
## 5. Create Smart Combos (Optional)
Combos enable automatic fallback between models:
```
Dashboard → Combos → Create New
Name: premium-coding
Models:
1. cc/claude-opus-4-5-20251101 (Subscription primary)
2. glm/glm-4.7 (Cheap backup, $0.6/1M)
3. if/kimi-k2-thinking (Free fallback)
Use in CLI: premium-coding
```
**How it works:**
1. Tries Claude Opus first (your subscription)
2. If quota exhausted → GLM-4.7 (ultra-cheap)
3. If budget limit → iFlow (free)
4. Zero downtime, automatic switching!
---
## Available Models
### Subscription Models (Maximize First)
**Claude Code (`cc/`)** - Pro/Max subscription:
- `cc/claude-opus-4-5-20251101` - Claude 4.5 Opus
- `cc/claude-sonnet-4-5-20250929` - Claude 4.5 Sonnet
- `cc/claude-haiku-4-5-20251001` - Claude 4.5 Haiku
**Codex (`cx/`)** - Plus/Pro subscription:
- `cx/gpt-5.2-codex` - GPT 5.2 Codex
- `cx/gpt-5.1-codex-max` - GPT 5.1 Codex Max
**Gemini CLI (`gc/`)** - FREE 180K/month:
- `gc/gemini-3-flash-preview` - Gemini 3 Flash Preview
- `gc/gemini-2.5-pro` - Gemini 2.5 Pro
**GitHub Copilot (`gh/`)** - Subscription:
- `gh/gpt-5` - GPT-5
- `gh/claude-4.5-sonnet` - Claude 4.5 Sonnet
### Cheap Models (Backup)
**GLM (`glm/`)** - $0.6/$2.2 per 1M:
- `glm/glm-4.7` - GLM 4.7 (daily reset 10AM)
**MiniMax (`minimax/`)** - $0.20/$1.00 per 1M:
- `minimax/MiniMax-M2.1` - MiniMax M2.1 (5h reset)
**Kimi (`kimi/`)** - $9/month (10M tokens):
- `kimi/kimi-latest` - Kimi Latest
### FREE Models (Emergency)
**iFlow (`if/`)** - 8 models FREE:
- `if/kimi-k2-thinking` - Kimi K2 Thinking
- `if/qwen3-coder-plus` - Qwen3 Coder Plus
- `if/glm-4.7` - GLM 4.7
- `if/deepseek-r1` - DeepSeek R1
**Qwen (`qw/`)** - 3 models FREE:
- `qw/qwen3-coder-plus` - Qwen3 Coder Plus
- `qw/qwen3-coder-flash` - Qwen3 Coder Flash
**Kiro (`kr/`)** - 2 models FREE:
- `kr/claude-sonnet-4.5` - Claude Sonnet 4.5
- `kr/claude-haiku-4.5` - Claude Haiku 4.5
---
## Cost Optimization Strategy
### Monthly Budget: $10-20/month
```
1. Use Gemini CLI free tier (180K/month) for quick tasks
2. Use Claude Code subscription quota fully (you already pay)
3. Fallback to GLM ($0.6/1M) when quota out
4. Emergency: MiniMax M2.1 ($0.20/1M) or iFlow (free)
Real example (100M tokens/month):
60M via Gemini CLI: $0 (free tier)
30M via Claude Code: $0 (subscription you already have)
8M via GLM: $4.80
2M via MiniMax: $0.40
Total: $5.20/month + existing subscriptions
```
### Quota Reset Strategy
```
Daily routine:
1. Morning: Fresh Claude Code quota (5h reset)
2. Afternoon: Switch to Gemini CLI (1K/day)
3. Evening: GLM daily quota (reset 10AM next day)
4. Late night: MiniMax (5h rolling) or iFlow (free)
→ Code 24/7 with minimal extra cost!
```
---
## Next Steps
- [Installation Details](getting-started/installation.md) - Requirements, troubleshooting
- [Features](features/) - Explore quota tracking, combos, deployment
- [FAQ](faq.md) - Common questions and answers
- [Troubleshooting](troubleshooting.md) - Fix common issues
---
## Need Help?
- **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)
+164
View File
@@ -0,0 +1,164 @@
# Welcome to 9Router
**Use Claude, Codex, Gemini for FREE • Ultra-cheap alternatives from $0.20/1M tokens**
9Router is an AI model router that maximizes your subscription value and minimizes costs through intelligent routing and automatic fallback.
---
## What is 9Router?
9Router is a smart proxy that sits between your coding tools (Cursor, Cline, Claude Desktop) and AI providers. It automatically routes requests to the best available model based on quota, cost, and availability.
**Stop wasting money:**
- ❌ Subscription quota expires unused every month
- ❌ Rate limits stop you mid-coding
- ❌ Expensive APIs ($20-50/month per provider)
- ❌ Manual switching between providers
**Start maximizing value:**
-**Maximize Subscriptions** - Track and use every bit of Claude Code, Codex, Gemini quota
-**FREE Available** - Access iFlow, Qwen, Kiro models via CLI
-**Ultra-Cheap Backup** - GLM ($0.6/1M), MiniMax M2.1 ($0.20/1M)
-**Smart Fallback** - Subscription → Cheap → Free, automatic switching
---
## Key Features
### 🔄 Smart 3-Tier Fallback
```
Setup once, never stop coding:
Tier 1 (SUBSCRIPTION): Claude Code → Codex → Gemini
↓ quota exhausted
Tier 2 (CHEAP): GLM-4.7 → MiniMax M2.1 → Kimi
↓ budget limit
Tier 3 (FREE): iFlow → Qwen → Kiro
→ Automatic switching, zero downtime!
```
### 📊 Quota Tracking
- Real-time token consumption per provider
- Reset countdown (5-hour, daily, weekly, monthly)
- Cost estimation for paid tiers
- Monthly spending reports
### 🎯 Universal CLI Support
Works with any tool that supports custom OpenAI endpoints:
**Cursor****Cline****Claude Desktop****Codex****RooCode****Continue****Any OpenAI-compatible tool**
### 💰 Cost Optimization
**Real example (100M tokens/month):**
```
60M via Gemini CLI: $0 (free tier)
30M via Claude Code: $0 (subscription you already have)
8M via GLM: $4.80
2M via MiniMax: $0.40
Total: $5.20/month vs $2000 on ChatGPT API!
```
---
## Why Choose 9Router?
### Maximize Subscriptions
Already paying for Claude Code ($20-100/month) or Codex ($20-200/month)? Get full value:
- Track quota usage in real-time
- Auto-switch when quota resets (5-hour, weekly)
- Use every token before it expires
- Gemini CLI: 180K completions/month **FREE**
### Ultra-Cheap Backup
When subscription quota runs out, pay pennies:
| Provider | Cost per 1M tokens | Reset |
|----------|-------------------|-------|
| **GLM-4.7** | $0.60 input / $2.20 output | Daily 10:00 AM |
| **MiniMax M2.1** | $0.20 input / $1.00 output | 5-hour rolling |
| **Kimi K2** | $9/month (10M tokens) | Monthly |
**~90% cheaper than ChatGPT API ($20/1M)!**
### Free Forever Fallback
Emergency backup when everything else is quota-limited:
- **iFlow**: 8 models (Kimi K2, Qwen3 Coder Plus, GLM 4.7, MiniMax M2)
- **Qwen**: 3 models (Qwen3 Coder Plus/Flash, Vision)
- **Kiro**: Claude Sonnet 4.5, Haiku 4.5 (AWS Builder ID)
---
## Quick Start
Get started in 2 minutes:
```bash
# Install globally
npm install -g 9router
# Start (dashboard opens automatically)
9router
```
🎉 **Dashboard opens** → Connect providers → Start coding!
**Use in your CLI tool:**
```
Endpoint: http://localhost:20128/v1
API Key: [from dashboard]
Model: cc/claude-opus-4-5-20251101
```
[→ Full Getting Started Guide](getting-started.md)
---
## Use Cases
### For Individual Developers
- Maximize your Claude Code/Codex subscription
- Use Gemini CLI free tier (180K/month)
- Fallback to ultra-cheap models ($0.20/1M)
- Code 24/7 without rate limits
### For Teams
- Deploy on VPS/Cloud for shared access
- Track team spending in real-time
- Set budget limits per tier
- Centralized provider management
### For Mobile/Remote Coding
- Use cloud deployment (https://9router.com)
- Access from iPad, phone, anywhere
- No localhost limitations
- Cloudflare edge network (300+ locations)
---
## What's Next?
- [Getting Started](getting-started.md) - Install and configure in 5 minutes
- [Installation Guide](getting-started/installation.md) - Detailed setup instructions
- [Features](features/) - Explore all capabilities
- [FAQ](faq.md) - Common questions
---
<div align="center">
<sub>Built with ❤️ for developers maximizing AI value</sub>
</div>
@@ -0,0 +1,109 @@
# Claude Code Integration
Integrate 9Router with Claude Code CLI to route your Anthropic API requests through 9Router's intelligent routing system.
## Prerequisites
- Claude Code CLI installed
- 9Router running locally or cloud endpoint configured
- API key from 9Router dashboard
## Setup
### 1. Configure Environment Variables
Set the following environment variables in your shell configuration file (`~/.bashrc`, `~/.zshrc`, or `~/.bash_profile`):
```bash
# Base URL for 9Router
export ANTHROPIC_BASE_URL="http://localhost:20128/v1"
# Optional: Set default models for aliases
export ANTHROPIC_DEFAULT_OPUS_MODEL="cc/claude-opus-4-5-20251101"
export ANTHROPIC_DEFAULT_SONNET_MODEL="cc/claude-sonnet-4-5-20250929"
export ANTHROPIC_DEFAULT_HAIKU_MODEL="cc/claude-haiku-4-5-20251001"
```
### 2. Reload Shell Configuration
```bash
source ~/.zshrc # or ~/.bashrc
```
### 3. Verify Configuration
Check that the environment variables are set correctly:
```bash
echo $ANTHROPIC_BASE_URL
```
## Model Aliases
Claude Code supports the following model aliases that map to 9Router models:
| Alias | Model | Environment Variable |
|-------|-------|---------------------|
| `opus` | Claude Opus 4.5 | `ANTHROPIC_DEFAULT_OPUS_MODEL` |
| `sonnet` | Claude Sonnet 4.5 | `ANTHROPIC_DEFAULT_SONNET_MODEL` |
| `haiku` | Claude Haiku 4.5 | `ANTHROPIC_DEFAULT_HAIKU_MODEL` |
## Usage Examples
### Using Model Aliases
```bash
# Use Opus model
claude --model opus "Explain quantum computing"
# Use Sonnet model
claude --model sonnet "Write a Python function"
# Use Haiku model
claude --model haiku "Quick code review"
```
### Using Full Model Names
```bash
claude --model cc/claude-opus-4-5-20251101 "Your prompt here"
```
## Settings File
Claude Code stores its configuration in `~/.claude/settings.json`. You can manually edit this file if needed:
```json
{
"baseUrl": "http://localhost:20128/v1",
"defaultModel": "sonnet"
}
```
## Troubleshooting
### Connection Issues
If you encounter connection errors:
1. Verify 9Router is running: `curl http://localhost:20128/health`
2. Check environment variables are set correctly
3. Ensure no firewall is blocking port 20128
### Model Not Found
If you get "model not found" errors:
1. Verify the model name matches your 9Router configuration
2. Check that the provider connection is active in 9Router dashboard
3. Ensure the model is available in your connected providers
## Cloud Endpoint
To use 9Router cloud endpoint instead of localhost:
```bash
export ANTHROPIC_BASE_URL="https://9router.com"
```
Make sure you have configured your API key in the 9Router cloud dashboard.
+201
View File
@@ -0,0 +1,201 @@
# Cline Integration
Integrate 9Router with Cline VSCode extension to route your AI requests through 9Router's intelligent routing system.
## Prerequisites
- Visual Studio Code installed
- Cline extension installed from VSCode marketplace
- 9Router running locally or cloud endpoint configured
- API key from 9Router dashboard
## Setup
### 1. Open Cline Settings
1. Open Visual Studio Code
2. Open the Cline extension panel (click the Cline icon in the sidebar)
3. Click the **Settings** icon (gear icon) in the Cline panel
### 2. Select API Provider
1. In the Cline settings, find **API Provider** dropdown
2. Select **Ollama** from the list
- Note: We use Ollama provider type because it's compatible with OpenAI-style APIs
### 3. Configure Base URL
Set the base URL to your 9Router endpoint:
**For Local 9Router:**
```
http://localhost:20128/v1
```
**For Cloud 9Router:**
```
https://9router.com
```
**Steps:**
1. In the **Base URL** field, enter your 9Router endpoint
2. Make sure to include `/v1` at the end
### 4. Add API Key
1. In the **API Key** field, enter your 9Router API key
2. You can find your API key in the 9Router dashboard under **Settings → API Keys**
3. The key should start with `sk-9router-`
### 5. Select Model
1. In the **Model** dropdown, you can either:
- Select from available models (if Cline auto-detects them)
- Manually enter the model name from your 9Router configuration
2. Common model names:
- `gpt-4`
- `gpt-4o`
- `claude-opus-4-5`
- `claude-sonnet-4-5`
- `gemini-2.0-flash`
### 6. Save Configuration
Click **Save** or close the settings panel. Cline will automatically save your configuration.
## Configuration Example
Your Cline settings should look like this:
```
API Provider: Ollama
Base URL: http://localhost:20128/v1
API Key: sk-9router-xxxxxxxxxxxxx
Model: gpt-4
```
## Available Models
You can use any model configured in your 9Router dashboard. Common examples:
| Model Name | Provider | Description |
|------------|----------|-------------|
| `gpt-4` | OpenAI | GPT-4 Turbo |
| `gpt-4o` | OpenAI | GPT-4 Optimized |
| `claude-opus-4-5` | Anthropic | Claude Opus 4.5 |
| `claude-sonnet-4-5` | Anthropic | Claude Sonnet 4.5 |
| `gemini-2.0-flash` | Google | Gemini 2.0 Flash |
## Usage
### Chat with AI
1. Open the Cline panel in VSCode
2. Type your message in the chat input
3. Press Enter to send
4. Cline will use 9Router to process your request
### Code Generation
1. Ask Cline to generate code: "Create a React component for a login form"
2. Cline will generate code using 9Router
3. Review and accept the generated code
### Code Explanation
1. Select code in your editor
2. Ask Cline: "Explain this code"
3. Get AI-powered explanations through 9Router
### File Operations
1. Ask Cline to create, modify, or delete files
2. Cline will use 9Router to understand context and make changes
3. Review changes before accepting
## Troubleshooting
### "Connection Failed" Error
1. Verify 9Router is running: `curl http://localhost:20128/health`
2. Check that the base URL is correct and includes `/v1`
3. Ensure no firewall is blocking port 20128
4. Try restarting VSCode
### "Invalid API Key" Error
1. Verify your API key in 9Router dashboard
2. Make sure you copied the entire key including the `sk-9router-` prefix
3. Check that the API key has not expired
4. Try regenerating a new API key
### "Model Not Found" Error
1. Verify the model name matches exactly with your 9Router configuration
2. Check that the provider connection is active in 9Router dashboard
3. Ensure the model is available in your connected providers
4. Try using the full model name (e.g., `openai/gpt-4` instead of `gpt-4`)
### Cline Not Responding
1. Check the Cline output panel for error messages
2. Verify your 9Router instance is running and healthy
3. Try reloading VSCode window (Cmd/Ctrl + Shift + P → "Reload Window")
4. Check 9Router logs for any errors
## Advanced Configuration
### Using Cloud Endpoint
To use 9Router cloud endpoint instead of localhost:
1. In Cline settings, set Base URL to: `https://9router.com`
2. Make sure you have configured your API key in the 9Router cloud dashboard
3. Ensure your cloud endpoint is active and accessible
### Multiple Models
You can quickly switch between models:
1. Open Cline settings
2. Change the **Model** field to a different model
3. Save and continue chatting with the new model
### Custom Timeout
If you experience timeout issues with large requests:
1. Open VSCode settings (Cmd/Ctrl + ,)
2. Search for "Cline timeout"
3. Increase the timeout value (default is usually 30 seconds)
## Best Practices
1. **Use Appropriate Models**: Choose faster models (like Haiku or Flash) for simple tasks, and more powerful models (like Opus or GPT-4) for complex tasks
2. **Monitor Usage**: Check 9Router dashboard for usage statistics and costs
3. **Context Management**: Keep your conversations focused to reduce token usage
4. **Model Switching**: Switch models based on task complexity to optimize cost and performance
5. **API Key Security**: Never commit your API key to version control
## Integration with 9Router Features
### Model Routing
9Router automatically routes your requests to the best available provider based on:
- Model availability
- Provider health status
- Cost optimization
- Load balancing
### Fallback Support
If a provider fails, 9Router automatically falls back to alternative providers configured in your dashboard.
### Usage Tracking
Monitor your Cline usage through 9Router dashboard:
- Total requests
- Token usage
- Cost per model
- Provider distribution
+136
View File
@@ -0,0 +1,136 @@
# OpenAI Codex CLI Integration
Integrate 9Router with OpenAI Codex CLI to route your OpenAI API requests through 9Router's intelligent routing system.
## Prerequisites
- OpenAI Codex CLI installed
- 9Router running locally or cloud endpoint configured
- API key from 9Router dashboard
## Setup
### 1. Configure Environment Variables
Set the following environment variables in your shell configuration file (`~/.bashrc`, `~/.zshrc`, or `~/.bash_profile`):
```bash
# Base URL for 9Router
export OPENAI_BASE_URL="http://localhost:20128/v1"
# API Key from 9Router dashboard
export OPENAI_API_KEY="your-9router-api-key"
```
### 2. Reload Shell Configuration
```bash
source ~/.zshrc # or ~/.bashrc
```
### 3. Verify Configuration
Check that the environment variables are set correctly:
```bash
echo $OPENAI_BASE_URL
echo $OPENAI_API_KEY
```
## Available Models
9Router provides the following Codex models:
| Model ID | Description |
|----------|-------------|
| `cx/gpt-5.2-codex` | GPT-5.2 Codex - Latest version |
| `cx/gpt-5.1-codex-max` | GPT-5.1 Codex Max - Extended context |
## Usage Examples
### Basic Usage
```bash
# Use GPT-5.2 Codex
codex --model cx/gpt-5.2-codex "Write a function to sort an array"
# Use GPT-5.1 Codex Max
codex --model cx/gpt-5.1-codex-max "Explain this complex algorithm"
```
### Code Generation
```bash
codex --model cx/gpt-5.2-codex "Create a REST API endpoint for user authentication"
```
### Code Explanation
```bash
codex --model cx/gpt-5.1-codex-max "Explain what this code does: $(cat myfile.js)"
```
## Configuration File
You can also configure Codex CLI using a configuration file. Create or edit `~/.codex/config.json`:
```json
{
"baseUrl": "http://localhost:20128/v1",
"apiKey": "your-9router-api-key",
"defaultModel": "cx/gpt-5.2-codex"
}
```
## Troubleshooting
### Authentication Errors
If you encounter authentication errors:
1. Verify your API key is correct in 9Router dashboard
2. Check that `OPENAI_API_KEY` environment variable is set
3. Ensure the API key has not expired
### Connection Issues
If you encounter connection errors:
1. Verify 9Router is running: `curl http://localhost:20128/health`
2. Check environment variables are set correctly
3. Ensure no firewall is blocking port 20128
### Model Not Available
If you get "model not available" errors:
1. Verify the model name matches your 9Router configuration
2. Check that the OpenAI provider connection is active in 9Router dashboard
3. Ensure the model is available in your connected providers
## Cloud Endpoint
To use 9Router cloud endpoint instead of localhost:
```bash
export OPENAI_BASE_URL="https://9router.com"
```
Make sure you have configured your API key in the 9Router cloud dashboard.
## Advanced Configuration
### Custom Timeout
```bash
export OPENAI_TIMEOUT=60 # seconds
```
### Debug Mode
Enable debug mode to see detailed request/response logs:
```bash
export CODEX_DEBUG=true
codex --model cx/gpt-5.2-codex "Your prompt"
```
+249
View File
@@ -0,0 +1,249 @@
# Continue VSCode Extension Integration
Integrate 9Router with Continue extension to bring AI assistance directly into Visual Studio Code.
## Prerequisites
- Visual Studio Code installed
- Continue extension installed from VSCode marketplace
- 9Router API key from [dashboard](https://9router.com/dashboard)
- 9Router running (local or cloud)
## Configuration Steps
### 1. Open Continue Configuration
1. Open VSCode
2. Press `Cmd+Shift+P` (Mac) or `Ctrl+Shift+P` (Windows/Linux)
3. Type "Continue: Open Config" and select it
4. This opens `~/.continue/config.json`
### 2. Add 9Router Model Configuration
Add the following configuration to your `config.json`:
**Single Model Setup:**
```json
{
"models": [
{
"title": "9Router - Claude Opus",
"provider": "openai",
"model": "cc/claude-opus-4-5-20251101",
"apiKey": "your-api-key-from-dashboard",
"apiBase": "http://localhost:20128/v1"
}
]
}
```
**Multiple Models Setup:**
```json
{
"models": [
{
"title": "9Router - Claude Opus (Best)",
"provider": "openai",
"model": "cc/claude-opus-4-5-20251101",
"apiKey": "your-api-key-from-dashboard",
"apiBase": "http://localhost:20128/v1"
},
{
"title": "9Router - Claude Sonnet (Balanced)",
"provider": "openai",
"model": "cc/claude-sonnet-4-20250514",
"apiKey": "your-api-key-from-dashboard",
"apiBase": "http://localhost:20128/v1"
},
{
"title": "9Router - DeepSeek Chat (Code)",
"provider": "openai",
"model": "cx/deepseek-chat",
"apiKey": "your-api-key-from-dashboard",
"apiBase": "http://localhost:20128/v1"
},
{
"title": "9Router - Claude Haiku (Fast)",
"provider": "openai",
"model": "cc/claude-haiku-4-20250514",
"apiKey": "your-api-key-from-dashboard",
"apiBase": "http://localhost:20128/v1"
}
]
}
```
**For Cloud 9Router:**
Replace `apiBase` with:
```json
"apiBase": "https://9router.com/v1"
```
### 3. Save and Reload
1. Save the configuration file
2. Reload VSCode window: `Cmd+Shift+P` → "Developer: Reload Window"
3. Continue extension will load the new configuration
### 4. Select Model
1. Open Continue sidebar (click Continue icon in left panel)
2. Click model selector dropdown at the top
3. Choose your preferred 9Router model
## Available Models
### Claude Models (Anthropic)
- `cc/claude-opus-4-5-20251101` - Most capable, best for complex tasks
- `cc/claude-sonnet-4-20250514` - Balanced performance and speed
- `cc/claude-haiku-4-20250514` - Fastest, good for simple tasks
### DeepSeek Models
- `cx/deepseek-chat` - Excellent for code generation
- `cx/deepseek-reasoner` - Best for complex problem solving
### GLM Models (Zhipu AI)
- `glm/glm-4-plus` - Advanced Chinese and English
- `glm/glm-4-flash` - Fast responses
## Usage Examples
### Code Explanation
1. Select code in editor
2. Open Continue sidebar
3. Type: "Explain this code"
4. Model: `cc/claude-sonnet-4-20250514`
### Code Generation
1. Open Continue sidebar
2. Type: "Create a React component for user profile card"
3. Model: `cx/deepseek-chat`
### Refactoring
1. Select code to refactor
2. Type: "Refactor this to use async/await"
3. Model: `cc/claude-sonnet-4-20250514`
### Bug Fixing
1. Select problematic code
2. Type: "Find and fix the bug in this code"
3. Model: `cx/deepseek-reasoner`
## Advanced Configuration
### Custom System Prompts
Add custom system prompts for specific behaviors:
```json
{
"models": [
{
"title": "9Router - Code Expert",
"provider": "openai",
"model": "cx/deepseek-chat",
"apiKey": "your-api-key",
"apiBase": "http://localhost:20128/v1",
"systemMessage": "You are an expert programmer. Always provide clean, well-documented code with best practices."
}
]
}
```
### Temperature and Parameters
Adjust model behavior with parameters:
```json
{
"models": [
{
"title": "9Router - Creative Writer",
"provider": "openai",
"model": "cc/claude-opus-4-5-20251101",
"apiKey": "your-api-key",
"apiBase": "http://localhost:20128/v1",
"temperature": 0.9,
"topP": 0.95
}
]
}
```
### Context Providers
Configure what context Continue sends to the model:
```json
{
"contextProviders": [
{
"name": "code",
"params": {
"maxLines": 100
}
},
{
"name": "diff",
"params": {}
},
{
"name": "terminal",
"params": {}
}
]
}
```
## Keyboard Shortcuts
- `Cmd+L` (Mac) / `Ctrl+L` (Windows/Linux) - Open Continue chat
- `Cmd+I` (Mac) / `Ctrl+I` (Windows/Linux) - Inline edit
- `Cmd+Shift+R` (Mac) / `Ctrl+Shift+R` (Windows/Linux) - Regenerate response
## Troubleshooting
### Model Not Responding
- Check 9Router is running: `curl http://localhost:20128/health`
- Verify API key in config.json
- Check VSCode Developer Console for errors: `Help``Toggle Developer Tools`
### Wrong Model Selected
- Click model dropdown in Continue sidebar
- Select correct 9Router model
- Model name must match exactly (case-sensitive)
### Configuration Not Loading
- Verify JSON syntax is valid (use JSON validator)
- Check file location: `~/.continue/config.json`
- Reload VSCode window after changes
### Slow Performance
- Switch to faster models (haiku, flash)
- Reduce context size in contextProviders
- Check network latency to 9Router
## Best Practices
### Model Selection Strategy
- **Quick edits**: Use `cc/claude-haiku-4-20250514`
- **Code generation**: Use `cx/deepseek-chat`
- **Complex refactoring**: Use `cc/claude-opus-4-5-20251101`
- **Problem solving**: Use `cx/deepseek-reasoner`
### Context Management
- Select only relevant code before asking
- Use specific, clear prompts
- Break complex tasks into smaller steps
### Cost Optimization
- Use faster/cheaper models for simple tasks
- Limit context size when possible
- Cache frequently used responses
## Next Steps
- [Configure Cursor](cursor.md) for enhanced IDE integration
- [Set up Roo](roo.md) for AI assistant
- [Explore CLI usage](../cli/basic-usage.md)
- [Learn about model selection](../models/overview.md)
+149
View File
@@ -0,0 +1,149 @@
# Cursor Integration
Integrate 9Router with Cursor IDE to route your AI requests through 9Router's intelligent routing system.
## Prerequisites
- Cursor IDE installed
- Cursor Pro account (required for custom API endpoints)
- 9Router cloud endpoint configured
- API key from 9Router dashboard
## ⚠️ Important Notes
> **Cloud Endpoint Required**: Cursor routes requests through its own server and does not support localhost endpoints. You must use the 9Router cloud endpoint: `https://9router.com`
> **Cursor Pro Required**: This feature requires a Cursor Pro account to use custom API endpoints.
## Setup
### 1. Open Cursor Settings
1. Open Cursor IDE
2. Go to **Settings** (Cmd/Ctrl + ,)
3. Navigate to **Models** section
### 2. Enable OpenAI API
1. Find the **OpenAI API key** option
2. Enable the toggle to activate custom API configuration
### 3. Configure Base URL
Set the base URL to 9Router cloud endpoint:
```
https://9router.com
```
**Steps:**
1. In the Models settings, locate the **Base URL** field
2. Enter: `https://9router.com`
3. Click **Save**
### 4. Add API Key
1. In the **API Key** field, enter your 9Router API key
2. You can find your API key in the 9Router dashboard under **Settings → API Keys**
3. Click **Save**
### 5. Add Custom Model
1. Click **View All Models** button
2. Click **Add Custom Model**
3. Enter the model name from your 9Router configuration (e.g., `gpt-4`, `claude-opus-4-5`, etc.)
4. Click **Add**
### 6. Select Model
1. In the Cursor chat interface, click the model selector dropdown
2. Choose your custom model from the list
3. Start using 9Router with Cursor!
## Configuration Example
Your Cursor settings should look like this:
```
OpenAI API: ✓ Enabled
Base URL: https://9router.com
API Key: sk-9router-xxxxxxxxxxxxx
Custom Models: gpt-4, claude-opus-4-5, gemini-2.0-flash
```
## Available Models
You can use any model configured in your 9Router dashboard. Common examples:
| Model Name | Provider | Description |
|------------|----------|-------------|
| `gpt-4` | OpenAI | GPT-4 Turbo |
| `gpt-4o` | OpenAI | GPT-4 Optimized |
| `claude-opus-4-5` | Anthropic | Claude Opus 4.5 |
| `claude-sonnet-4-5` | Anthropic | Claude Sonnet 4.5 |
| `gemini-2.0-flash` | Google | Gemini 2.0 Flash |
## Usage
### Chat Interface
1. Open Cursor chat (Cmd/Ctrl + L)
2. Select your model from the dropdown
3. Start chatting with AI through 9Router
### Inline Code Generation
1. Select code in your editor
2. Press Cmd/Ctrl + K
3. Enter your prompt
4. Cursor will use 9Router to generate code
### Code Explanation
1. Select code in your editor
2. Press Cmd/Ctrl + L
3. Ask "Explain this code"
4. Get AI-powered explanations through 9Router
## Troubleshooting
### "Invalid API Key" Error
1. Verify your API key in 9Router dashboard
2. Make sure you copied the entire key including the `sk-9router-` prefix
3. Check that the API key has not expired
4. Try regenerating a new API key
### "Model Not Found" Error
1. Verify the model name matches exactly with your 9Router configuration
2. Check that the provider connection is active in 9Router dashboard
3. Ensure the model is available in your connected providers
4. Try using the full model name (e.g., `openai/gpt-4` instead of `gpt-4`)
### Connection Issues
1. Verify you are using the cloud endpoint: `https://9router.com`
2. Check your internet connection
3. Ensure 9Router cloud service is operational
4. Try disabling VPN or proxy if enabled
### Localhost Not Working
> **Remember**: Cursor does not support localhost endpoints. You must use the cloud endpoint `https://9router.com`. If you need to use a local 9Router instance, consider using a tunneling service like ngrok to expose your local endpoint.
## Cloud Endpoint Setup
If you're running 9Router locally and want to use it with Cursor:
1. Enable cloud endpoint in 9Router settings
2. Configure your cloud endpoint URL in 9Router dashboard
3. Use the cloud URL in Cursor settings
4. Ensure your local 9Router instance is accessible from the internet
## Best Practices
1. **Use Model Aliases**: Create short aliases for frequently used models in 9Router
2. **Monitor Usage**: Check 9Router dashboard for usage statistics and costs
3. **Rotate API Keys**: Regularly rotate your API keys for security
4. **Test Models**: Try different models to find the best one for your use case
@@ -0,0 +1,416 @@
# Other Tools Integration
9Router is compatible with any tool that supports the OpenAI API format. This guide covers generic integration patterns for various tools and custom applications.
## Overview
9Router provides an OpenAI-compatible API endpoint that works with:
- Custom scripts and applications
- API clients and testing tools
- CLI tools and utilities
- Third-party integrations
- Development frameworks
## Generic Setup Pattern
Any OpenAI-compatible tool can connect to 9Router using these settings:
**Local 9Router:**
```
Base URL: http://localhost:20128/v1
API Key: your-api-key-from-dashboard
Model: any 9Router model (cc/*, cx/*, glm/*, etc.)
```
**Cloud 9Router:**
```
Base URL: https://9router.com/v1
API Key: your-api-key-from-dashboard
Model: any 9Router model (cc/*, cx/*, glm/*, etc.)
```
## Available Models
### Claude Models (Anthropic)
- `cc/claude-opus-4-5-20251101`
- `cc/claude-sonnet-4-20250514`
- `cc/claude-haiku-4-20250514`
### DeepSeek Models
- `cx/deepseek-chat`
- `cx/deepseek-reasoner`
### GLM Models (Zhipu AI)
- `glm/glm-4-plus`
- `glm/glm-4-flash`
## Integration Examples
### Python with OpenAI SDK
```python
from openai import OpenAI
client = OpenAI(
api_key="your-api-key-from-dashboard",
base_url="http://localhost:20128/v1"
)
response = client.chat.completions.create(
model="cc/claude-sonnet-4-20250514",
messages=[
{"role": "user", "content": "Hello, how are you?"}
]
)
print(response.choices[0].message.content)
```
### Node.js with OpenAI SDK
```javascript
import OpenAI from "openai";
const client = new OpenAI({
apiKey: "your-api-key-from-dashboard",
baseURL: "http://localhost:20128/v1"
});
const response = await client.chat.completions.create({
model: "cc/claude-sonnet-4-20250514",
messages: [
{ role: "user", content: "Hello, how are you?" }
]
});
console.log(response.choices[0].message.content);
```
### cURL Command
```bash
curl http://localhost:20128/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-api-key-from-dashboard" \
-d '{
"model": "cc/claude-sonnet-4-20250514",
"messages": [
{"role": "user", "content": "Hello, how are you?"}
]
}'
```
### HTTP Client (Postman, Insomnia)
**Request:**
```
POST http://localhost:20128/v1/chat/completions
```
**Headers:**
```
Content-Type: application/json
Authorization: Bearer your-api-key-from-dashboard
```
**Body:**
```json
{
"model": "cc/claude-sonnet-4-20250514",
"messages": [
{"role": "user", "content": "Hello, how are you?"}
],
"temperature": 0.7,
"max_tokens": 1000
}
```
### LangChain Integration
```python
from langchain.chat_models import ChatOpenAI
from langchain.schema import HumanMessage
llm = ChatOpenAI(
model_name="cc/claude-sonnet-4-20250514",
openai_api_key="your-api-key-from-dashboard",
openai_api_base="http://localhost:20128/v1",
temperature=0.7
)
messages = [HumanMessage(content="Explain quantum computing")]
response = llm(messages)
print(response.content)
```
### LlamaIndex Integration
```python
from llama_index.llms import OpenAI
llm = OpenAI(
model="cc/claude-sonnet-4-20250514",
api_key="your-api-key-from-dashboard",
api_base="http://localhost:20128/v1"
)
response = llm.complete("What is machine learning?")
print(response.text)
```
## Custom Script Examples
### Batch Processing Script
```python
import openai
import json
openai.api_key = "your-api-key-from-dashboard"
openai.api_base = "http://localhost:20128/v1"
def process_batch(prompts, model="cx/deepseek-chat"):
results = []
for prompt in prompts:
response = openai.ChatCompletion.create(
model=model,
messages=[{"role": "user", "content": prompt}]
)
results.append({
"prompt": prompt,
"response": response.choices[0].message.content
})
return results
prompts = [
"Explain AI in one sentence",
"What is machine learning?",
"Define neural networks"
]
results = process_batch(prompts)
print(json.dumps(results, indent=2))
```
### Streaming Response Handler
```javascript
import OpenAI from "openai";
const client = new OpenAI({
apiKey: "your-api-key-from-dashboard",
baseURL: "http://localhost:20128/v1"
});
async function streamResponse(prompt) {
const stream = await client.chat.completions.create({
model: "cc/claude-sonnet-4-20250514",
messages: [{ role: "user", content: prompt }],
stream: true
});
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || "";
process.stdout.write(content);
}
}
streamResponse("Write a short story about AI");
```
### Multi-Model Comparison
```python
from openai import OpenAI
client = OpenAI(
api_key="your-api-key-from-dashboard",
base_url="http://localhost:20128/v1"
)
models = [
"cc/claude-sonnet-4-20250514",
"cx/deepseek-chat",
"glm/glm-4-plus"
]
prompt = "Explain quantum computing in simple terms"
for model in models:
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}]
)
print(f"\n=== {model} ===")
print(response.choices[0].message.content)
```
## Common Integration Patterns
### Environment Variables
Store credentials securely:
```bash
# .env file
ROUTER_API_KEY=your-api-key-from-dashboard
ROUTER_BASE_URL=http://localhost:20128/v1
ROUTER_MODEL=cc/claude-sonnet-4-20250514
```
```python
import os
from openai import OpenAI
client = OpenAI(
api_key=os.getenv("ROUTER_API_KEY"),
base_url=os.getenv("ROUTER_BASE_URL")
)
```
### Error Handling
```python
from openai import OpenAI, OpenAIError
client = OpenAI(
api_key="your-api-key",
base_url="http://localhost:20128/v1"
)
try:
response = client.chat.completions.create(
model="cc/claude-sonnet-4-20250514",
messages=[{"role": "user", "content": "Hello"}]
)
print(response.choices[0].message.content)
except OpenAIError as e:
print(f"Error: {e}")
```
### Retry Logic
```python
import time
from openai import OpenAI, RateLimitError
client = OpenAI(
api_key="your-api-key",
base_url="http://localhost:20128/v1"
)
def chat_with_retry(prompt, max_retries=3):
for attempt in range(max_retries):
try:
response = client.chat.completions.create(
model="cc/claude-sonnet-4-20250514",
messages=[{"role": "user", "content": prompt}]
)
return response.choices[0].message.content
except RateLimitError:
if attempt < max_retries - 1:
time.sleep(2 ** attempt) # Exponential backoff
else:
raise
```
## Troubleshooting
### Connection Issues
**Problem:** Cannot connect to 9Router
```bash
# Check if 9Router is running
curl http://localhost:20128/health
# Expected response:
{"status": "ok"}
```
**Solution:**
- Verify 9Router is running
- Check port 20128 is not blocked
- Ensure correct base URL (include `/v1`)
### Authentication Errors
**Problem:** 401 Unauthorized
```
Error: Invalid API key
```
**Solution:**
- Verify API key from dashboard
- Check Authorization header format: `Bearer your-api-key`
- Ensure no extra spaces or newlines in API key
### Model Not Found
**Problem:** 404 Model not found
```
Error: Model 'cc/claude-opus' not found
```
**Solution:**
- Use exact model name (case-sensitive)
- Check available models: `curl http://localhost:20128/v1/models`
- Verify model is enabled in your plan
### Timeout Issues
**Problem:** Request timeout
```
Error: Request timed out after 30s
```
**Solution:**
- Increase timeout in client configuration
- Use faster models for time-sensitive tasks
- Check network connection to 9Router
### Rate Limiting
**Problem:** 429 Too Many Requests
```
Error: Rate limit exceeded
```
**Solution:**
- Implement exponential backoff
- Reduce request frequency
- Check rate limits in dashboard
- Consider upgrading plan
## Best Practices
### Security
- Store API keys in environment variables
- Never commit API keys to version control
- Use HTTPS for cloud deployments
- Rotate API keys regularly
### Performance
- Use appropriate models for task complexity
- Implement caching for repeated queries
- Use streaming for long responses
- Batch requests when possible
### Error Handling
- Always implement try-catch blocks
- Add retry logic with exponential backoff
- Log errors for debugging
- Provide fallback mechanisms
### Cost Optimization
- Choose cost-effective models for simple tasks
- Cache responses when appropriate
- Monitor usage in dashboard
- Set request limits in code
## Next Steps
- [Configure Cursor](cursor.md) for IDE integration
- [Set up Continue](continue.md) for VSCode
- [Explore CLI usage](../cli/basic-usage.md)
- [Learn about model selection](../models/overview.md)
- [API Reference](../api/reference.md)
+127
View File
@@ -0,0 +1,127 @@
# Roo AI Assistant Integration
Integrate 9Router with Roo AI Assistant to access multiple AI models through a unified interface.
## Prerequisites
- Roo AI Assistant installed
- 9Router API key from [dashboard](https://9router.com/dashboard)
- 9Router running (local or cloud)
## Configuration Steps
### 1. Open Roo Settings
Launch Roo AI Assistant and open the settings panel.
### 2. Configure API Provider
1. Navigate to **API Provider** settings
2. Select **Ollama** as the provider type
3. Configure the following settings:
**For Local 9Router:**
```
Base URL: http://localhost:20128/v1
API Key: your-api-key-from-dashboard
```
**For Cloud 9Router:**
```
Base URL: https://9router.com/v1
API Key: your-api-key-from-dashboard
```
### 3. Select Model
Choose from available 9Router models:
**Claude Models:**
- `cc/claude-opus-4-5-20251101` - Most capable
- `cc/claude-sonnet-4-20250514` - Balanced
- `cc/claude-haiku-4-20250514` - Fast
**DeepSeek Models:**
- `cx/deepseek-chat` - General purpose
- `cx/deepseek-reasoner` - Complex reasoning
**GLM Models:**
- `glm/glm-4-plus` - Advanced
- `glm/glm-4-flash` - Fast responses
### 4. Test Connection
Send a test message to verify the integration:
```
Hello! Can you confirm you're connected through 9Router?
```
## Usage Examples
### Basic Chat
```
Ask Roo: "Explain quantum computing in simple terms"
Model: cc/claude-sonnet-4-20250514
```
### Code Generation
```
Ask Roo: "Write a Python function to calculate Fibonacci numbers"
Model: cx/deepseek-chat
```
### Complex Reasoning
```
Ask Roo: "Analyze the trade-offs between microservices and monolithic architecture"
Model: cx/deepseek-reasoner
```
## Model Selection Tips
- **Quick tasks**: Use `cc/claude-haiku-4-20250514` or `glm/glm-4-flash`
- **Balanced performance**: Use `cc/claude-sonnet-4-20250514` or `cx/deepseek-chat`
- **Complex reasoning**: Use `cc/claude-opus-4-5-20251101` or `cx/deepseek-reasoner`
- **Cost optimization**: Use DeepSeek or GLM models
## Troubleshooting
### Connection Failed
- Verify 9Router is running: `curl http://localhost:20128/health`
- Check API key is correct
- Ensure Base URL includes `/v1` suffix
### Model Not Available
- Check model name matches exactly (case-sensitive)
- Verify model is enabled in your 9Router plan
- Try a different model from the list
### Slow Responses
- Switch to faster models (haiku, flash)
- Check network connection
- Monitor 9Router logs for issues
## Advanced Configuration
### Custom Model Aliases
You can create shortcuts for frequently used models in Roo settings:
```
Alias: "fast" → cc/claude-haiku-4-20250514
Alias: "smart" → cc/claude-opus-4-5-20251101
Alias: "code" → cx/deepseek-chat
```
### Multiple Profiles
Set up different profiles for different use cases:
- **Development**: DeepSeek models for code
- **Writing**: Claude models for content
- **Research**: Reasoner models for analysis
## Next Steps
- [Configure Cursor](cursor.md) for IDE integration
- [Set up Continue](continue.md) for VSCode
- [Explore CLI usage](../cli/basic-usage.md)
+462
View File
@@ -0,0 +1,462 @@
# Cheap Providers - Ultra-Cheap Backup
When subscription quota runs out, pay pennies instead of dollars. ~90% cheaper than ChatGPT API!
---
## Overview
Cheap tier providers are your **backup** when subscription quota exhausted:
- 💰 **GLM-4.7** - $0.6/$2.2 per 1M tokens (daily reset)
- 💰 **MiniMax M2.1** - $0.2/$1.0 per 1M tokens (5h reset)
- 💰 **Kimi K2** - $9/month flat (10M tokens)
**Strategy:** Use after subscription quota out, before free tier. Massive cost savings vs ChatGPT API ($20/1M).
---
## GLM-4.7 (Daily Reset)
### Pricing
| Tier | Input | Output | Reset |
|------|-------|--------|-------|
| Standard | $0.60/1M | $2.20/1M | Daily 10:00 AM |
| Coding Plan | $0.60/1M | $2.20/1M | Daily 10:00 AM (3× quota) |
**Cost Example (10M tokens):**
- Input: 10M × $0.60 = $6
- Output: 10M × $2.20 = $22
- **Total: $6-22** vs $200 on ChatGPT API!
### Setup
**Step 1: Sign Up**
1. Visit [Zhipu AI](https://open.bigmodel.cn/)
2. Create account (phone verification)
3. Choose **Coding Plan** for 3× quota at same price
**Step 2: Get API Key**
```bash
Dashboard → API Keys → Create New
→ Copy API key (starts with "zhipu-")
```
**Step 3: Add to 9Router**
```bash
9router
# Dashboard → Providers → Add API Key
Provider: glm
API Key: zhipu-your-api-key-here
```
**Step 4: Use in CLI**
```
Model: glm/glm-4.7
glm/glm-4.6v (vision)
```
### Available Models
| Model ID | Description | Context | Best For |
|----------|-------------|---------|----------|
| `glm/glm-4.7` | GLM 4.7 | 128K | Coding, general tasks |
| `glm/glm-4.6v` | GLM 4.6V Vision | 128K | Image analysis |
### Pro Tips
- **Coding Plan** - 3× quota at same price ($0.6/$2.2)
- **Daily reset** - Fresh quota at 10:00 AM Beijing time
- **Best for coding** - Optimized for code generation
- **128K context** - Handle large files
### Quota Reset
```
Daily reset: 10:00 AM Beijing Time (UTC+8)
→ 2:00 AM UTC
→ 6:00 PM PST (previous day)
→ 9:00 PM EST (previous day)
Plan your heavy tasks around reset time!
```
---
## MiniMax M2.1 (5-Hour Reset)
### Pricing
| Tier | Input | Output | Reset |
|------|-------|--------|-------|
| Standard | $0.20/1M | $1.00/1M | 5-hour rolling |
**Cost Example (10M tokens):**
- Input: 10M × $0.20 = $2
- Output: 10M × $1.00 = $10
- **Total: $2-10** - Cheapest option!
### Setup
**Step 1: Sign Up**
1. Visit [MiniMax](https://www.minimax.io/)
2. Create account
3. Verify email/phone
**Step 2: Get API Key**
```bash
Dashboard → API Management → Create Key
→ Copy API key
```
**Step 3: Add to 9Router**
```bash
9router
# Dashboard → Providers → Add API Key
Provider: minimax
API Key: your-minimax-api-key
```
**Step 4: Use in CLI**
```
Model: minimax/MiniMax-M2.1
```
### Available Models
| Model ID | Description | Context | Best For |
|----------|-------------|---------|----------|
| `minimax/MiniMax-M2.1` | MiniMax M2.1 | 1M tokens | Long context, coding |
### Pro Tips
- **Cheapest option** - $0.20/1M input (90% cheaper than ChatGPT)
- **5-hour rolling** - Quota resets every 5 hours
- **1M context** - Massive context window
- **Best for long files** - Handle entire codebases
### Quota Reset
```
5-hour rolling window:
→ Use quota → Wait 5 hours → Fresh quota
Example:
10:00 AM - Use 5M tokens
3:00 PM - Fresh quota available
8:00 PM - Fresh quota available
Code 24/7 with minimal cost!
```
---
## Kimi K2 (Flat $9/month)
### Pricing
| Plan | Monthly Cost | Included Tokens | Effective Cost |
|------|--------------|-----------------|----------------|
| Subscription | $9 | 10M tokens | $0.90/1M |
**Cost Example:**
- $9/month flat
- 10M tokens included
- **Effective: $0.90/1M** - Best value for consistent usage!
### Setup
**Step 1: Subscribe**
1. Visit [Moonshot AI](https://platform.moonshot.ai/)
2. Create account
3. Subscribe to $9/month plan
**Step 2: Get API Key**
```bash
Dashboard → API Keys → Create New
→ Copy API key
```
**Step 3: Add to 9Router**
```bash
9router
# Dashboard → Providers → Add API Key
Provider: kimi
API Key: your-kimi-api-key
```
**Step 4: Use in CLI**
```
Model: kimi/kimi-latest
```
### Available Models
| Model ID | Description | Context | Best For |
|----------|-------------|---------|----------|
| `kimi/kimi-latest` | Kimi Latest | 200K | General coding |
### Pro Tips
- **Fixed cost** - $9/month regardless of usage (up to 10M)
- **Best for consistent usage** - If you use 10M/month, only $0.90/1M
- **Monthly reset** - 10M tokens reset monthly
- **Predictable billing** - No surprise costs
### Quota Reset
```
Monthly reset: 1st of each month
→ 10M tokens refresh
Example monthly usage:
Week 1: 3M tokens
Week 2: 2M tokens
Week 3: 3M tokens
Week 4: 2M tokens
Total: 10M tokens = $9 flat
```
---
## Pricing Comparison
| Provider | Input/1M | Output/1M | Reset | 10M Cost | Best For |
|----------|----------|-----------|-------|----------|----------|
| **GLM-4.7** | $0.60 | $2.20 | Daily 10AM | $6-22 | Daily quota users |
| **MiniMax M2.1** | $0.20 | $1.00 | 5-hour | $2-10 | **Cheapest!** |
| **Kimi K2** | $0.90 | $0.90 | Monthly | **$9 flat** | Consistent usage |
| ChatGPT API | $20.00 | $20.00 | None | $200 | ❌ Expensive |
**Savings:** 90-95% cheaper than ChatGPT API!
---
## Usage Example
### Cursor IDE Setup
```
Settings → Models → Advanced:
OpenAI API Base URL: http://localhost:20128/v1
OpenAI API Key: [from 9router dashboard]
Model: glm/glm-4.7
```
### Create Combo (Recommended)
```
Dashboard → Combos → Create New
Name: cheap-backup
Models:
1. cc/claude-opus-4-5 (Subscription primary)
2. glm/glm-4.7 (Cheap backup, daily reset)
3. minimax/MiniMax-M2.1 (Cheapest fallback)
4. if/kimi-k2-thinking (FREE emergency)
Use in CLI: cheap-backup
```
**Result:** Subscription → Cheap → Cheapest → Free
---
## Cost Optimization
### Strategy 1: Daily Reset Routine
```
Morning (10AM): Fresh GLM quota
→ Use GLM for heavy tasks
→ Save subscription quota
Afternoon: Subscription quota
→ Use Claude/Codex for complex tasks
Evening: MiniMax (5h reset)
→ Cheap fallback for late work
Night: Free tier (iFlow)
→ Zero cost emergency backup
```
### Strategy 2: Budget-First
```
Set monthly budget: $20
Allocation:
- $9 Kimi K2 (10M tokens flat)
- $6 GLM daily quota (10M tokens)
- $5 MiniMax overflow (25M tokens)
Total: 45M tokens for $20
vs 1M tokens for $20 on ChatGPT API!
```
### Strategy 3: Maximize Subscriptions First
```
Priority:
1. Gemini CLI (180K/month FREE)
2. Claude Code (subscription you already pay)
3. GLM-4.7 (cheap backup, $0.6/1M)
4. MiniMax M2.1 (cheapest, $0.2/1M)
5. iFlow (FREE emergency)
Monthly cost example (100M tokens):
- 60M via Gemini CLI: $0 (free)
- 30M via Claude Code: $0 (subscription)
- 8M via GLM: $4.80
- 2M via MiniMax: $0.40
Total: $5.20/month!
```
---
## Real-World Examples
### Example 1: Heavy Coding Month (100M tokens)
```
Breakdown:
- 60M via subscription (Claude/Codex): $0 extra
- 30M via GLM-4.7: $18
- 10M via MiniMax M2.1: $2
Total: $20/month
vs $2000 on ChatGPT API!
Savings: 99% cheaper!
```
### Example 2: Budget Coder ($10/month)
```
Strategy:
- $9 Kimi K2 (10M tokens)
- $1 MiniMax overflow (5M tokens)
Total: 15M tokens for $10
vs 0.5M tokens for $10 on ChatGPT API!
30× more tokens!
```
### Example 3: Freelancer (Variable Usage)
```
Light month (20M tokens):
- 15M via subscription: $0
- 5M via GLM: $3
Total: $3
Heavy month (150M tokens):
- 60M via subscription: $0
- 60M via GLM: $36
- 30M via MiniMax: $6
Total: $42
Average: $22.50/month
vs $3400 on ChatGPT API!
```
---
## Best Practices
### 1. Track Daily Quota
```
Dashboard shows:
- GLM quota: 75% used (reset in 6h)
- MiniMax quota: 50% used (reset in 2h)
- Kimi quota: 8M/10M used (reset in 15 days)
Plan heavy tasks around reset times!
```
### 2. Use Coding Plan (GLM)
```
Standard: 1× quota
Coding Plan: 3× quota (same price!)
→ Always choose Coding Plan
```
### 3. Combine with Free Tier
```
Combo:
1. gc/gemini-3-flash (FREE primary)
2. glm/glm-4.7 (cheap backup)
3. minimax/MiniMax-M2.1 (cheapest)
4. if/kimi-k2-thinking (FREE emergency)
Result: Minimize costs, maximize uptime
```
### 4. Set Budget Alerts
```
Dashboard → Settings → Budget Alerts
Daily: $2 limit
Weekly: $10 limit
Monthly: $30 limit
→ Auto switch to free tier when limit reached
```
---
## Troubleshooting
### "Quota exhausted"
**Solution:**
- GLM: Wait until 10:00 AM Beijing time
- MiniMax: Wait 5 hours from first use
- Kimi: Wait until 1st of next month
- Use combo fallback to free tier
### "API key invalid"
**Solution:**
- Check API key copied correctly
- Verify account has credits
- Regenerate API key if needed
### "High costs"
**Solution:**
- Check usage stats in Dashboard
- Set budget alerts
- Switch to MiniMax ($0.2/1M cheapest)
- Use free tier for non-critical tasks
---
## Next Steps
- **Add free fallback:** [Free Providers](./free.md)
- **Setup subscriptions:** [Subscription Providers](./subscription.md)
- **Create combos:** Dashboard → Combos → Create New
+442
View File
@@ -0,0 +1,442 @@
# Free Providers - Zero Cost Fallback
Emergency backup when everything else is quota-limited. Code 24/7 with zero cost!
---
## Overview
Free tier providers are your **fallback** when subscription and cheap quota exhausted:
- 🆓 **iFlow** - 8 models FREE (Kimi K2, Qwen3, GLM 4.7, MiniMax M2...)
- 🆓 **Qwen** - 3 models FREE (Qwen3 Coder Plus/Flash, Vision)
- 🆓 **Kiro** - 2 models FREE (Claude Sonnet 4.5, Haiku 4.5)
**Strategy:** Use as emergency backup. Unlimited usage, zero cost forever!
---
## iFlow (8 FREE Models)
### Pricing
| Plan | Monthly Cost | Models | Quota |
|------|--------------|--------|-------|
| FREE | $0 | 8 models | Unlimited |
**Best Value:** Most models in free tier! Kimi K2, Qwen3, GLM, MiniMax, DeepSeek.
### Setup
**Step 1: Connect via Dashboard**
```bash
9router
# Dashboard → Providers → Connect iFlow
```
**Step 2: iFlow OAuth Login**
- Click "Connect iFlow"
- Browser opens → iFlow login page
- Create account or login
- Grant permissions
- Auto token refresh enabled
**Step 3: Use in CLI**
```
Model: if/kimi-k2-thinking
if/kimi-k2
if/qwen3-coder-plus
if/glm-4.7
if/minimax-m2
if/deepseek-r1
if/deepseek-v3.2-chat
if/deepseek-v3.2-reasoner
```
### Available Models
| Model ID | Description | Best For |
|----------|-------------|----------|
| `if/kimi-k2-thinking` | Kimi K2 Thinking | Complex reasoning |
| `if/kimi-k2` | Kimi K2 | General coding |
| `if/qwen3-coder-plus` | Qwen3 Coder Plus | Code generation |
| `if/glm-4.7` | GLM 4.7 | Chinese + English |
| `if/minimax-m2` | MiniMax M2 | Long context |
| `if/deepseek-r1` | DeepSeek R1 | Reasoning tasks |
| `if/deepseek-v3.2-chat` | DeepSeek V3.2 Chat | Conversational |
| `if/deepseek-v3.2-reasoner` | DeepSeek V3.2 Reasoner | Complex logic |
### Pro Tips
- **8 models FREE** - Most variety in free tier
- **Unlimited usage** - No quota limits
- **Kimi K2 Thinking** - Best for complex reasoning
- **DeepSeek R1** - Strong reasoning capabilities
---
## Qwen (3 FREE Models)
### Pricing
| Plan | Monthly Cost | Models | Quota |
|------|--------------|--------|-------|
| FREE | $0 | 3 models | Unlimited |
### Setup
**Step 1: Connect via Dashboard**
```bash
9router
# Dashboard → Providers → Connect Qwen
```
**Step 2: Device Code Authorization**
- Click "Connect Qwen"
- Dashboard shows device code
- Visit authorization URL
- Enter device code
- Login to Qwen account
- Auto token refresh enabled
**Step 3: Use in CLI**
```
Model: qw/qwen3-coder-plus
qw/qwen3-coder-flash
qw/vision-model
```
### Available Models
| Model ID | Description | Best For |
|----------|-------------|----------|
| `qw/qwen3-coder-plus` | Qwen3 Coder Plus | Advanced coding |
| `qw/qwen3-coder-flash` | Qwen3 Coder Flash | Fast responses |
| `qw/vision-model` | Qwen3 Vision | Image analysis |
### Pro Tips
- **Qwen3 Coder Plus** - Strong coding capabilities
- **Qwen3 Coder Flash** - Fast for quick tasks
- **Vision model** - FREE image analysis
- **Unlimited usage** - No quota limits
---
## Kiro (Claude FREE)
### Pricing
| Plan | Monthly Cost | Models | Quota |
|------|--------------|--------|-------|
| FREE | $0 | Claude Sonnet 4.5, Haiku 4.5 | Unlimited |
**Best Value:** FREE Claude! Same quality as paid Claude Code.
### Setup
**Step 1: Connect via Dashboard**
```bash
9router
# Dashboard → Providers → Connect Kiro
```
**Step 2: AWS Builder ID or OAuth**
- Click "Connect Kiro"
- Choose login method:
- AWS Builder ID (recommended)
- Google account
- GitHub account
- Grant permissions
- Auto token refresh enabled
**Step 3: Use in CLI**
```
Model: kr/claude-sonnet-4.5
kr/claude-haiku-4.5
```
### Available Models
| Model ID | Description | Best For |
|----------|-------------|----------|
| `kr/claude-sonnet-4.5` | Claude Sonnet 4.5 | Balanced quality/speed |
| `kr/claude-haiku-4.5` | Claude Haiku 4.5 | Fast responses |
### Pro Tips
- **FREE Claude** - Same quality as paid tier
- **AWS Builder ID** - Easy setup with AWS account
- **Unlimited usage** - No quota limits
- **Best quality** - Claude 4.5 for free!
---
## Feature Comparison
| Provider | Models | Best Model | Setup | Quota |
|----------|--------|------------|-------|-------|
| **iFlow** | 8 | Kimi K2 Thinking | OAuth | Unlimited |
| **Qwen** | 3 | Qwen3 Coder Plus | Device Code | Unlimited |
| **Kiro** | 2 | Claude Sonnet 4.5 | AWS Builder ID | Unlimited |
**Winner:** iFlow for variety, Kiro for quality!
---
## Usage Example
### Cursor IDE Setup
```
Settings → Models → Advanced:
OpenAI API Base URL: http://localhost:20128/v1
OpenAI API Key: [from 9router dashboard]
Model: if/kimi-k2-thinking
```
### Create Combo (Recommended)
```
Dashboard → Combos → Create New
Name: free-combo
Models:
1. if/kimi-k2-thinking (iFlow primary)
2. qw/qwen3-coder-plus (Qwen backup)
3. kr/claude-sonnet-4.5 (Kiro quality)
Use in CLI: free-combo
```
**Result:** Zero cost, maximum uptime!
---
## Full Fallback Strategy
### Complete 3-Tier Combo
```
Dashboard → Combos → Create New
Name: complete-fallback
Models:
1. gc/gemini-3-flash-preview (FREE subscription)
2. cc/claude-opus-4-5 (Paid subscription)
3. glm/glm-4.7 (Cheap backup, $0.6/1M)
4. minimax/MiniMax-M2.1 (Cheapest, $0.2/1M)
5. if/kimi-k2-thinking (FREE fallback)
6. kr/claude-sonnet-4.5 (FREE quality)
Use in CLI: complete-fallback
```
**Result:**
- Tier 1: FREE subscription (Gemini CLI)
- Tier 2: Paid subscription (Claude Code)
- Tier 3: Cheap backup (GLM, MiniMax)
- Tier 4: FREE fallback (iFlow, Kiro)
**Never stop coding!**
---
## Best Practices
### 1. Use as Emergency Backup
```
Priority:
1. Subscription tier (maximize paid quota)
2. Cheap tier (pennies per 1M tokens)
3. FREE tier (unlimited, zero cost)
Only use free tier when:
- Subscription quota exhausted
- Budget limit reached
- Testing/non-critical tasks
```
### 2. Choose Right Model
```
Complex reasoning: if/kimi-k2-thinking
Fast coding: qw/qwen3-coder-flash
Best quality: kr/claude-sonnet-4.5
Long context: if/minimax-m2
Vision tasks: qw/vision-model
```
### 3. Create Free-Only Combo
```
For zero-cost coding:
Name: zero-cost
Models:
1. kr/claude-sonnet-4.5 (Best quality)
2. if/kimi-k2-thinking (Complex tasks)
3. qw/qwen3-coder-plus (Fast coding)
Cost: $0 forever!
```
### 4. Test Before Production
```
Use free tier to:
- Test prompts
- Prototype features
- Learn new frameworks
- Non-critical tasks
Save paid quota for:
- Production code
- Complex refactoring
- Critical features
```
---
## Real-World Examples
### Example 1: Student/Learner (Zero Budget)
```
Setup:
1. kr/claude-sonnet-4.5 (Best quality)
2. if/kimi-k2-thinking (Complex reasoning)
3. qw/qwen3-coder-plus (Fast coding)
Monthly cost: $0
Usage: Unlimited
Perfect for:
- Learning to code
- Personal projects
- Homework/assignments
```
### Example 2: Freelancer (Budget-Conscious)
```
Setup:
1. gc/gemini-3-flash-preview (FREE 180K/month)
2. glm/glm-4.7 (Cheap backup, $0.6/1M)
3. if/kimi-k2-thinking (FREE fallback)
Monthly cost: $5-10
Usage: 100M+ tokens
Perfect for:
- Client projects (paid tier)
- Testing (free tier)
- Emergency backup
```
### Example 3: Heavy User (Maximize Everything)
```
Setup:
1. gc/gemini-3-flash-preview (FREE 180K/month)
2. cc/claude-opus-4-5 (Subscription $20-100)
3. cx/gpt-5.2-codex (Subscription $20-200)
4. glm/glm-4.7 (Cheap $0.6/1M)
5. minimax/MiniMax-M2.1 (Cheapest $0.2/1M)
6. if/kimi-k2-thinking (FREE unlimited)
7. kr/claude-sonnet-4.5 (FREE quality)
Monthly cost: $40-320 (subscriptions) + $10-20 (cheap tier)
Usage: 500M+ tokens
Perfect for:
- Professional development
- Team projects
- 24/7 coding
```
---
## Cost Comparison
### Scenario: 100M tokens/month
**Option 1: ChatGPT API Only**
```
100M × $20/1M = $2,000/month
```
**Option 2: 9Router Free Tier Only**
```
100M via free tier = $0/month
Savings: $2,000/month (100%)
```
**Option 3: 9Router Complete Strategy**
```
60M via Gemini CLI (FREE): $0
30M via Claude Code (subscription): $0 extra
8M via GLM (cheap): $4.80
2M via iFlow (FREE): $0
Total: $4.80/month + subscriptions you already have
Savings: $1,995/month (99.76%)
```
---
## Troubleshooting
### "OAuth failed"
**Solution:**
- Check internet connection
- Try different browser
- Clear browser cache
- Reconnect in dashboard
### "Model not available"
**Solution:**
- Check provider connected in dashboard
- Verify OAuth token valid
- Reconnect provider if needed
### "Slow responses"
**Solution:**
- Free tier may have lower priority
- Use during off-peak hours
- Switch to different free provider
- Upgrade to cheap tier for speed
---
## Limitations
### Free Tier Considerations
- **Speed** - May be slower than paid tiers
- **Priority** - Lower priority during peak hours
- **Rate limits** - Possible rate limiting (but unlimited quota)
- **Availability** - May have occasional downtime
**Solution:** Use 3-tier fallback strategy for reliability!
---
## Next Steps
- **Setup subscriptions:** [Subscription Providers](./subscription.md)
- **Add cheap backup:** [Cheap Providers](./cheap.md)
- **Create combos:** Dashboard → Combos → Create New
- **Start coding:** Use `complete-fallback` combo for maximum reliability
@@ -0,0 +1,404 @@
# Subscription Providers - Maximize Your Value
Maximize your existing AI subscriptions with smart quota tracking and automatic fallback. Use every bit of your subscription before it resets!
---
## Overview
Subscription tier providers are your **primary** choice - you're already paying for them, so get full value:
-**Claude Code** (Pro/Max) - Claude 4.5 Opus/Sonnet/Haiku
-**OpenAI Codex** (Plus/Pro) - GPT 5.2 Codex, GPT 5.1 Codex Max
-**Gemini CLI** (FREE tier!) - 180K completions/month
-**GitHub Copilot** - GPT-5, Claude 4.5, Gemini 3
-**Antigravity** (Google) - Gemini 3 Pro, Claude Sonnet 4.5
**Strategy:** Use these first, track quota in real-time, fallback to cheap/free when exhausted.
---
## Claude Code (Pro/Max)
### Pricing
| Plan | Monthly Cost | Quota Reset | Models |
|------|--------------|-------------|--------|
| Pro | $20 | 5-hour + Weekly | Opus, Sonnet, Haiku |
| Max | $100 | 5-hour + Weekly | Opus, Sonnet, Haiku |
### Setup
**Step 1: Connect via Dashboard**
```bash
9router
# Dashboard opens → Providers → Connect Claude Code
```
**Step 2: OAuth Login**
- Click "Connect Claude Code"
- Browser opens → Login to Claude.ai
- Auto token refresh enabled
- Quota tracking starts
**Step 3: Use in CLI**
```
Model: cc/claude-opus-4-5-20251101
cc/claude-sonnet-4-5-20250929
cc/claude-haiku-4-5-20251001
```
### Available Models
| Model ID | Description | Best For |
|----------|-------------|----------|
| `cc/claude-opus-4-5-20251101` | Claude 4.5 Opus | Complex tasks, architecture |
| `cc/claude-sonnet-4-5-20250929` | Claude 4.5 Sonnet | Balanced speed/quality |
| `cc/claude-haiku-4-5-20251001` | Claude 4.5 Haiku | Fast responses |
### Pro Tips
- **Use Opus for complex tasks** - Architecture decisions, refactoring
- **Use Sonnet for speed** - Quick edits, code generation
- **Track quota per model** - Dashboard shows usage per model
- **5-hour reset** - Fresh quota every 5 hours + weekly reset
---
## OpenAI Codex (Plus/Pro)
### Pricing
| Plan | Monthly Cost | Quota Reset | Models |
|------|--------------|-------------|--------|
| Plus | $20 | 5-hour + Weekly | GPT 5.2, GPT 5.1 |
| Pro | $200 | 5-hour + Weekly | GPT 5.2 Codex, GPT 5.1 Max |
### Setup
**Step 1: Connect via Dashboard**
```bash
9router
# Dashboard → Providers → Connect Codex
```
**Step 2: OAuth Login**
- Click "Connect Codex"
- Browser opens to `http://localhost:1455`
- Login to OpenAI account
- Auto token refresh enabled
**Step 3: Use in CLI**
```
Model: cx/gpt-5.2-codex
cx/gpt-5.1-codex-max
cx/gpt-5.2
cx/gpt-5.1-codex
```
### Available Models
| Model ID | Description | Best For |
|----------|-------------|----------|
| `cx/gpt-5.2-codex` | GPT 5.2 Codex | Latest coding model |
| `cx/gpt-5.1-codex-max` | GPT 5.1 Codex Max | Maximum context |
| `cx/gpt-5.2` | GPT 5.2 | General tasks |
| `cx/gpt-5.1-codex` | GPT 5.1 Codex | Stable coding |
### Pro Tips
- **5-hour rolling quota** - Fresh quota every 5 hours
- **Weekly reset** - Full quota reset weekly
- **Pro tier** - 10× more quota than Plus
---
## Gemini CLI (FREE 180K/month!)
### Pricing
| Plan | Monthly Cost | Quota | Reset |
|------|--------------|-------|-------|
| FREE | $0 | 180K completions/month + 1K/day | Daily + Monthly |
**Best Value:** Huge free tier! Use this before paid tiers.
### Setup
**Step 1: Connect via Dashboard**
```bash
9router
# Dashboard → Providers → Connect Gemini CLI
```
**Step 2: Google OAuth**
- Click "Connect Gemini CLI"
- Browser opens → Login to Google account
- Grant permissions
- Auto token refresh enabled
**Step 3: Use in CLI**
```
Model: gc/gemini-3-flash-preview
gc/gemini-3-pro-preview
gc/gemini-2.5-pro
gc/gemini-2.5-flash
```
### Available Models
| Model ID | Description | Best For |
|----------|-------------|----------|
| `gc/gemini-3-flash-preview` | Gemini 3 Flash Preview | Fast responses |
| `gc/gemini-3-pro-preview` | Gemini 3 Pro Preview | Complex tasks |
| `gc/gemini-2.5-pro` | Gemini 2.5 Pro | Stable production |
| `gc/gemini-2.5-flash` | Gemini 2.5 Flash | Quick tasks |
### Pro Tips
- **180K completions/month** - Massive free tier
- **1K/day limit** - Daily quota resets at midnight
- **Use first** - Free tier, use before paid subscriptions
- **No credit card** - Completely free with Google account
---
## GitHub Copilot
### Pricing
| Plan | Monthly Cost | Quota Reset | Models |
|------|--------------|-------------|--------|
| Individual | $10 | Monthly (1st) | GPT-5, Claude 4.5, Gemini 3 |
| Business | $19 | Monthly (1st) | GPT-5, Claude 4.5, Gemini 3 |
### Setup
**Step 1: Connect via Dashboard**
```bash
9router
# Dashboard → Providers → Connect GitHub
```
**Step 2: OAuth via GitHub**
- Click "Connect GitHub"
- Browser opens → Login to GitHub
- Authorize GitHub Copilot
- Auto token refresh enabled
**Step 3: Use in CLI**
```
Model: gh/gpt-5
gh/gpt-5.1-codex-max
gh/claude-4.5-sonnet
gh/gemini-3-pro
```
### Available Models
| Model ID | Description | Best For |
|----------|-------------|----------|
| `gh/gpt-5` | GPT-5 | Latest OpenAI model |
| `gh/gpt-5.1-codex-max` | GPT-5.1 Codex Max | Maximum context |
| `gh/claude-4.5-sonnet` | Claude 4.5 Sonnet | Anthropic quality |
| `gh/gemini-3-pro` | Gemini 3 Pro | Google quality |
### Pro Tips
- **Monthly reset** - Full quota reset on 1st of month
- **Multiple models** - Access GPT, Claude, Gemini in one subscription
- **Business tier** - Higher quota for teams
---
## Antigravity (Google Account)
### Pricing
| Plan | Monthly Cost | Quota | Models |
|------|--------------|-------|--------|
| FREE | $0 | Similar to Gemini CLI | Gemini 3 Pro, Claude Sonnet 4.5 |
### Setup
**Step 1: Connect via Dashboard**
```bash
9router
# Dashboard → Providers → Connect Antigravity
```
**Step 2: Google OAuth**
- Click "Connect Antigravity"
- Browser opens → Login to Google account
- Grant permissions
- Auto token refresh enabled
**Step 3: Use in CLI**
```
Model: ag/gemini-3-pro-high
ag/claude-sonnet-4-5
ag/claude-opus-4-5-thinking
```
### Available Models
| Model ID | Description | Best For |
|----------|-------------|----------|
| `ag/gemini-3-pro-high` | Gemini 3 Pro High | High-quality responses |
| `ag/claude-sonnet-4-5` | Claude Sonnet 4.5 | Anthropic quality |
| `ag/claude-opus-4-5-thinking` | Claude Opus 4.5 Thinking | Complex reasoning |
### Pro Tips
- **Free tier** - No cost with Google account
- **Claude access** - Free Claude Sonnet/Opus
- **Quota similar to Gemini CLI** - Daily/monthly limits
---
## Pricing Comparison
| Provider | Monthly Cost | Quota Reset | Value |
|----------|--------------|-------------|-------|
| **Claude Code Pro** | $20 | 5-hour + Weekly | ⭐⭐⭐⭐⭐ Best quality |
| **Claude Code Max** | $100 | 5-hour + Weekly | ⭐⭐⭐⭐⭐ Highest quota |
| **Codex Plus** | $20 | 5-hour + Weekly | ⭐⭐⭐⭐ Good value |
| **Codex Pro** | $200 | 5-hour + Weekly | ⭐⭐⭐⭐⭐ 10× quota |
| **Gemini CLI** | **$0** | Daily + Monthly | ⭐⭐⭐⭐⭐ FREE 180K/month! |
| **GitHub Copilot** | $10-19 | Monthly (1st) | ⭐⭐⭐⭐ Multi-model |
| **Antigravity** | **$0** | Daily + Monthly | ⭐⭐⭐⭐ FREE Claude! |
---
## Usage Example
### Cursor IDE Setup
```
Settings → Models → Advanced:
OpenAI API Base URL: http://localhost:20128/v1
OpenAI API Key: [from 9router dashboard]
Model: cc/claude-opus-4-5-20251101
```
### Create Combo (Recommended)
```
Dashboard → Combos → Create New
Name: premium-coding
Models:
1. gc/gemini-3-flash-preview (FREE, use first)
2. cc/claude-opus-4-5-20251101 (Subscription)
3. cx/gpt-5.2-codex (Subscription backup)
Use in CLI: premium-coding
```
**Result:** Maximize free tier → Use subscription → Auto fallback
---
## Quota Tracking
9Router tracks quota in real-time:
- **Token consumption** - Input/output tokens per request
- **Reset countdown** - Time until next quota reset
- **Usage percentage** - How much quota used
- **Auto fallback** - Switch to next tier when exhausted
**Dashboard view:**
```
Claude Code Pro
├─ Quota: 75% used
├─ Reset: 2h 15m (5-hour)
├─ Weekly reset: 3 days
└─ Fallback: glm/glm-4.7 (cheap tier)
```
---
## Best Practices
### 1. Use Free Tier First
```
Priority:
1. Gemini CLI (180K/month FREE)
2. Antigravity (FREE Claude)
3. Claude Code/Codex (paid subscriptions)
```
### 2. Track Quota Daily
- Check dashboard every morning
- Plan heavy tasks around quota resets
- Use cheap/free tier for non-critical tasks
### 3. Create Smart Combos
```
Example combo:
1. gc/gemini-3-flash-preview (FREE primary)
2. cc/claude-opus-4-5 (Complex tasks)
3. glm/glm-4.7 (Cheap backup)
4. if/kimi-k2-thinking (FREE fallback)
```
### 4. Optimize by Time
```
Morning: Fresh 5-hour quota (Claude/Codex)
Afternoon: Gemini CLI (1K/day)
Evening: Subscription quota
Night: Cheap/free tier
```
---
## Troubleshooting
### "Quota exhausted"
**Solution:**
- Check dashboard quota tracker
- Wait for reset (5-hour or daily)
- Use combo fallback to cheap/free tier
### "OAuth token expired"
**Solution:**
- Auto-refreshed by 9Router
- If issues: Dashboard → Provider → Reconnect
### "Rate limiting"
**Solution:**
- Subscription quota out
- Add fallback: `cc/claude-opus → glm/glm-4.7`
- Use free tier: `if/kimi-k2-thinking`
---
## Next Steps
- **Setup cheap backup:** [Cheap Providers](./cheap.md)
- **Add free fallback:** [Free Providers](./free.md)
- **Create combos:** Dashboard → Combos → Create New
+351
View File
@@ -0,0 +1,351 @@
# Troubleshooting
Common issues and solutions when using 9Router.
---
## "Language model did not provide messages"
**Problem:** Request fails with empty response or error message.
**Causes:**
- Provider quota exhausted
- API key invalid or expired
- Model not available
**Solutions:**
1. **Check quota status:**
```
Dashboard → Providers → View quota tracker
```
If quota is exhausted, wait for reset or switch provider.
2. **Use combo fallback:**
```
Dashboard → Combos → Create fallback chain
Example: cc/claude-opus → glm/glm-4.7 → if/kimi-k2
```
3. **Verify provider connection:**
```
Dashboard → Providers → Reconnect if needed
```
---
## Rate Limiting
**Problem:** "Rate limit exceeded" or "Too many requests" errors.
**Causes:**
- Subscription quota depleted (5-hour/daily/weekly limits)
- API rate limits hit
- Too many concurrent requests
**Solutions:**
1. **Check reset time:**
```
Dashboard → Quota Tracking → View reset countdown
```
2. **Switch to cheap tier:**
```
Use: glm/glm-4.7 ($0.6/1M tokens)
minimax/MiniMax-M2.1 ($0.20/1M tokens)
```
3. **Add fallback combo:**
```
Dashboard → Combos → Add backup models
Primary: cc/claude-opus (subscription)
Backup: glm/glm-4.7 (cheap)
Emergency: if/kimi-k2 (free)
```
---
## OAuth Token Expired
**Problem:** "Unauthorized" or "Token expired" errors.
**Causes:**
- OAuth token expired (auto-refresh failed)
- Provider session invalidated
- Network issues during refresh
**Solutions:**
1. **Auto-refresh (default):**
9Router automatically refreshes tokens. Wait 30 seconds and retry.
2. **Manual reconnect:**
```
Dashboard → Providers → [Provider Name] → Reconnect
→ Complete OAuth flow again
```
3. **Check provider status:**
Verify provider service is online (Claude Code, Codex, etc.)
---
## High Costs
**Problem:** Unexpected high usage or costs.
**Causes:**
- Using expensive models unnecessarily
- No fallback to cheaper tiers
- Large context windows
**Solutions:**
1. **Check usage stats:**
```
Dashboard → Usage Stats → View token consumption
→ Identify high-cost models
```
2. **Switch to cheaper models:**
```
Replace: cc/claude-opus ($20-100/month subscription)
With: glm/glm-4.7 ($0.6/1M tokens)
minimax/MiniMax-M2.1 ($0.20/1M tokens)
```
3. **Use free tier:**
```
if/kimi-k2-thinking (FREE)
qw/qwen3-coder-plus (FREE)
kr/claude-sonnet-4.5 (FREE)
gc/gemini-3-flash-preview (FREE 180K/month)
```
4. **Optimize prompts:**
- Reduce context size
- Use streaming for long responses
- Cache common prompts
---
## Connection Refused
**Problem:** "ECONNREFUSED" or "Cannot connect to localhost:20128".
**Causes:**
- 9Router not running
- Port 20128 blocked
- Firewall blocking connection
**Solutions:**
1. **Start 9Router:**
```bash
9router
```
Dashboard should open at http://localhost:3000
2. **Verify port 20128:**
```bash
# Check if port is listening
lsof -i :20128
# Or on Windows
netstat -ano | findstr :20128
```
3. **Check firewall:**
- macOS: System Settings → Network → Firewall
- Windows: Windows Defender Firewall → Allow app
- Linux: `sudo ufw allow 20128`
4. **Use cloud endpoint:**
If localhost doesn't work (e.g., Cursor IDE):
```
Endpoint: https://9router.com/v1
```
---
## Dashboard Not Opening
**Problem:** Dashboard doesn't load at http://localhost:3000.
**Causes:**
- Port 3000 already in use
- 9Router crashed
- Browser cache issues
**Solutions:**
1. **Check if 9Router is running:**
```bash
# Check process
ps aux | grep 9router
# Check port 3000
lsof -i :3000
```
2. **Kill conflicting process:**
```bash
# macOS/Linux
lsof -ti:3000 | xargs kill -9
# Windows
netstat -ano | findstr :3000
taskkill /PID <PID> /F
```
3. **Restart 9Router:**
```bash
# Stop
pkill -f 9router
# Start
9router
```
4. **Clear browser cache:**
- Chrome: Ctrl+Shift+Delete → Clear cache
- Try incognito mode
5. **Check firewall settings:**
Ensure port 3000 is not blocked.
---
## Model Not Found
**Problem:** "Model not found" or "Invalid model" errors.
**Causes:**
- Provider not connected
- Model ID typo
- Provider inactive
**Solutions:**
1. **Verify provider connection:**
```
Dashboard → Providers → Check status (green = active)
```
2. **Check model ID format:**
```
Correct: cc/claude-opus-4-5-20251101
Wrong: claude-opus-4-5-20251101
Format: [provider-prefix]/[model-name]
```
3. **List available models:**
```bash
curl http://localhost:20128/v1/models \
-H "Authorization: Bearer your-api-key"
```
4. **Reconnect provider:**
```
Dashboard → Providers → [Provider] → Reconnect
```
---
## Slow Response
**Problem:** Requests take too long or timeout.
**Causes:**
- Provider latency
- Network issues
- Large context/response
- Provider rate limiting
**Solutions:**
1. **Check provider status:**
```
Dashboard → Providers → View latency stats
```
2. **Switch to faster model:**
```
Fast: cc/claude-haiku-4-5 (Haiku is faster than Opus)
gc/gemini-3-flash-preview
qw/qwen3-coder-flash
```
3. **Use streaming:**
```json
{
"model": "cc/claude-opus-4-5",
"messages": [...],
"stream": true
}
```
4. **Check network:**
```bash
# Test latency
ping api.anthropic.com
ping api.openai.com
```
5. **Reduce context size:**
- Trim message history
- Use smaller prompts
- Enable context pruning in CLI tool
---
## API Key Invalid
**Problem:** "Invalid API key" or "Authentication failed" errors.
**Causes:**
- Wrong API key copied
- API key expired
- API key not generated
**Solutions:**
1. **Regenerate API key:**
```
Dashboard → Settings → API Keys → Generate New Key
→ Copy and use new key
```
2. **Verify key format:**
```
Correct: 9r_xxxxxxxxxxxxxxxxxxxxxxxx
Wrong: Missing 9r_ prefix
```
3. **Check key in CLI config:**
```bash
# Cursor
Settings → Models → OpenAI API Key
# Cline
Settings → API Key
# Environment variable
export OPENAI_API_KEY="9r_your_key"
```
4. **Test API key:**
```bash
curl http://localhost:20128/v1/models \
-H "Authorization: Bearer 9r_your_key"
```
---
## Need More Help?
- **GitHub Issues:** [github.com/decolua/9router/issues](https://github.com/decolua/9router/issues)
- **Documentation:** [9router.com/docs](https://9router.com/docs)
- **FAQ:** [faq.md](faq.md)
+473
View File
@@ -0,0 +1,473 @@
# ☁️ Despliegue en la nube
Despliega 9Router en VPS o Docker para acceso remoto y uso en producción.
---
## 🖥️ Despliegue en VPS
### Requisitos previos
- Ubuntu 20.04+ o distribución Linux similar
- Node.js 20+
- Git
- Acceso root o sudo
### Paso 1: Clonar el repositorio
```bash
git clone https://github.com/decolua/9router.git
cd 9router/app
```
### Paso 2: Instalar dependencias
```bash
npm install
```
### Paso 3: Compilar la aplicación
```bash
npm run build
```
### Paso 4: Configurar variables de entorno
Crea un archivo `.env` o exporta variables:
```bash
export JWT_SECRET="your-secure-secret-change-this-to-random-string"
export INITIAL_PASSWORD="your-secure-password"
export DATA_DIR="/var/lib/9router"
export NODE_ENV="production"
```
**Variables de entorno:**
| Variable | Por defecto | Descripción |
|----------|---------|-------------|
| `JWT_SECRET` | Auto-generado | **¡DEBE cambiarse en producción!** Usado para firmar tokens JWT |
| `INITIAL_PASSWORD` | `123456` | Contraseña de login del dashboard |
| `DATA_DIR` | `~/.9router` | Ruta de almacenamiento de la base de datos |
| `NODE_ENV` | `development` | Establece a `production` para despliegue |
| `ENABLE_REQUEST_LOGS` | `false` | Habilita logs de debug de request/response |
### Paso 5: Crear el directorio de datos
```bash
sudo mkdir -p /var/lib/9router
sudo chown $USER:$USER /var/lib/9router
```
### Paso 6: Iniciar la aplicación
```bash
npm run start
```
### Paso 7: Configurar PM2 para producción
PM2 mantiene tu aplicación corriendo y la reinicia en caso de crash:
```bash
# Instalar PM2 globalmente
npm install -g pm2
# Iniciar 9Router con PM2
pm2 start npm --name 9router -- start
# Guardar la configuración de PM2
pm2 save
# Configurar PM2 para iniciar al arrancar el sistema
pm2 startup
# Sigue las instrucciones impresas por el comando anterior
```
**Comandos de gestión de PM2:**
```bash
# Ver logs
pm2 logs 9router
# Reiniciar aplicación
pm2 restart 9router
# Detener aplicación
pm2 stop 9router
# Ver estado
pm2 status
# Monitorear recursos
pm2 monit
```
---
## 🐳 Despliegue con Docker
### Opción 1: Usando Dockerfile
Crea un `Dockerfile` en el directorio `app`:
```dockerfile
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy application files
COPY . .
# Build application
RUN npm run build
# Expose ports
EXPOSE 3000 20128
# Set environment variables
ENV NODE_ENV=production
ENV DATA_DIR=/app/data
# Create data directory
RUN mkdir -p /app/data
# Start application
CMD ["npm", "run", "start"]
```
**Build y Run:**
```bash
# Construir imagen
docker build -t 9router .
# Ejecutar contenedor
docker run -d \
--name 9router \
-p 3000:3000 \
-p 20128:20128 \
-e JWT_SECRET="your-secure-secret-change-this" \
-e INITIAL_PASSWORD="your-secure-password" \
-v 9router-data:/app/data \
9router
```
### Opción 2: Docker Compose
Crea `docker-compose.yml`:
```yaml
version: '3.8'
services:
9router:
build: .
container_name: 9router
ports:
- "3000:3000"
- "20128:20128"
environment:
- NODE_ENV=production
- JWT_SECRET=your-secure-secret-change-this
- INITIAL_PASSWORD=your-secure-password
- DATA_DIR=/app/data
volumes:
- 9router-data:/app/data
restart: unless-stopped
volumes:
9router-data:
```
**Ejecutar con Docker Compose:**
```bash
# Iniciar servicios
docker-compose up -d
# Ver logs
docker-compose logs -f
# Detener servicios
docker-compose down
# Reconstruir y reiniciar
docker-compose up -d --build
```
---
## 🌐 Proxy reverso con Nginx
### ¿Por qué usar Nginx?
- Terminación SSL/TLS
- Mapeo de nombre de dominio
- Balanceo de carga
- Mejor seguridad
### Paso 1: Instalar Nginx
```bash
sudo apt update
sudo apt install nginx
```
### Paso 2: Configurar Nginx
Crea `/etc/nginx/sites-available/9router`:
```nginx
server {
listen 80;
server_name your-domain.com;
# Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name your-domain.com;
# SSL certificates (use certbot to generate)
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
# SSL configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# Proxy to 9Router
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# SSE support - CRITICAL for streaming
proxy_buffering off;
proxy_read_timeout 86400;
}
# API endpoint
location /v1 {
proxy_pass http://localhost:20128;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE support - CRITICAL for streaming
proxy_buffering off;
proxy_read_timeout 86400;
}
}
```
### Paso 3: Habilitar el sitio
```bash
# Crear enlace simbólico
sudo ln -s /etc/nginx/sites-available/9router /etc/nginx/sites-enabled/
# Probar configuración
sudo nginx -t
# Recargar Nginx
sudo systemctl reload nginx
```
### Paso 4: Configurar SSL con Let's Encrypt
```bash
# Instalar certbot
sudo apt install certbot python3-certbot-nginx
# Obtener certificado SSL
sudo certbot --nginx -d your-domain.com
# La auto-renovación se configura automáticamente
# Probar renovación
sudo certbot renew --dry-run
```
---
## 🔒 Consideraciones de seguridad
### 1. Cambiar credenciales por defecto
**CRÍTICO:** Cambia `JWT_SECRET` y `INITIAL_PASSWORD` antes del despliegue:
```bash
# Generar JWT secret seguro
openssl rand -base64 32
# Usa este valor para JWT_SECRET
export JWT_SECRET="generated-secret-here"
```
### 2. Configuración del firewall
```bash
# Permitir SSH
sudo ufw allow 22/tcp
# Permitir HTTP/HTTPS (si usas Nginx)
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Si NO usas proxy reverso, permite los puertos de 9Router
sudo ufw allow 3000/tcp
sudo ufw allow 20128/tcp
# Habilitar firewall
sudo ufw enable
```
### 3. Restringir el acceso al dashboard
Si solo necesitas acceso por API, restringe el puerto del dashboard:
```bash
# Solo permitir acceso localhost al dashboard
sudo ufw deny 3000/tcp
```
Accede al dashboard vía túnel SSH:
```bash
ssh -L 3000:localhost:3000 user@your-server.com
# Luego abre http://localhost:3000 en tu navegador
```
### 4. Actualizaciones regulares
```bash
# Actualizar paquetes del sistema
sudo apt update && sudo apt upgrade -y
# Actualizar 9Router
cd /path/to/9router/app
git pull
npm install
npm run build
pm2 restart 9router
```
### 5. Estrategia de respaldo
```bash
# Respaldar el directorio de datos
tar -czf 9router-backup-$(date +%Y%m%d).tar.gz /var/lib/9router
# Respaldo automatizado diario (agregar a crontab)
0 2 * * * tar -czf /backups/9router-$(date +\%Y\%m\%d).tar.gz /var/lib/9router
```
---
## 📊 Monitoreo
### Verificar el estado de la aplicación
```bash
# Estado PM2
pm2 status
# Ver logs
pm2 logs 9router --lines 100
# Monitorear recursos
pm2 monit
```
### Logs de Nginx
```bash
# Logs de acceso
sudo tail -f /var/log/nginx/access.log
# Logs de error
sudo tail -f /var/log/nginx/error.log
```
### Recursos del sistema
```bash
# Uso de CPU y memoria
htop
# Uso de disco
df -h
# Conexiones de red
netstat -tulpn | grep -E '3000|20128'
```
---
## 🚨 Solución de problemas
### La aplicación no inicia
```bash
# Verificar logs
pm2 logs 9router
# Verificar si los puertos están en uso
sudo lsof -i :3000
sudo lsof -i :20128
# Verificar variables de entorno
pm2 env 9router
```
### Nginx 502 Bad Gateway
```bash
# Verificar si 9Router está corriendo
pm2 status
# Verificar logs de error de Nginx
sudo tail -f /var/log/nginx/error.log
# Probar configuración de Nginx
sudo nginx -t
```
### El streaming SSE no funciona
Asegúrate de que `proxy_buffering off` esté configurado en Nginx para soporte SSE.
### Errores de permiso denegado
```bash
# Corregir permisos del directorio de datos
sudo chown -R $USER:$USER /var/lib/9router
chmod 755 /var/lib/9router
```
---
## 🔗 Próximos pasos
- [Conectar proveedores](/providers/subscription.md)
- [Configurar combos](/features/combos.md)
- [Integrar con herramientas](/integration/cursor.md)
+164
View File
@@ -0,0 +1,164 @@
# 🏠 Despliegue en localhost
Ejecuta 9Router en tu máquina local para desarrollo y uso personal.
---
## 📦 Instalación
Instala 9Router globalmente vía npm:
```bash
npm install -g 9router
```
**Requisitos:**
- Node.js 20 o superior
- npm 9 o superior
---
## 🚀 Iniciar el servidor
Inicia 9Router con un solo comando:
```bash
9router
```
El dashboard se abrirá automáticamente en tu navegador en `http://localhost:3000`
**Configuración por defecto:**
- **Dashboard**: `http://localhost:3000`
- **API Endpoint**: `http://localhost:20128/v1`
- **Directorio de datos**: `~/.9router`
---
## 🔧 Configuración
### Directorio de datos personalizado
Establece un directorio de datos personalizado usando una variable de entorno:
```bash
DATA_DIR=/path/to/data 9router
```
### Puerto personalizado
El puerto de API (20128) y el puerto del dashboard (3000) están configurados en la aplicación. Para cambiarlos, necesitarás modificar el código fuente o usar variables de entorno si se soportan.
---
## 🛑 Detener el servidor
Presiona `Ctrl+C` en la terminal donde 9Router se está ejecutando.
```bash
# En la terminal ejecutando 9router
^C # Presiona Ctrl+C
```
El servidor se apagará correctamente y guardará todos los datos.
---
## 🔄 Reiniciar el servidor
Simplemente ejecuta el comando de inicio nuevamente:
```bash
9router
```
Todas tus configuraciones, API keys y combos se preservan en el directorio de datos.
---
## 📊 Actualizar 9Router
Actualiza a la última versión:
```bash
npm update -g 9router
```
Verifica tu versión actual:
```bash
npm list -g 9router
```
---
## 🔍 Solución de problemas
### Puerto ya en uso
Si el puerto 20128 o 3000 ya está en uso:
```bash
# Encontrar proceso usando el puerto (macOS/Linux)
lsof -i :20128
lsof -i :3000
# Matar el proceso
kill -9 <PID>
```
### Errores de permisos
Si encuentras errores de permisos durante la instalación:
```bash
# Usar sudo (no recomendado)
sudo npm install -g 9router
# O corregir los permisos de npm (recomendado)
mkdir ~/.npm-global
npm config set prefix '~/.npm-global'
echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.bashrc
source ~/.bashrc
```
### Problemas con el directorio de datos
Si el directorio de datos no es accesible:
```bash
# Verificar permisos
ls -la ~/.9router
# Corregir permisos
chmod 755 ~/.9router
```
---
## 📁 Estructura del directorio de datos
```
~/.9router/
├── db.json # Main database (providers, combos, settings)
├── logs/ # Application logs
└── cache/ # Temporary cache files
```
**Respaldar tus datos:**
```bash
# Respaldo
cp -r ~/.9router ~/.9router.backup
# Restaurar
cp -r ~/.9router.backup ~/.9router
```
---
## 🔗 Próximos pasos
- [Conectar proveedores](/providers/subscription.md)
- [Crear combos](/features/combos.md)
- [Integrar con herramientas CLI](/integration/cursor.md)
+387
View File
@@ -0,0 +1,387 @@
# Preguntas frecuentes
Preguntas comunes sobre 9Router.
---
## ¿Qué es 9Router?
**9Router es un router de modelos de IA que maximiza el valor de tu suscripción y minimiza los costos.**
Enruta inteligentemente las solicitudes a través de múltiples proveedores de IA usando un sistema de fallback de 3 niveles:
1. **Nivel de suscripción** - Maximiza las cuotas de Claude Code, Codex, Gemini que ya pagas
2. **Nivel barato** - Alternativas ultra-baratas ($0.20-$0.60 por 1M tokens)
3. **Nivel gratis** - Respaldo de emergencia con modelos gratis ilimitados
**Beneficios clave:**
- Nunca desperdicies la cuota de suscripción
- Fallback automático cuando se agota la cuota
- Seguimiento de cuota en tiempo real
- 90% de ahorro en costos vs uso directo de API
---
## ¿Cómo funciona el precio?
**9Router usa una estrategia de precios de 3 niveles:**
### Nivel 1: Suscripción (Maximiza primero)
- **Claude Code** (Pro/Max): $20-100/mes - Cuota de 5 horas + semanal
- **OpenAI Codex** (Plus/Pro): $20-200/mes - Cuota de 5 horas + semanal
- **Gemini CLI**: GRATIS - 180K completados/mes + 1K/día
- **GitHub Copilot**: $10-19/mes - Reinicio mensual
- **Antigravity**: GRATIS - Similar a Gemini
**Objetivo:** ¡Usa cada bit de cuota antes de que se reinicie!
### Nivel 2: Barato (Respaldo)
- **GLM-4.7**: $0.60/$2.20 por 1M tokens - Reinicio diario 10AM
- **MiniMax M2.1**: $0.20/$1.00 por 1M tokens - 5 horas rolling
- **Kimi K2**: $9/mes plano (10M tokens)
**Objetivo:** ¡90% más barato que ChatGPT API ($20/1M)!
### Nivel 3: Gratis (Emergencia)
- **iFlow**: 8 modelos GRATIS (Kimi K2, Qwen3, GLM, MiniMax...)
- **Qwen**: 3 modelos GRATIS (Qwen3 Coder Plus/Flash, Vision)
- **Kiro**: 2 modelos GRATIS (Claude Sonnet 4.5, Haiku 4.5)
**Objetivo:** ¡Fallback de cero costo cuando todo lo demás está limitado por cuota!
---
## ¿9Router es gratis?
**Sí, 9Router en sí es 100% gratis y open source.**
**Proveedores de nivel gratis disponibles:**
- **Gemini CLI** - 180K completados/mes (cuenta Google GRATIS)
- **iFlow** - 8 modelos ilimitados (OAuth GRATIS)
- **Qwen** - 3 modelos ilimitados (OAuth GRATIS)
- **Kiro** - Claude Sonnet/Haiku (AWS Builder ID GRATIS)
**¡Puedes codificar GRATIS para siempre usando solo proveedores de nivel gratis!**
**Proveedores de pago opcionales:**
- Servicios de suscripción que ya puedes tener (Claude Code, Codex, Copilot)
- Alternativas ultra-baratas ($0.20-$0.60 por 1M tokens)
---
## ¿Qué proveedores son compatibles?
### Proveedores de suscripción
- **Claude Code** (Pro/Max) - Claude 4.5 Opus/Sonnet/Haiku
- **OpenAI Codex** (Plus/Pro) - GPT 5.2 Codex, GPT 5.1 Codex Max
- **Gemini CLI** (GRATIS) - Gemini 3 Flash/Pro, 2.5 Pro/Flash
- **GitHub Copilot** - GPT-5, Claude 4.5, Gemini 3
- **Antigravity** (Google) - Gemini 3 Pro, Claude Sonnet 4.5
### Proveedores baratos
- **GLM** (Zhipu AI) - GLM 4.7, GLM 4.6V Vision
- **MiniMax** - MiniMax M2.1
- **Kimi** (Moonshot AI) - Kimi Latest
- **OpenRouter** - Passthrough a cualquier modelo de OpenRouter
### Proveedores gratis
- **iFlow** - 8 modelos (Kimi K2, Qwen3, GLM, MiniMax, DeepSeek...)
- **Qwen** - 3 modelos (Qwen3 Coder Plus/Flash, Vision)
- **Kiro** - 2 modelos (Claude Sonnet 4.5, Haiku 4.5)
**Total: 15+ proveedores, 50+ modelos**
Consulta la [documentación de proveedores](providers/subscription.md) para más detalles.
---
## ¿Puedo usar múltiples proveedores?
**¡Sí! Esta es la característica principal de 9Router.**
**Los combos te permiten encadenar múltiples proveedores con fallback automático:**
```
Ejemplo de combo: "premium-coding"
1. cc/claude-opus-4-5 (Suscripción principal)
2. glm/glm-4.7 (Respaldo barato)
3. if/kimi-k2 (Emergencia gratis)
→ Cambio automático cuando se agota la cuota
→ Nunca para de codificar
→ Costo extra mínimo
```
**Cómo crear combos:**
```
Dashboard → Combos → Create New
→ Agrega modelos en orden de prioridad
→ Usa el nombre del combo en CLI: "premium-coding"
```
**Beneficios:**
- Cero tiempo de inactividad cuando se agota la cuota
- Optimización automática de costos
- Un solo nombre de modelo para todas las herramientas
Consulta la [documentación de combos](features/combos.md) para ejemplos.
---
## ¿Cómo funciona el seguimiento de cuota?
**9Router rastrea la cuota en tiempo real para todos los proveedores:**
**Características:**
- **Consumo de tokens** - Tokens de entrada/salida por solicitud
- **Cuenta regresiva de reinicio** - Tiempo hasta que se refresca la cuota
- **Estadísticas de uso** - Reportes diarios/semanales/mensuales
- **Estimación de costos** - Gasto proyectado (niveles de pago)
- **Alertas de cuota** - Notificaciones cuando la cuota es baja
**Tipos de cuota:**
- **5 horas rolling** - Claude Code, Codex, MiniMax
- **Reinicio diario** - Gemini CLI (1K/día), GLM (10AM)
- **Reinicio semanal** - Claude Code, Codex (cuota adicional)
- **Reinicio mensual** - Gemini CLI (180K), GitHub Copilot (día 1)
**Ver cuota:**
```
Dashboard → Providers → Quota Tracking
→ Uso en tiempo real + cuenta regresiva de reinicio
```
Consulta la [documentación de seguimiento de cuota](features/quota-tracking.md) para detalles.
---
## ¿9Router funciona con Cursor?
**Sí, pero Cursor requiere un endpoint en la nube.**
**Problema:** Cursor IDE no soporta endpoints en localhost.
**Solución:** Usa el despliegue en la nube de 9Router:
```
Cursor Settings → Models → Advanced:
OpenAI API Base URL: https://9router.com/v1
OpenAI API Key: [desde el dashboard]
Model: cc/claude-opus-4-5-20251101
```
**Alternativa:** Auto-hospéda en VPS con dominio público:
```bash
# Despliega en VPS
git clone https://github.com/decolua/9router.git
cd 9router/app
npm install && npm run build
npm start
# Configura proxy reverso Nginx
# Apunta Cursor a: https://your-domain.com/v1
```
**Otras herramientas CLI funcionan con localhost:**
- Cline ✅
- Claude Desktop ✅
- Codex CLI ✅
- Continue ✅
- RooCode ✅
Consulta la [guía de integración de Cursor](integration/cursor.md) para detalles.
---
## ¿Puedo auto-hospedar 9Router?
**¡Sí! 9Router soporta múltiples opciones de despliegue:**
### Localhost (Por defecto)
```bash
npm install -g 9router
9router
→ Dashboard: http://localhost:3000
→ API: http://localhost:20128/v1
```
### VPS/Cloud
```bash
git clone https://github.com/decolua/9router.git
cd 9router/app
npm install && npm run build
export JWT_SECRET="your-secure-secret"
export INITIAL_PASSWORD="your-password"
export NODE_ENV="production"
npm start
```
### Docker
```bash
docker build -t 9router .
docker run -d \
-p 3000:3000 \
-e JWT_SECRET="your-secret" \
-v 9router-data:/app/data \
9router
```
### Cloudflare Workers
```bash
cd 9router/app
npm run deploy:cloudflare
```
**Variables de entorno:**
- `JWT_SECRET` - **¡DEBE cambiarse en producción!**
- `DATA_DIR` - Ruta de almacenamiento de la base de datos (por defecto: `~/.9router`)
- `INITIAL_PASSWORD` - Login del dashboard (por defecto: `123456`)
- `NODE_ENV` - Establece en `production` para desplegar
Consulta la [guía de despliegue](getting-started/installation.md#deployment) para detalles.
---
## ¿Mis datos están seguros?
**Sí, 9Router prioriza la seguridad y privacidad:**
**Almacenamiento local:**
- Todos los datos se almacenan localmente en `~/.9router` (o `DATA_DIR` personalizado)
- No se envían datos a los servidores de 9Router
- Tokens OAuth cifrados con JWT
**Sin telemetría:**
- Sin seguimiento de uso
- Sin analítica
- Sin phone-home
**Open source:**
- Código fuente completo disponible en GitHub
- Audita la seguridad tú mismo
- Revisado por la comunidad
**Mejores prácticas:**
- Cambia `JWT_SECRET` en producción
- Usa un `INITIAL_PASSWORD` fuerte
- Habilita HTTPS para despliegues en la nube
- Rota las API keys regularmente
**Lo que 9Router almacena:**
- Tokens OAuth de proveedores (cifrados)
- API keys (cifradas)
- Estadísticas de uso (solo locales)
- Configuraciones de combos
**Lo que 9Router NO almacena:**
- Tus prompts o respuestas
- El código que generas
- Información personal
---
## ¿Cómo actualizo 9Router?
**Los métodos de actualización dependen del tipo de instalación:**
### Instalación global NPM
```bash
npm update -g 9router
```
### Instalación local
```bash
cd 9router/app
git pull origin main
npm install
npm run build
npm start
```
### Docker
```bash
docker pull 9router:latest
docker stop 9router
docker rm 9router
docker run -d \
-p 3000:3000 \
-v 9router-data:/app/data \
9router:latest
```
**Verificar versión:**
```bash
9router --version
```
**Cambios disruptivos:**
- Revisa [CHANGELOG.md](https://github.com/decolua/9router/blob/main/CHANGELOG.md)
- Respalda `~/.9router` antes de actualizaciones mayores
- Revisa las guías de migración para versiones mayores
---
## ¿Cómo puedo contribuir?
**¡Damos la bienvenida a las contribuciones!**
### Formas de contribuir:
1. **Reportar bugs:**
- [GitHub Issues](https://github.com/decolua/9router/issues)
- Incluye logs de error, pasos para reproducir
2. **Solicitar características:**
- [GitHub Discussions](https://github.com/decolua/9router/discussions)
- Describe el caso de uso y los beneficios
3. **Enviar código:**
```bash
# Fork del repo
git clone https://github.com/YOUR_USERNAME/9router.git
cd 9router
# Crea una rama
git checkout -b feature/your-feature
# Haz cambios
npm install
npm run dev
# Prueba
npm test
# Commit y push
git add .
git commit -m "Add your feature"
git push origin feature/your-feature
# Crea un Pull Request en GitHub
```
4. **Mejorar docs:**
- Corrige errores tipográficos, agrega ejemplos
- Traduce a otros idiomas
- Escribe tutoriales
5. **Agregar proveedores:**
- Implementa nuevos adaptadores de proveedores
- Consulta `app/lib/providers/` para ejemplos
**Directrices de contribución:**
- Sigue el estilo de código existente
- Agrega tests para nuevas características
- Actualiza la documentación
- Mantén los commits atómicos y descriptivos
Consulta [CONTRIBUTING.md](https://github.com/decolua/9router/blob/main/CONTRIBUTING.md) para detalles.
---
## ¿Necesitas más ayuda?
- **Documentación:** [9router.com/docs](https://9router.com/docs)
- **GitHub:** [github.com/decolua/9router](https://github.com/decolua/9router)
- **Issues:** [github.com/decolua/9router/issues](https://github.com/decolua/9router/issues)
- **Troubleshooting:** [troubleshooting.md](troubleshooting.md)
+537
View File
@@ -0,0 +1,537 @@
# Combos - Cadenas de fallback personalizadas
Crea combinaciones de modelos personalizadas con fallback automático. Los combos te permiten definir tu propia estrategia de enrutamiento basada en costo, calidad y disponibilidad.
---
## ¿Qué son los combos?
Los combos son **cadenas de fallback personalizadas** que creas en el dashboard. En lugar de usar un solo modelo, defines una secuencia de modelos que 9Router intenta en orden.
**Ejemplo:**
```
Nombre del combo: premium-coding
Modelos:
1. cc/claude-opus-4-5-20251101 (intentar primero)
2. glm/glm-4.7 (si #1 tiene cuota agotada)
3. minimax/MiniMax-M2.1 (si #2 tiene cuota agotada)
```
**Uso en CLI:**
```
Model: premium-coding
```
9Router intenta automáticamente cada modelo en secuencia hasta que uno tenga éxito.
---
## ¿Por qué usar combos?
### 1. Maximiza el valor de la suscripción
```
cc/claude-opus → glm/glm-4.7 → if/kimi-k2-thinking
→ Usa la suscripción primero, respaldo barato, emergencia gratis
→ Obtén el valor completo de las suscripciones que ya pagas
```
### 2. Minimiza costos
```
glm/glm-4.7 → minimax/MiniMax-M2.1 → if/kimi-k2-thinking
→ Comienza con la opción de pago más barata ($0.60/1M)
→ Fallback a una aún más barata ($0.20/1M)
→ Nivel de emergencia gratis
→ Costo total: ~$5-10/mes vs $2000 en ChatGPT API
```
### 3. Garantiza disponibilidad 24/7
```
cc/claude-opus → cx/gpt-5.2-codex → glm/glm-4.7 → if/kimi-k2-thinking
→ Siempre incluye el nivel gratis al final
→ Nunca te quedes sin cuota
→ Codifica en cualquier momento, en cualquier lugar
```
### 4. Optimiza por calidad
```
cc/claude-opus-4-5 → cx/gpt-5.2-codex → gc/gemini-3-pro
→ Mejores modelos primero
→ Fallback a otros modelos premium
→ Mantén alta calidad en toda la cadena de fallback
```
---
## Cómo crear combos
### Paso 1: Abrir el dashboard
```
http://localhost:20128
→ Inicia sesión con tu contraseña
```
### Paso 2: Navegar a Combos
```
Dashboard → Combos → Create New Combo
```
### Paso 3: Configurar el combo
**Nombre del combo:**
```
premium-coding
```
**Descripción (opcional):**
```
Suscripción primero, respaldo barato, emergencia gratis
```
**Seleccionar modelos:**
```
1. cc/claude-opus-4-5-20251101
2. glm/glm-4.7
3. minimax/MiniMax-M2.1
```
**Arrastra para reordenar** - Prioridad de arriba a abajo.
### Paso 4: Guardar
```
Clic en "Save Combo"
→ El combo aparece en la lista de modelos
```
### Paso 5: Usar en CLI
```
Cursor/Cline/Cualquier herramienta:
Model: premium-coding
```
---
## Combos de ejemplo
### Ejemplo 1: Premium Coding (Suscripción → Barato → Gratis)
**Objetivo**: Maximizar el valor de la suscripción, minimizar costos extras.
```
Dashboard → Combos → Create New
Name: premium-coding
Models:
1. cc/claude-opus-4-5-20251101
2. glm/glm-4.7
3. minimax/MiniMax-M2.1
```
**Uso:**
```
Cursor IDE:
Model: premium-coding
```
**Comportamiento:**
```
Mañana (cuota fresca):
Solicitud → cc/claude-opus-4-5 ✅
Tarde (cuota de Claude agotada):
Solicitud → glm/glm-4.7 ✅ (cambio automático)
Noche (cuota de GLM agotada):
Solicitud → minimax/MiniMax-M2.1 ✅ (cambio automático)
```
**Costo mensual (100M tokens):**
```
80M vía Claude Code: $0 (suscripción)
15M vía GLM: $9
5M vía MiniMax: $1
Total: $10 + tu suscripción
```
**Ahorros**: ~99% vs ChatGPT API ($2000).
---
### Ejemplo 2: Combo de presupuesto (Barato → Gratis)
**Objetivo**: Minimizar costos, usar el nivel gratis como respaldo.
```
Dashboard → Combos → Create New
Name: budget-combo
Models:
1. glm/glm-4.7
2. minimax/MiniMax-M2.1
3. if/kimi-k2-thinking
```
**Uso:**
```
Cline:
Provider: OpenAI Compatible
Base URL: http://localhost:20128/v1
Model: budget-combo
```
**Comportamiento:**
```
Solicitud → glm/glm-4.7
✅ Cuota diaria disponible → Usa GLM ($0.60/1M)
❌ Cuota agotada → Intenta MiniMax ($0.20/1M)
❌ Cuota de MiniMax agotada → Usa iFlow (GRATIS)
```
**Costo mensual (100M tokens):**
```
70M vía GLM: $42
20M vía MiniMax: $4
10M vía iFlow: $0
Total: $46 vs $2000 en ChatGPT API
```
**Ahorros**: 97%.
---
### Ejemplo 3: Combo gratis (Cero costo)
**Objetivo**: 100% gratis, sin costos nunca.
```
Dashboard → Combos → Create New
Name: free-combo
Models:
1. if/kimi-k2-thinking
2. qw/qwen3-coder-plus
3. kr/claude-sonnet-4.5
```
**Uso:**
```
Claude Desktop:
Model: free-combo
```
**Comportamiento:**
```
Solicitud → if/kimi-k2-thinking
✅ Disponible → Usa iFlow
❌ Error → Intenta Qwen
❌ Error → Intenta Kiro
```
**Costo mensual:**
```
100M tokens vía proveedores gratis: $0
Total: $0 para siempre
```
**Caso de uso**: Proyectos personales, aprendizaje, experimentación.
---
### Ejemplo 4: Calidad primero (Solo modelos premium)
**Objetivo**: Mejor calidad, sin fallback barato.
```
Dashboard → Combos → Create New
Name: quality-first
Models:
1. cc/claude-opus-4-5-20251101
2. cx/gpt-5.2-codex
3. gc/gemini-3-pro-preview
```
**Uso:**
```
Codex CLI:
export OPENAI_BASE_URL="http://localhost:20128"
Model: quality-first
```
**Comportamiento:**
```
Solicitud → cc/claude-opus-4-5
❌ Cuota agotada → cx/gpt-5.2-codex
❌ Cuota agotada → gc/gemini-3-pro-preview
❌ Todo agotado → Devuelve error (sin fallback barato)
```
**Caso de uso**: Código crítico de producción, refactoring complejo.
---
### Ejemplo 5: Multi-suscripción (Maximiza todo)
**Objetivo**: Usa todas las suscripciones antes de pagar extra.
```
Dashboard → Combos → Create New
Name: multi-sub
Models:
1. gc/gemini-3-flash-preview (GRATIS 180K/mes)
2. cc/claude-opus-4-5-20251101 (suscripción Pro)
3. cx/gpt-5.2-codex (suscripción Plus)
4. gh/gpt-5 (suscripción Copilot)
5. glm/glm-4.7 (respaldo barato)
6. if/kimi-k2-thinking (emergencia gratis)
```
**Costo mensual (200M tokens):**
```
50M vía Gemini CLI: $0 (nivel gratis)
80M vía Claude Code: $0 (suscripción)
40M vía Codex: $0 (suscripción)
20M vía Copilot: $0 (suscripción)
8M vía GLM: $4.80
2M vía iFlow: $0
Total: $4.80 + suscripciones existentes
```
**Resultado**: Usa 190M tokens de suscripciones, solo $4.80 extra.
---
### Ejemplo 6: Optimización de reinicio de cuota
**Objetivo**: Distribuir el uso según los tiempos de reinicio.
```
Dashboard → Combos → Create New
Name: reset-optimized
Models:
1. cc/claude-opus-4-5 (reinicio 5h, usar mañana)
2. gc/gemini-3-flash (1K/día, usar tarde)
3. glm/glm-4.7 (reinicio diario 10AM, usar noche)
4. minimax/MiniMax-M2.1 (rolling 5h, usar madrugada)
5. if/kimi-k2-thinking (ilimitado, emergencia)
```
**Rutina diaria:**
```
08:00 - 13:00: Claude Code (cuota fresca de 5h)
13:00 - 18:00: Gemini CLI (cuota 1K/día)
18:00 - 22:00: GLM (se reinicia 10AM del día siguiente)
22:00 - 08:00: MiniMax (rolling 5h) o iFlow
```
**Resultado**: Codifica 24/7 con costos mínimos.
---
## Usar combos en herramientas CLI
### Cursor IDE
```
Settings → Models → Advanced:
OpenAI API Base URL: http://localhost:20128/v1
OpenAI API Key: [desde el dashboard]
Model: premium-coding
```
### Claude Desktop
Edita `~/.claude/config.json`:
```json
{
"anthropic_api_base": "http://localhost:20128/v1",
"anthropic_api_key": "your-9router-api-key",
"model": "budget-combo"
}
```
### Codex CLI
```bash
export OPENAI_BASE_URL="http://localhost:20128"
export OPENAI_API_KEY="your-9router-api-key"
codex --model quality-first "your prompt"
```
### Cline / Continue / RooCode
```
Provider: OpenAI Compatible
Base URL: http://localhost:20128/v1
API Key: [desde el dashboard]
Model: free-combo
```
### Solicitud por API
```bash
curl http://localhost:20128/v1/chat/completions \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"model": "premium-coding",
"messages": [
{"role": "user", "content": "Write a function to..."}
],
"stream": true
}'
```
---
## Mejores prácticas
### 1. Siempre incluye el nivel gratis
```
✅ Bueno:
cc/claude-opus → glm/glm-4.7 → if/kimi-k2-thinking
❌ Malo:
cc/claude-opus → glm/glm-4.7
(sin fallback gratis, puede quedarse sin cuota)
```
**Por qué**: Garantiza disponibilidad 24/7, nunca bloqueado por cuota.
### 2. Ordena por costo (Barato a costoso)
```
✅ Bueno:
glm/glm-4.7 → minimax/MiniMax-M2.1 → cc/claude-opus
❌ Malo:
cc/claude-opus → glm/glm-4.7
(desperdicia cuota de suscripción en tareas simples)
```
**Excepción**: Si quieres maximizar el valor de la suscripción, pon la suscripción primero.
### 3. Coincide con los requisitos de calidad
```
Para código de producción:
cc/claude-opus → cx/gpt-5.2-codex → glm/glm-4.7
Para tareas rápidas:
glm/glm-4.7 → if/kimi-k2-thinking
Para experimentación:
if/kimi-k2-thinking → qw/qwen3-coder-plus
```
### 4. Considera los tiempos de reinicio de cuota
```
Combo matutino (cuotas frescas):
cc/claude-opus → cx/gpt-5.2-codex
Combo nocturno (cuotas probablemente agotadas):
glm/glm-4.7 → minimax/MiniMax-M2.1 → if/kimi-k2-thinking
```
### 5. Crea múltiples combos para diferentes casos de uso
```
premium-coding: Para tareas complejas
budget-combo: Para tareas simples
free-combo: Para experimentación
quality-first: Para código de producción
```
**Cambia entre combos** según los requisitos de la tarea.
### 6. Monitorea el desempeño del combo
```
Dashboard → Analytics → Combo Usage:
premium-coding:
80% vía cc/claude-opus (bueno, usando suscripción)
15% vía glm/glm-4.7 (respaldo aceptable)
5% vía minimax (fallback raro)
```
**Optimiza**: Si hay demasiado uso de fallback, aumenta la cuota principal o reordena modelos.
---
## Configuración avanzada
### Establecer límites de presupuesto por combo
```
Dashboard → Combos → Edit → Budget:
Daily limit: $5
Monthly limit: $50
```
Cuando se alcanza el límite, 9Router omite los modelos de pago y usa solo el nivel gratis.
### Habilitar/Deshabilitar modelos en un combo
```
Dashboard → Combos → Edit → Models:
✅ cc/claude-opus-4-5 (habilitado)
❌ glm/glm-4.7 (deshabilitado temporalmente)
✅ if/kimi-k2-thinking (habilitado)
```
**Caso de uso**: Deshabilitar temporalmente modelos costosos sin eliminar el combo.
### Clonar un combo existente
```
Dashboard → Combos → Clone "premium-coding"
→ Crea una copia con sufijo "-copy"
→ Modifica y guarda como nuevo combo
```
**Caso de uso**: Crear variaciones para diferentes escenarios.
---
## Solución de problemas
**Problema: El combo no aparece en la lista de modelos**
**Solución:**
1. Refresca el dashboard
2. Verifica que el combo esté guardado (marca verde)
3. Reinicia la herramienta CLI para refrescar la lista de modelos
**Problema: El combo siempre usa el último modelo (nivel gratis)**
**Solución:**
1. Verifica la cuota de los modelos principales (Dashboard → Quota)
2. Verifica que las API keys sean válidas (Dashboard → Providers)
3. Verifica que no se hayan excedido los límites de presupuesto
**Problema: El combo cuesta más de lo esperado**
**Solución:**
1. Dashboard → Analytics → Revisa el uso del combo
2. Verifica si los modelos principales tienen cuota agotada
3. Reordena los modelos (pon los más baratos primero)
4. Establece límites de presupuesto
---
## Relacionado
- [Enrutamiento inteligente](./smart-routing.md) - Cómo funciona el fallback automático
- [Seguimiento de cuota](./quota-tracking.md) - Monitorea uso y costos
@@ -0,0 +1,687 @@
# Seguimiento de cuota y monitoreo de uso
Rastrea el consumo de tokens en tiempo real, monitorea los límites de cuota, estima costos y recibe alertas antes de quedarte sin recursos. Nunca desperdicies cuota de suscripción ni excedas los límites de presupuesto.
---
## Resumen
9Router proporciona un seguimiento de cuota integral para todos los proveedores:
- **Consumo de tokens en tiempo real** - Mira los tokens usados por solicitud
- **Límites de cuota y restantes** - Rastrea el uso vs límites
- **Cuenta regresiva de reinicio** - Sabe cuándo se refresca la cuota
- **Estimación de costos** - Calcula el gasto para niveles de pago
- **Reportes mensuales** - Analiza patrones de uso
- **Alertas y notificaciones** - Recibe advertencias antes de los límites
---
## Resumen del dashboard
### Resumen de cuota
```
Dashboard → Home → Quota Overview
┌─────────────────────────────────────────────┐
│ Claude Code (cc/) │
│ ████████████░░░░░░░░ 2.5h / 5h (50%) │
│ Se reinicia en: 2h 30m │
│ Costo: $0 (suscripción) │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ Gemini CLI (gc/) │
│ ████████░░░░░░░░░░░░ 450 / 1000 (45%) │
│ Reinicio diario en: 18h 30m │
│ Mensual: 45K / 180K (25%) │
│ Costo: $0 (nivel gratis) │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ GLM-4.7 (glm/) │
│ ██████████████░░░░░░ 7M / 10M tokens (70%) │
│ Se reinicia: Diario 10:00 AM (en 5h 35m) │
│ Costo hoy: $4.20 │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ MiniMax M2.1 (minimax/) │
│ ████████████████░░░░ 4M / 5M tokens (80%) │
│ Ventana rolling 5h │
│ Costo (5h): $0.80 │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ iFlow (if/) │
│ ████████████████████ Ilimitado │
│ Costo: $0 (gratis para siempre) │
└─────────────────────────────────────────────┘
```
---
## Consumo de tokens en tiempo real
### Seguimiento por solicitud
Cada solicitud muestra el uso detallado de tokens:
```
Dashboard → Activity → Recent Requests
Request #1234
Model: cc/claude-opus-4-5-20251101
Timestamp: 2026-02-04 04:15:32
Tokens:
Input: 1,250 tokens
Output: 850 tokens
Total: 2,100 tokens
Cost: $0 (cuota de suscripción)
Duration: 3.2s
Status: ✅ Success
```
### Monitor de uso en vivo
```
Dashboard → Live Monitor
Solicitud actual:
Model: glm/glm-4.7
Tokens transmitidos: 450 / ~800 estimados
Costo hasta ahora: $0.0009
Duración: 1.8s
```
### Desglose de tokens por modelo
```
Dashboard → Analytics → Token Usage
Hoy (4 feb 2026):
cc/claude-opus-4-5: 15M tokens ($0, suscripción)
glm/glm-4.7: 8M tokens ($4.80)
if/kimi-k2-thinking: 3M tokens ($0, gratis)
Total: 26M tokens
Costo: $4.80
```
---
## Límites de cuota y tiempos de reinicio
### Proveedores de suscripción
**Claude Code (Pro/Max)**
```
Tipo de cuota: Basado en tiempo (rolling 5 horas)
Límite: 5 horas de uso
Reinicio: Ventana rolling 5 horas + refresh semanal
Seguimiento: Tiempo de uso por modelo
El dashboard muestra:
Opus: 2.5h / 5h usados
Sonnet: 1.2h / 5h usados
Haiku: 0.8h / 5h usados
Reinicio semanal: Todos los lunes 00:00 UTC
```
**OpenAI Codex (Plus/Pro)**
```
Tipo de cuota: Basado en tiempo (rolling 5 horas)
Límite: 5 horas (Plus) / 10 horas (Pro)
Reinicio: Ventana rolling 5 horas + refresh semanal
El dashboard muestra:
GPT-5.2 Codex: 3.5h / 5h usados
Se reinicia en: 1h 30m
```
**Gemini CLI (GRATIS)**
```
Tipo de cuota: Conteo de solicitudes + tokens mensuales
Límite diario: 1,000 solicitudes
Límite mensual: 180,000 completados
Reinicio: Diario 00:00 UTC + Mensual día 1
El dashboard muestra:
Hoy: 450 / 1,000 solicitudes (45%)
Este mes: 45K / 180K completados (25%)
Reinicio diario en: 18h 30m
Reinicio mensual en: 26 días
```
**GitHub Copilot**
```
Tipo de cuota: Uso mensual
Límite: Varía según el plan
Reinicio: 1ro de cada mes
El dashboard muestra:
Uso: 60% de la cuota mensual
Se reinicia: 1 mar 2026 (en 25 días)
```
### Proveedores baratos
**GLM-4.7**
```
Tipo de cuota: Límite diario de tokens
Límite: 10M tokens/día (Coding Plan)
Reinicio: Diario 10:00 AM hora de Beijing (UTC+8)
El dashboard muestra:
Usados: 7M / 10M tokens (70%)
Restantes: 3M tokens
Se reinicia en: 5h 35m
Costo hoy: $4.20
```
**MiniMax M2.1**
```
Tipo de cuota: Ventana rolling 5 horas
Límite: 5M tokens por 5 horas
Reinicio: Ventana rolling continua
El dashboard muestra:
Usados (5h): 4M / 5M tokens (80%)
El uso más antiguo expira en: 45m
Costo (5h): $0.80
```
**Kimi K2**
```
Tipo de cuota: Suscripción mensual
Límite: 10M tokens/mes ($9 plano)
Reinicio: Mensual en la fecha de suscripción
El dashboard muestra:
Usados: 6M / 10M tokens (60%)
Se reinicia: 15 feb 2026 (en 11 días)
Costo: $9/mes (pagado por adelantado)
```
### Proveedores gratis
**iFlow / Qwen / Kiro**
```
Tipo de cuota: Ilimitado (con rate-limit)
Límite: Sin límite duro
Reinicio: N/A
El dashboard muestra:
Usados hoy: 5M tokens
Costo: $0 (gratis para siempre)
Estado: ✅ Disponible
```
---
## Estimación de costos
### Seguimiento de costos en tiempo real
```
Dashboard → Costs → Today
Proveedores de suscripción: $0
Claude Code: 15M tokens ($0, incluido)
Gemini CLI: 3M tokens ($0, nivel gratis)
Proveedores de pago: $4.80
GLM-4.7: 8M tokens ($4.80)
Input: 6M × $0.60/1M = $3.60
Output: 2M × $2.20/1M = $4.40
Total: $4.80
Proveedores gratis: $0
iFlow: 3M tokens ($0)
Total hoy: $4.80
```
### Reporte de gasto mensual
```
Dashboard → Costs → This Month (Febrero 2026)
Semana 1 (1-7 feb):
Suscripción: $0 (80M tokens)
Pago: $15.20 (25M tokens)
Gratis: $0 (10M tokens)
Total: $15.20
Semana 2 (8-14 feb):
Suscripción: $0 (75M tokens)
Pago: $12.80 (20M tokens)
Gratis: $0 (8M tokens)
Total: $12.80
Mes hasta la fecha: $28.00
Proyectado (30 días): ~$120
Desglose por proveedor:
GLM-4.7: $22.00 (78%)
MiniMax M2.1: $6.00 (22%)
Costo promedio por 1M tokens: $0.62
Ahorros vs ChatGPT API: 97% ($4,000 → $120)
```
### Proyección de costos
```
Dashboard → Costs → Projections
Basado en uso de los últimos 7 días:
Promedio diario: 50M tokens
Costo diario: $4.50
Proyección mensual:
Tokens: 1,500M (1.5B)
Costo: $135
Desglose:
Suscripción: 900M tokens ($0)
GLM-4.7: 450M tokens ($90)
MiniMax: 120M tokens ($24)
Gratis: 30M tokens ($0)
Estado del presupuesto:
Límite diario: $5 → 90% usado hoy
Límite mensual: $150 → 90% proyectado
⚠️ Advertencia: Puede exceder el presupuesto mensual
```
---
## Dashboard de uso
### Estadísticas generales
```
Dashboard → Analytics → Overview
Hoy (4 feb 2026):
Solicitudes: 1,234
Tokens: 26M
Costo: $4.80
Tiempo promedio de respuesta: 2.1s
Esta semana:
Solicitudes: 8,456
Tokens: 180M
Costo: $28.00
Tasa de éxito: 99.2%
Este mes:
Solicitudes: 15,234
Tokens: 320M
Costo: $52.00
Modelo principal: cc/claude-opus-4-5 (45%)
```
### Uso por modelo
```
Dashboard → Analytics → Models
Modelos principales (este mes):
1. cc/claude-opus-4-5: 145M tokens (45%)
2. glm/glm-4.7: 95M tokens (30%)
3. if/kimi-k2-thinking: 50M tokens (16%)
4. minimax/MiniMax-M2.1: 20M tokens (6%)
5. gc/gemini-3-flash: 10M tokens (3%)
Desglose de costos:
cc/claude-opus: $0 (suscripción)
glm/glm-4.7: $45.00
if/kimi-k2-thinking: $0 (gratis)
minimax/MiniMax-M2.1: $7.00
gc/gemini-3-flash: $0 (gratis)
```
### Uso por tiempo
```
Dashboard → Analytics → Timeline
Uso por hora (hoy):
00:00 - 01:00: 0.5M tokens
01:00 - 02:00: 0.2M tokens
...
08:00 - 09:00: 3.2M tokens (pico)
09:00 - 10:00: 2.8M tokens
...
23:00 - 00:00: 0.8M tokens
Horas pico: 08:00 - 12:00 (codificación matutina)
Horas bajas: 00:00 - 06:00 (noche)
```
### Uso por combo
```
Dashboard → Analytics → Combos
premium-coding:
Solicitudes: 456
Tokens: 12M
Costo: $2.40
Desglose:
cc/claude-opus: 8M tokens (67%, $0)
glm/glm-4.7: 3M tokens (25%, $1.80)
minimax/MiniMax-M2.1: 1M tokens (8%, $0.20)
budget-combo:
Solicitudes: 234
Tokens: 6M
Costo: $1.20
Desglose:
glm/glm-4.7: 4M tokens (67%, $2.40)
if/kimi-k2-thinking: 2M tokens (33%, $0)
```
---
## Alertas y notificaciones
### Alertas de cuota
```
Dashboard → Settings → Alerts
Advertencias de cuota:
✅ Alerta al 80% de cuota usada
✅ Alerta al 90% de cuota usada
✅ Alerta cuando la cuota se agota
✅ Notificar cuando la cuota se reinicia
Entrega:
✅ Notificación del dashboard
✅ Email (opcional)
✅ Webhook (opcional)
```
**Ejemplo de notificaciones:**
```
⚠️ Cuota de Claude Code 80% usada
2.5h restantes (se reinicia en 1h 30m)
⚠️ Cuota de GLM-4.7 90% usada
1M tokens restantes (se reinicia en 5h)
✅ Cuota de Gemini CLI reiniciada
1,000 solicitudes disponibles (límite diario)
```
### Alertas de presupuesto
```
Dashboard → Settings → Budget Alerts
Presupuesto diario: $5
✅ Alerta al 80% ($4)
✅ Alerta al 100% ($5)
✅ Cambio automático al nivel gratis cuando se excede
Presupuesto mensual: $150
✅ Alerta al 50% ($75)
✅ Alerta al 80% ($120)
✅ Alerta al 100% ($150)
```
**Ejemplo de notificaciones:**
```
⚠️ Presupuesto diario 80% usado
$4.00 / $5.00 gastados hoy
⚠️ Presupuesto mensual 50% alcanzado
$75 / $150 gastados este mes
Proyectado: $135 (dentro del presupuesto)
🚨 Presupuesto diario excedido
$5.20 / $5.00 gastados hoy
Cambio automático al nivel gratis
```
### Detección de anomalías de costo
```
Dashboard → Settings → Anomaly Detection
✅ Detectar patrones de gasto inusuales
✅ Alerta en picos de costo (>2× promedio diario)
✅ Advertencia en patrones de agotamiento de cuota
Ejemplo de alerta:
⚠️ Pico de costo detectado
Hoy: $12.50 (2.5× promedio diario)
Razón: Alto uso de GLM-4.7 (20M tokens)
Sugerencia: Verifica si los modelos principales tienen cuota agotada
```
---
## Mejores prácticas
### 1. Monitorea la cuota diariamente
```
Rutina diaria:
1. Revisa el resumen de cuota del dashboard (30 segundos)
2. Revisa los tiempos de reinicio
3. Planifica el uso según la disponibilidad de cuota
```
**Ejemplo:**
```
Revisión matutina:
✅ Claude Code: 5h disponibles (reinicio fresco)
✅ Gemini CLI: 1K solicitudes disponibles
⚠️ GLM-4.7: 2M tokens restantes (se reinicia 10AM)
Acción: Usar Claude Code para el trabajo matutino
```
### 2. Establece límites de presupuesto
```
Dashboard → Settings → Budget:
Diario: $5 (previene gastos excesivos)
Mensual: $150 (alinea con el presupuesto)
```
**Resultado**: Cambio automático al nivel gratis cuando se alcanza el límite.
### 3. Optimiza el uso de combos
```
Dashboard → Analytics → Combos:
Revisa qué modelos se usan más
Ajusta el orden del combo para minimizar costos
```
**Ejemplo:**
```
Actual: cc/claude-opus → glm/glm-4.7
80% vía Claude (bueno)
20% vía GLM ($12/mes)
Optimizado: gc/gemini-3-flash → cc/claude-opus → glm/glm-4.7
50% vía Gemini (gratis)
40% vía Claude (suscripción)
10% vía GLM ($6/mes)
Ahorros: $6/mes
```
### 4. Rastrea los tiempos de reinicio
```
Dashboard → Quota → Reset Schedule:
Claude Code: 5h rolling + Semanal lunes
Gemini CLI: Diario 00:00 UTC + Mensual día 1
GLM-4.7: Diario 10:00 AM hora Beijing
MiniMax: Ventana rolling 5h
```
**Estrategia**: Usa proveedores cuando la cuota esté fresca.
### 5. Revisa los reportes mensuales
```
Dashboard → Analytics → Monthly Report:
Total de tokens: 1.5B
Costo total: $120
Ahorros: 97% vs ChatGPT API
Insights:
- 60% de uso vía suscripciones ($0)
- 30% vía GLM ($90)
- 10% vía nivel gratis ($0)
Optimización:
- Aumentar el uso de Gemini CLI (gratis)
- Reducir el uso de GLM (costoso)
```
---
## Acceso por API
### Obtener estado de cuota
```bash
GET http://localhost:20128/api/quota
Authorization: Bearer your-api-key
Response:
{
"providers": [
{
"id": "cc",
"name": "Claude Code",
"quota": {
"used": 2.5,
"limit": 5,
"unit": "hours",
"percentage": 50
},
"reset": {
"type": "rolling",
"window": "5h",
"nextReset": "2026-02-04T06:45:00Z"
},
"cost": {
"today": 0,
"month": 0,
"currency": "USD"
}
},
{
"id": "glm",
"name": "GLM-4.7",
"quota": {
"used": 7000000,
"limit": 10000000,
"unit": "tokens",
"percentage": 70
},
"reset": {
"type": "daily",
"time": "10:00 AM UTC+8",
"nextReset": "2026-02-04T10:00:00+08:00"
},
"cost": {
"today": 4.20,
"month": 52.00,
"currency": "USD"
}
}
]
}
```
### Obtener estadísticas de uso
```bash
GET http://localhost:20128/api/usage?period=today
Authorization: Bearer your-api-key
Response:
{
"period": "today",
"date": "2026-02-04",
"summary": {
"requests": 1234,
"tokens": 26000000,
"cost": 4.80
},
"byModel": [
{
"model": "cc/claude-opus-4-5",
"requests": 456,
"tokens": 15000000,
"cost": 0
},
{
"model": "glm/glm-4.7",
"requests": 234,
"tokens": 8000000,
"cost": 4.80
}
]
}
```
---
## Solución de problemas
**Problema: La cuota muestra 0% pero las solicitudes fallan**
**Solución:**
1. Verifica la conexión del proveedor (Dashboard → Providers)
2. Verifica que las API keys sean válidas
3. Verifica si el proveedor está caído (página de estado)
4. Intenta reconectar los proveedores OAuth
**Problema: Estimación de costos incorrecta**
**Solución:**
1. Dashboard → Settings → Pricing
2. Verifica que el precio por proveedor coincida con las tarifas actuales
3. Actualiza el precio si el proveedor cambió las tarifas
4. Contacta a soporte si la discrepancia persiste
**Problema: El tiempo de reinicio no se actualiza**
**Solución:**
1. Refresca el dashboard (F5)
2. Verifica que la hora del sistema sea correcta
3. Verifica la configuración de zona horaria
4. Reinicia 9Router si el problema persiste
**Problema: No se reciben alertas**
**Solución:**
1. Dashboard → Settings → Alerts
2. Verifica que la dirección de email sea correcta
3. Revisa la carpeta de spam
4. Prueba la notificación (botón Send Test)
---
## Relacionado
- [Enrutamiento inteligente](./smart-routing.md) - Fallback automático según cuota
- [Combos](./combos.md) - Crea cadenas de fallback personalizadas
@@ -0,0 +1,407 @@
# Enrutamiento inteligente y fallback automático
9Router enruta automáticamente tus solicitudes a través del mejor proveedor disponible usando un sistema de fallback de 3 niveles. Nunca dejes de codificar debido a límites de cuota o rate-limiting.
---
## Cómo funciona
9Router usa enrutamiento inteligente para maximizar tus suscripciones existentes, minimizar costos y garantizar disponibilidad 24/7:
```
Solicitud → 9Router → Verificar Nivel 1 (Suscripción)
↓ cuota agotada
Verificar Nivel 2 (Barato)
↓ límite de presupuesto
Verificar Nivel 3 (Gratis)
Respuesta
```
### Sistema de fallback de 3 niveles
**Nivel 1: SUSCRIPCIÓN (Primario)**
- Claude Code (Pro/Max)
- OpenAI Codex (Plus/Pro)
- Gemini CLI (GRATIS 180K/mes)
- GitHub Copilot
- Antigravity (Google)
**Objetivo**: Maximizar el valor de las suscripciones que ya pagas.
**Nivel 2: BARATO (Respaldo)**
- GLM-4.7 ($0.60/1M entrada)
- MiniMax M2.1 ($0.20/1M entrada)
- Kimi K2 ($9/mes plano)
**Objetivo**: Respaldo ultra-barato cuando se agota la cuota de suscripción (~90% más barato que ChatGPT API).
**Nivel 3: GRATIS (Emergencia)**
- iFlow (8 modelos)
- Qwen (3 modelos)
- Kiro (Claude GRATIS)
**Objetivo**: Fallback de cero costo para codificación ilimitada.
---
## Cambio automático
9Router monitorea la cuota en tiempo real y cambia de proveedor automáticamente:
### Escenario 1: Cuota de suscripción agotada
```
Solicitud del usuario → cc/claude-opus-4-5
↓ cuota agotada (límite de 5 horas alcanzado)
Cambio automático → glm/glm-4.7
↓ cuota diaria agotada
Cambio automático → minimax/MiniMax-M2.1
↓ cuota de 5 horas agotada
Cambio automático → if/kimi-k2-thinking (GRATIS)
Respuesta entregada ✅
```
**Resultado**: Cero tiempo de inactividad, experiencia sin interrupciones.
### Escenario 2: Rate limiting
```
Solicitud del usuario → cx/gpt-5.2-codex
↓ rate limited (demasiadas solicitudes)
Cambio automático → glm/glm-4.7
Respuesta entregada ✅
```
### Escenario 3: Proveedor no disponible
```
Solicitud del usuario → cc/claude-opus-4-5
↓ error del proveedor (503)
Cambio automático → siguiente modelo disponible
Respuesta entregada ✅
```
---
## Lógica de selección de modelo
9Router selecciona el mejor modelo según:
1. **Disponibilidad de cuota** - Verifica si el proveedor tiene cuota restante
2. **Nivel de costo** - Prefiere suscripción → barato → gratis
3. **Tiempo de reinicio** - Considera cuándo se reinicia la cuota
4. **Salud del proveedor** - Omite proveedores con errores
### Ejemplo de orden de prioridad
Para una solicitud a `cc/claude-opus-4-5`:
```
1. Verificar cuota de Claude Code
✅ Disponible → Usa cc/claude-opus-4-5
❌ Agotada → Continúa al paso 2
2. Verificar nivel de fallback (si está configurado)
✅ Cuota de GLM disponible → Usa glm/glm-4.7
❌ Agotada → Continúa al paso 3
3. Verificar nivel gratis
✅ iFlow disponible → Usa if/kimi-k2-thinking
❌ Todo agotado → Devuelve error de cuota
```
---
## Opciones de configuración
### Configuración del dashboard
**1. Habilitar/Deshabilitar fallback automático**
```
Dashboard → Settings → Smart Routing
→ Toggle "Auto Fallback" ON/OFF
```
- **ON** (por defecto): Cambio automático de nivel
- **OFF**: Modo estricto, devuelve error si el modelo principal no está disponible
**2. Establecer límites de presupuesto**
```
Dashboard → Settings → Budget Control
→ Límite diario: $5
→ Límite mensual: $50
```
Cuando se alcanza el presupuesto, 9Router cambia automáticamente al nivel gratis.
**3. Configurar el orden de fallback**
```
Dashboard → Settings → Fallback Priority
→ Arrastra para reordenar proveedores dentro de cada nivel
```
Ejemplo de orden personalizado:
```
Nivel 1: Gemini CLI → Claude Code → Codex
Nivel 2: MiniMax → GLM → Kimi
Nivel 3: iFlow → Kiro → Qwen
```
**4. Notificaciones de reinicio de cuota**
```
Dashboard → Settings → Notifications
→ Email cuando se reinicia la cuota
→ Alerta cuando se usa 80% de cuota
```
---
## Ejemplos
### Ejemplo 1: Fallback automático básico
**Configuración:**
```
Model: cc/claude-opus-4-5-20251101
Fallback: Auto (3 niveles por defecto)
```
**Comportamiento:**
```
Mañana (cuota fresca):
Solicitud → cc/claude-opus-4-5 ✅
Tarde (cuota agotada):
Solicitud → glm/glm-4.7 ✅ (cambio automático)
Noche (cuota de GLM agotada):
Solicitud → minimax/MiniMax-M2.1 ✅ (cambio automático)
Madrugada (toda la cuota de pago agotada):
Solicitud → if/kimi-k2-thinking ✅ (nivel gratis)
```
**Costo**: ~$5-10/mes extra (en su mayoría cubierto por la suscripción).
### Ejemplo 2: Enrutamiento consciente del presupuesto
**Configuración:**
```
Dashboard → Settings:
Presupuesto diario: $2
Presupuesto mensual: $20
Fallback: Habilitado
```
**Comportamiento:**
```
Día 1-15 (dentro del presupuesto):
Solicitudes → glm/glm-4.7 (nivel barato)
Costo: $1.50/día
Día 16 (presupuesto alcanzado):
Solicitudes → if/kimi-k2-thinking (nivel gratis)
Costo: $0
Mes siguiente (presupuesto se reinicia):
Solicitudes → glm/glm-4.7 nuevamente
```
**Resultado**: Nunca excede $20/mes, siempre disponible.
### Ejemplo 3: Modo solo suscripción
**Configuración:**
```
Dashboard → Settings:
Fallback automático: OFF
Modo estricto: ON
```
**Comportamiento:**
```
Solicitud → cc/claude-opus-4-5
✅ Cuota disponible → Éxito
❌ Cuota agotada → Devuelve error (sin fallback)
```
**Caso de uso**: Cuando solo quieres usar suscripciones de pago, sin costos extras.
### Ejemplo 4: Modo solo gratis
**Configuración:**
```
Model: if/kimi-k2-thinking
Fallback: qw/qwen3-coder-plus → kr/claude-sonnet-4.5
```
**Comportamiento:**
```
Todas las solicitudes → Solo nivel gratis
Costo: $0 para siempre
```
**Caso de uso**: Proyectos personales, aprendizaje, experimentación.
---
## Mejores prácticas
### 1. Maximiza el valor de la suscripción
```
Estrategia:
- Establece modelos de suscripción como Nivel 1
- Monitorea el uso de cuota en el dashboard
- Usa el nivel barato solo cuando la suscripción se agote
```
**Ejemplo de combo:**
```
cc/claude-opus-4-5 → glm/glm-4.7 → if/kimi-k2-thinking
```
### 2. Optimiza por costo
```
Estrategia:
- Usa el nivel gratis de Gemini CLI primero (180K/mes)
- Fallback a GLM/MiniMax (ultra-baratos)
- Emergencia: iFlow (gratis)
```
**Ejemplo de combo:**
```
gc/gemini-3-flash-preview → glm/glm-4.7 → if/kimi-k2-thinking
```
### 3. Optimiza por calidad
```
Estrategia:
- Usa los mejores modelos (Claude Opus, GPT-5.2)
- Fallback a modelos baratos buenos (GLM-4.7)
- Último recurso: Nivel gratis
```
**Ejemplo de combo:**
```
cc/claude-opus-4-5 → cx/gpt-5.2-codex → glm/glm-4.7
```
### 4. Disponibilidad 24/7
```
Estrategia:
- Siempre incluye el nivel gratis en el fallback
- Monitorea los tiempos de reinicio de cuota
- Distribuye el uso entre proveedores
```
**Ejemplo de combo:**
```
cc/claude-opus-4-5 → glm/glm-4.7 → minimax/MiniMax-M2.1 → if/kimi-k2-thinking
```
**Resultado**: Nunca te quedas sin cuota, codifica en cualquier momento.
---
## Estrategia de reinicio de cuota
Planifica tu uso según los tiempos de reinicio de cuota:
| Proveedor | Reinicio de cuota | Estrategia |
|----------|-------------|----------|
| **Claude Code** | 5 horas + semanal | Usar en la mañana, cuota fresca |
| **Codex** | 5 horas + semanal | Usar después de cuota de Claude |
| **Gemini CLI** | Diario (1K) + Mensual (180K) | Usar durante el día |
| **GLM-4.7** | Diario 10:00 AM | Usar en la noche, se reinicia al día siguiente |
| **MiniMax M2.1** | Rolling 5 horas | Usar cuando sea, rastrea ventana rolling |
| **iFlow/Qwen/Kiro** | Sin límite | Respaldo de emergencia |
**Ejemplo de rutina diaria:**
```
08:00 - 13:00: Claude Code (cuota fresca 5h)
13:00 - 18:00: Gemini CLI (cuota 1K/día)
18:00 - 22:00: GLM-4.7 (barato, se reinicia 10AM)
22:00 - 08:00: MiniMax o iFlow (rolling 5h o gratis)
```
---
## Monitoreo y alertas
### Rastreador de cuota del dashboard
```
Dashboard → Quota Overview:
Claude Code: 2.5h / 5h restantes (50%)
Gemini CLI: 450 / 1000 solicitudes hoy
GLM-4.7: 5M / 10M tokens (se reinicia en 8h)
MiniMax: 3M / 5M tokens (rolling 5h)
```
### Notificaciones en tiempo real
```
Dashboard → Notifications:
⚠️ Cuota de Claude Code 80% usada (1h restante)
✅ Cuota de GLM-4.7 reiniciada (10M tokens disponibles)
💰 Presupuesto diario 50% usado ($2.50 / $5)
```
### Analítica de uso
```
Dashboard → Analytics:
Hoy: 50M tokens
- 30M vía Claude Code (suscripción)
- 15M vía GLM-4.7 ($9)
- 5M vía iFlow (gratis)
Costo: $9 (vs $1000 en ChatGPT API)
Ahorros: 99%
```
---
## Solución de problemas
**Problema: "All providers quota exhausted"**
**Solución:**
1. Verifica el rastreador de cuota del dashboard
2. Espera el reinicio de cuota (mira la cuenta regresiva)
3. Agrega el nivel gratis a la cadena de fallback
4. O aumenta el límite de presupuesto
**Problema: "Demasiados cambios de fallback"**
**Solución:**
1. Verifica si el proveedor principal está caído
2. Aumenta los límites de cuota (mejora la suscripción)
3. Usa un modelo principal más barato (GLM en lugar de Claude)
**Problema: "Costos inesperados"**
**Solución:**
1. Dashboard → Analytics → Revisa el uso
2. Establece límites de presupuesto diarios/mensuales
3. Cambia al nivel gratis para tareas no críticas
4. Usa combos con fallback gratis
---
## Relacionado
- [Combos](./combos.md) - Crea cadenas de fallback personalizadas
- [Seguimiento de cuota](./quota-tracking.md) - Monitorea uso y costos
@@ -0,0 +1,478 @@
# Instalación
Guía detallada de instalación de 9Router con consejos de solución de problemas.
---
## Requisitos
### Requisitos del sistema
- **Node.js**: Versión 20.0.0 o superior
- **npm**: Versión 10.0.0 o superior (viene con Node.js)
- **OS**: macOS, Linux, Windows (WSL recomendado)
- **Espacio en disco**: ~200MB para la instalación
### Verifica tu versión
```bash
node --version
# Debería mostrar v20.x.x o superior
npm --version
# Debería mostrar 10.x.x o superior
```
**¿No tienes Node.js?** Instálalo desde [nodejs.org](https://nodejs.org/)
---
## Métodos de instalación
### Método 1: Instalación global (Recomendado)
Instala 9Router globalmente para usar desde cualquier lugar:
```bash
npm install -g 9router
```
**Iniciar 9Router:**
```bash
9router
```
**Beneficios:**
- ✅ Ejecuta desde cualquier directorio
- ✅ Comando simple: `9router`
- ✅ Auto-actualizaciones con `npm update -g 9router`
### Método 2: Instalación local
Instala en un proyecto específico:
```bash
mkdir my-9router
cd my-9router
npm install 9router
```
**Iniciar 9Router:**
```bash
npx 9router
```
**Beneficios:**
- ✅ Aislado por proyecto
- ✅ Control de versiones por proyecto
- ✅ Sin contaminación del namespace global
### Método 3: Desde el código fuente (Desarrollo)
Clona y compila desde GitHub:
```bash
git clone https://github.com/decolua/9router.git
cd 9router/app
npm install
npm run build
npm start
```
**Beneficios:**
- ✅ Últimas características de desarrollo
- ✅ Contribuir al desarrollo
- ✅ Modificaciones personalizadas
---
## Primera ejecución
### Iniciar el servidor
```bash
9router
```
**Qué sucede:**
1. El servidor inicia en `http://localhost:20128`
2. El dashboard se abre automáticamente en el navegador
3. Se crea el directorio de datos en `~/.9router`
4. API key generada automáticamente
### Login del dashboard
**Credenciales por defecto:**
- Contraseña: `123456`
**⚠️ Cambia la contraseña inmediatamente:**
1. Inicia sesión en el dashboard
2. Settings → Change Password
3. Usa una contraseña fuerte
### Obtén tu API key
```
Dashboard → Settings → API Keys
→ Copia tu API key
→ Úsala en herramientas CLI
```
**Ejemplo de formato de API key:**
```
9r_1234567890abcdef1234567890abcdef
```
---
## Verificar la instalación
### Verifica el estado del servidor
```bash
curl http://localhost:20128/health
```
**Respuesta esperada:**
```json
{
"status": "ok",
"version": "1.0.0"
}
```
### Lista los modelos disponibles
```bash
curl http://localhost:20128/v1/models \
-H "Authorization: Bearer your-api-key"
```
**Respuesta esperada:**
```json
{
"object": "list",
"data": [
{
"id": "cc/claude-opus-4-5-20251101",
"object": "model",
"created": 1234567890,
"owned_by": "claude-code"
}
]
}
```
### Prueba el chat completion
```bash
curl http://localhost:20128/v1/chat/completions \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"model": "cc/claude-opus-4-5-20251101",
"messages": [
{"role": "user", "content": "Hello!"}
]
}'
```
---
## Configuración
### Variables de entorno
Crea un archivo `.env` o establece variables de entorno:
```bash
# Security (REQUIRED in production)
export JWT_SECRET="your-secure-secret-change-this"
export INITIAL_PASSWORD="your-password"
# Storage
export DATA_DIR="~/.9router"
# Server
export PORT="20128"
export NODE_ENV="production"
# Logging
export ENABLE_REQUEST_LOGS="false"
```
### Directorio de datos
**Ubicación por defecto:** `~/.9router`
**Contenido:**
```
~/.9router/
├── db.json # Database (providers, combos, usage)
├── api-keys.json # API keys
└── logs/ # Request logs (if enabled)
```
**Cambiar ubicación:**
```bash
export DATA_DIR="/custom/path"
9router
```
### Configuración de puerto
**Puerto por defecto:** `20128`
**Cambiar puerto:**
```bash
export PORT="3000"
9router
```
**O usa la línea de comandos:**
```bash
9router --port 3000
```
---
## Solución de problemas
### Puerto ya en uso
**Error:**
```
Error: listen EADDRINUSE: address already in use :::20128
```
**Solución 1: Mata el proceso existente**
```bash
# Encuentra proceso usando el puerto 20128
lsof -i :20128
# Mata el proceso
kill -9 <PID>
```
**Solución 2: Usa otro puerto**
```bash
9router --port 3000
```
### Permiso denegado
**Error:**
```
Error: EACCES: permission denied, mkdir '/usr/local/lib/node_modules/9router'
```
**Solución: Usa sudo (no recomendado) o corrige los permisos de npm**
```bash
# Corregir permisos de npm (recomendado)
mkdir ~/.npm-global
npm config set prefix '~/.npm-global'
echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.bashrc
source ~/.bashrc
# Luego instalar nuevamente
npm install -g 9router
```
### Versión de Node.js muy antigua
**Error:**
```
Error: The engine "node" is incompatible with this module
```
**Solución: Actualizar Node.js**
```bash
# Usando nvm (recomendado)
nvm install 20
nvm use 20
# O descargar desde nodejs.org
```
### El dashboard no se abre
**Problema:** El dashboard no se abre automáticamente
**Solución 1: Abrir manualmente**
```
http://localhost:20128
```
**Solución 2: Verifica el firewall**
```bash
# macOS: Permitir Node.js en System Preferences → Security
# Linux: Verificar iptables
# Windows: Verificar Windows Firewall
```
### No se puede conectar a proveedores
**Problema:** El login OAuth falla o la API key es inválida
**Solución 1: Verifica la conexión a internet**
```bash
ping google.com
```
**Solución 2: Verifica el estado del proveedor**
- Claude Code: [status.anthropic.com](https://status.anthropic.com)
- OpenAI: [status.openai.com](https://status.openai.com)
- Gemini: [status.cloud.google.com](https://status.cloud.google.com)
**Solución 3: Regenera la API key**
```
Dashboard → Provider → Disconnect → Reconnect
```
### Uso alto de memoria
**Problema:** 9Router usa demasiada RAM
**Solución: Reinicia el servidor**
```bash
# Detener
pkill -f 9router
# Iniciar
9router
```
**O usa PM2 para auto-reinicio:**
```bash
npm install -g pm2
pm2 start 9router --name 9router
pm2 save
```
---
## Opciones de despliegue
### Desarrollo local
```bash
npm install -g 9router
9router
```
**Caso de uso:** Codificación personal, pruebas
### Servidor VPS/Cloud
```bash
# Instalar
npm install -g 9router
# Configurar
export JWT_SECRET="your-secure-secret"
export INITIAL_PASSWORD="your-password"
export NODE_ENV="production"
# Iniciar con PM2
npm install -g pm2
pm2 start 9router --name 9router
pm2 save
pm2 startup
```
**Caso de uso:** Acceso de equipo, codificación remota
### Docker
```bash
docker pull 9router/9router:latest
docker run -d \
-p 20128:20128 \
-e JWT_SECRET="your-secure-secret" \
-e INITIAL_PASSWORD="your-password" \
-v 9router-data:/root/.9router \
--name 9router \
9router/9router:latest
```
**Caso de uso:** Despliegue containerizado, Kubernetes
### Proxy reverso (Nginx)
```nginx
server {
listen 80;
server_name your-domain.com;
location / {
proxy_pass http://localhost:20128;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
# SSE support for streaming
proxy_buffering off;
proxy_read_timeout 86400;
}
}
```
**Caso de uso:** HTTPS, dominio personalizado, balanceo de carga
---
## Desinstalación
### Eliminar instalación global
```bash
npm uninstall -g 9router
```
### Eliminar el directorio de datos
```bash
rm -rf ~/.9router
```
### Eliminar la configuración
```bash
# Eliminar variables de entorno del archivo de configuración del shell
nano ~/.bashrc # o ~/.zshrc
# Eliminar exports relacionados con 9router
```
---
## Próximos pasos
- [Guía para empezar](../getting-started.md) - Conecta proveedores y comienza a codificar
- [Características](../features/) - Explora seguimiento de cuota, combos, despliegue
- [Solución de problemas](../troubleshooting.md) - Resuelve problemas comunes
---
## ¿Necesitas ayuda?
- **Sitio web**: [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)
@@ -0,0 +1,247 @@
# Empezar
Pon en marcha 9Router en 5 minutos y comienza a enrutar solicitudes de IA de forma inteligente.
---
## Inicio rápido
### 1. Instalar
```bash
npm install -g 9router
```
**Requisitos:** Node.js 20+ ([Detalles de instalación](getting-started/installation.md))
### 2. Iniciar
```bash
9router
```
🎉 **El dashboard se abre automáticamente** en `http://localhost:20128`
- Contraseña por defecto: `123456` (cámbiala en el dashboard)
- API key generada automáticamente
- Listo para conectar proveedores
### 3. Conectar proveedores
Tienes 3 formas de conectar proveedores:
#### Opción A: OAuth (Proveedores de suscripción)
**Ideal para:** Claude Code, Codex, Gemini CLI, GitHub Copilot
```
Dashboard → Providers → Connect [Provider]
→ Login OAuth → Refresh automático de token
→ Seguimiento de cuota habilitado
```
**Ejemplo: Claude Code**
1. Clic en "Connect Claude Code"
2. Inicia sesión con tu cuenta de Claude
3. Autoriza 9Router
4. ✅ ¡Listo! Usa el modelo: `cc/claude-opus-4-5-20251101`
#### Opción B: API Key (Proveedores baratos)
**Ideal para:** GLM, MiniMax, Kimi, OpenRouter
```
Dashboard → Providers → Add API Key
→ Selecciona proveedor
→ Pega API key
→ Guardar
```
**Ejemplo: GLM-4.7**
1. Regístrate en [Zhipu AI](https://open.bigmodel.cn/)
2. Obtén la API key del Coding Plan
3. Dashboard → Add API Key → Provider: `glm` → Pega la key
4. ✅ ¡Listo! Usa el modelo: `glm/glm-4.7`
#### Opción C: Proveedores gratis (Sin costo)
**Ideal para:** iFlow, Qwen, Kiro
```
Dashboard → Providers → Connect [Free Provider]
→ Device code u OAuth
→ Uso ilimitado
```
**Ejemplo: iFlow**
1. Clic en "Connect iFlow"
2. Inicia sesión con tu cuenta de iFlow
3. Autoriza
4. ✅ ¡Listo! Usa 8 modelos: `if/kimi-k2-thinking`, `if/qwen3-coder-plus`, etc.
---
## 4. Usar en herramientas CLI
Apunta tu herramienta de codificación a 9Router:
### Cursor IDE
```
Settings → Models → Advanced:
OpenAI API Base URL: http://localhost:20128/v1
OpenAI API Key: [desde el dashboard de 9router]
Model: cc/claude-opus-4-5-20251101
```
### Claude Desktop
Edita `~/.claude/config.json`:
```json
{
"anthropic_api_base": "http://localhost:20128/v1",
"anthropic_api_key": "your-9router-api-key"
}
```
### Cline / Continue / RooCode
```
Provider: OpenAI Compatible
Base URL: http://localhost:20128/v1
API Key: [desde el dashboard]
Model: cc/claude-opus-4-5-20251101
```
### Codex CLI
```bash
export OPENAI_BASE_URL="http://localhost:20128"
export OPENAI_API_KEY="your-9router-api-key"
codex "your prompt"
```
---
## 5. Crear combos inteligentes (Opcional)
Los combos habilitan el fallback automático entre modelos:
```
Dashboard → Combos → Create New
Name: premium-coding
Models:
1. cc/claude-opus-4-5-20251101 (Suscripción principal)
2. glm/glm-4.7 (Respaldo barato, $0.6/1M)
3. if/kimi-k2-thinking (Fallback gratis)
Usar en CLI: premium-coding
```
**Cómo funciona:**
1. Intenta primero Claude Opus (tu suscripción)
2. Si la cuota se agota → GLM-4.7 (ultra-barato)
3. Si llega al límite de presupuesto → iFlow (gratis)
4. ¡Cero tiempo de inactividad, cambio automático!
---
## Modelos disponibles
### Modelos de suscripción (Maximiza primero)
**Claude Code (`cc/`)** - Suscripción Pro/Max:
- `cc/claude-opus-4-5-20251101` - Claude 4.5 Opus
- `cc/claude-sonnet-4-5-20250929` - Claude 4.5 Sonnet
- `cc/claude-haiku-4-5-20251001` - Claude 4.5 Haiku
**Codex (`cx/`)** - Suscripción Plus/Pro:
- `cx/gpt-5.2-codex` - GPT 5.2 Codex
- `cx/gpt-5.1-codex-max` - GPT 5.1 Codex Max
**Gemini CLI (`gc/`)** - GRATIS 180K/mes:
- `gc/gemini-3-flash-preview` - Gemini 3 Flash Preview
- `gc/gemini-2.5-pro` - Gemini 2.5 Pro
**GitHub Copilot (`gh/`)** - Suscripción:
- `gh/gpt-5` - GPT-5
- `gh/claude-4.5-sonnet` - Claude 4.5 Sonnet
### Modelos baratos (Respaldo)
**GLM (`glm/`)** - $0.6/$2.2 por 1M:
- `glm/glm-4.7` - GLM 4.7 (reinicio diario 10AM)
**MiniMax (`minimax/`)** - $0.20/$1.00 por 1M:
- `minimax/MiniMax-M2.1` - MiniMax M2.1 (reinicio 5h)
**Kimi (`kimi/`)** - $9/mes (10M tokens):
- `kimi/kimi-latest` - Kimi Latest
### Modelos GRATIS (Emergencia)
**iFlow (`if/`)** - 8 modelos GRATIS:
- `if/kimi-k2-thinking` - Kimi K2 Thinking
- `if/qwen3-coder-plus` - Qwen3 Coder Plus
- `if/glm-4.7` - GLM 4.7
- `if/deepseek-r1` - DeepSeek R1
**Qwen (`qw/`)** - 3 modelos GRATIS:
- `qw/qwen3-coder-plus` - Qwen3 Coder Plus
- `qw/qwen3-coder-flash` - Qwen3 Coder Flash
**Kiro (`kr/`)** - 2 modelos GRATIS:
- `kr/claude-sonnet-4.5` - Claude Sonnet 4.5
- `kr/claude-haiku-4.5` - Claude Haiku 4.5
---
## Estrategia de optimización de costos
### Presupuesto mensual: $10-20/mes
```
1. Usa el nivel gratis de Gemini CLI (180K/mes) para tareas rápidas
2. Usa la cuota de suscripción de Claude Code al máximo (ya pagas)
3. Fallback a GLM ($0.6/1M) cuando se agote la cuota
4. Emergencia: MiniMax M2.1 ($0.20/1M) o iFlow (gratis)
Ejemplo real (100M tokens/mes):
60M vía Gemini CLI: $0 (nivel gratis)
30M vía Claude Code: $0 (suscripción que ya tienes)
8M vía GLM: $4.80
2M vía MiniMax: $0.40
Total: $5.20/mes + suscripciones existentes
```
### Estrategia de reinicio de cuota
```
Rutina diaria:
1. Mañana: Cuota fresca de Claude Code (reinicio 5h)
2. Tarde: Cambia a Gemini CLI (1K/día)
3. Noche: Cuota diaria de GLM (reinicio 10AM del día siguiente)
4. Madrugada: MiniMax (rolling 5h) o iFlow (gratis)
→ ¡Codifica 24/7 con costo extra mínimo!
```
---
## Próximos pasos
- [Detalles de instalación](getting-started/installation.md) - Requisitos, troubleshooting
- [Características](features/) - Explora seguimiento de cuota, combos, despliegue
- [FAQ](faq.md) - Preguntas y respuestas comunes
- [Troubleshooting](troubleshooting.md) - Soluciona problemas comunes
---
## ¿Necesitas ayuda?
- **Sitio web**: [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)
+164
View File
@@ -0,0 +1,164 @@
# Bienvenido a 9Router
**Usa Claude, Codex, Gemini GRATIS • Alternativas ultra-baratas desde $0.20/1M tokens**
9Router es un router de modelos de IA que maximiza el valor de tus suscripciones y minimiza los costos mediante enrutamiento inteligente y fallback automático.
---
## ¿Qué es 9Router?
9Router es un proxy inteligente que se sitúa entre tus herramientas de codificación (Cursor, Cline, Claude Desktop) y los proveedores de IA. Enruta automáticamente las solicitudes al mejor modelo disponible según la cuota, el costo y la disponibilidad.
**Deja de desperdiciar dinero:**
- ❌ La cuota de suscripción expira sin usar cada mes
- ❌ Los límites de tasa te detienen a mitad de la codificación
- ❌ APIs costosas ($20-50/mes por proveedor)
- ❌ Cambio manual entre proveedores
**Empieza a maximizar el valor:**
-**Maximiza tus suscripciones** - Rastrea y usa cada bit de cuota de Claude Code, Codex, Gemini
-**GRATIS disponible** - Accede a modelos iFlow, Qwen, Kiro vía CLI
-**Respaldo ultra-barato** - GLM ($0.6/1M), MiniMax M2.1 ($0.20/1M)
-**Fallback inteligente** - Suscripción → Barato → Gratis, cambio automático
---
## Características clave
### 🔄 Fallback inteligente de 3 niveles
```
Configura una vez, nunca dejes de codificar:
Nivel 1 (SUSCRIPCIÓN): Claude Code → Codex → Gemini
↓ cuota agotada
Nivel 2 (BARATO): GLM-4.7 → MiniMax M2.1 → Kimi
↓ límite de presupuesto
Nivel 3 (GRATIS): iFlow → Qwen → Kiro
→ Cambio automático, sin tiempo de inactividad!
```
### 📊 Seguimiento de cuota
- Consumo de tokens en tiempo real por proveedor
- Cuenta regresiva de reinicio (5 horas, diario, semanal, mensual)
- Estimación de costos para niveles de pago
- Reportes de gasto mensual
### 🎯 Soporte universal de CLI
Funciona con cualquier herramienta que soporte endpoints personalizados de OpenAI:
**Cursor****Cline****Claude Desktop****Codex****RooCode****Continue****Cualquier herramienta compatible con OpenAI**
### 💰 Optimización de costos
**Ejemplo real (100M tokens/mes):**
```
60M vía Gemini CLI: $0 (nivel gratis)
30M vía Claude Code: $0 (suscripción que ya tienes)
8M vía GLM: $4.80
2M vía MiniMax: $0.40
Total: $5.20/mes vs $2000 en ChatGPT API!
```
---
## ¿Por qué elegir 9Router?
### Maximiza tus suscripciones
¿Ya pagas Claude Code ($20-100/mes) o Codex ($20-200/mes)? Obtén el valor completo:
- Rastrea el uso de cuota en tiempo real
- Cambio automático cuando se reinicia la cuota (5 horas, semanal)
- Usa cada token antes de que expire
- Gemini CLI: 180K completados/mes **GRATIS**
### Respaldo ultra-barato
Cuando se agota la cuota de suscripción, paga centavos:
| Proveedor | Costo por 1M tokens | Reinicio |
|----------|-------------------|-------|
| **GLM-4.7** | $0.60 entrada / $2.20 salida | Diario 10:00 AM |
| **MiniMax M2.1** | $0.20 entrada / $1.00 salida | 5 horas rolling |
| **Kimi K2** | $9/mes (10M tokens) | Mensual |
**~90% más barato que ChatGPT API ($20/1M)!**
### Fallback gratis para siempre
Respaldo de emergencia cuando todo lo demás está limitado por cuota:
- **iFlow**: 8 modelos (Kimi K2, Qwen3 Coder Plus, GLM 4.7, MiniMax M2)
- **Qwen**: 3 modelos (Qwen3 Coder Plus/Flash, Vision)
- **Kiro**: Claude Sonnet 4.5, Haiku 4.5 (AWS Builder ID)
---
## Inicio rápido
Comienza en 2 minutos:
```bash
# Instala globalmente
npm install -g 9router
# Inicia (el dashboard se abre automáticamente)
9router
```
🎉 **Se abre el dashboard** → Conecta proveedores → ¡Empieza a codificar!
**Úsalo en tu herramienta CLI:**
```
Endpoint: http://localhost:20128/v1
API Key: [desde el dashboard]
Model: cc/claude-opus-4-5-20251101
```
[→ Guía completa para empezar](getting-started.md)
---
## Casos de uso
### Para desarrolladores individuales
- Maximiza tu suscripción de Claude Code/Codex
- Usa el nivel gratis de Gemini CLI (180K/mes)
- Fallback a modelos ultra-baratos ($0.20/1M)
- Codifica 24/7 sin límites de tasa
### Para equipos
- Despliega en VPS/Cloud para acceso compartido
- Rastrea el gasto del equipo en tiempo real
- Establece límites de presupuesto por nivel
- Gestión centralizada de proveedores
### Para codificación móvil/remota
- Usa el despliegue en la nube (https://9router.com)
- Accede desde iPad, teléfono, donde sea
- Sin limitaciones de localhost
- Red edge de Cloudflare (300+ ubicaciones)
---
## ¿Qué sigue?
- [Empezar](getting-started.md) - Instala y configura en 5 minutos
- [Guía de instalación](getting-started/installation.md) - Instrucciones detalladas
- [Características](features/) - Explora todas las capacidades
- [FAQ](faq.md) - Preguntas comunes
---
<div align="center">
<sub>Construido con ❤️ para desarrolladores que maximizan el valor de la IA</sub>
</div>
@@ -0,0 +1,109 @@
# Integración con Claude Code
Integra 9Router con Claude Code CLI para enrutar tus solicitudes de la API de Anthropic a través del sistema de enrutamiento inteligente de 9Router.
## Requisitos previos
- Claude Code CLI instalado
- 9Router ejecutándose localmente o endpoint en la nube configurado
- API key del dashboard de 9Router
## Configuración
### 1. Configurar variables de entorno
Establece las siguientes variables de entorno en tu archivo de configuración del shell (`~/.bashrc`, `~/.zshrc`, o `~/.bash_profile`):
```bash
# Base URL for 9Router
export ANTHROPIC_BASE_URL="http://localhost:20128/v1"
# Optional: Set default models for aliases
export ANTHROPIC_DEFAULT_OPUS_MODEL="cc/claude-opus-4-5-20251101"
export ANTHROPIC_DEFAULT_SONNET_MODEL="cc/claude-sonnet-4-5-20250929"
export ANTHROPIC_DEFAULT_HAIKU_MODEL="cc/claude-haiku-4-5-20251001"
```
### 2. Recargar la configuración del shell
```bash
source ~/.zshrc # o ~/.bashrc
```
### 3. Verificar la configuración
Verifica que las variables de entorno estén configuradas correctamente:
```bash
echo $ANTHROPIC_BASE_URL
```
## Aliases de modelos
Claude Code soporta los siguientes aliases de modelos que mapean a modelos de 9Router:
| Alias | Modelo | Variable de entorno |
|-------|-------|---------------------|
| `opus` | Claude Opus 4.5 | `ANTHROPIC_DEFAULT_OPUS_MODEL` |
| `sonnet` | Claude Sonnet 4.5 | `ANTHROPIC_DEFAULT_SONNET_MODEL` |
| `haiku` | Claude Haiku 4.5 | `ANTHROPIC_DEFAULT_HAIKU_MODEL` |
## Ejemplos de uso
### Usando aliases de modelos
```bash
# Usar modelo Opus
claude --model opus "Explain quantum computing"
# Usar modelo Sonnet
claude --model sonnet "Write a Python function"
# Usar modelo Haiku
claude --model haiku "Quick code review"
```
### Usando nombres completos de modelos
```bash
claude --model cc/claude-opus-4-5-20251101 "Your prompt here"
```
## Archivo de configuración
Claude Code almacena su configuración en `~/.claude/settings.json`. Puedes editar este archivo manualmente si es necesario:
```json
{
"baseUrl": "http://localhost:20128/v1",
"defaultModel": "sonnet"
}
```
## Solución de problemas
### Problemas de conexión
Si encuentras errores de conexión:
1. Verifica que 9Router esté corriendo: `curl http://localhost:20128/health`
2. Verifica que las variables de entorno estén configuradas correctamente
3. Asegúrate de que ningún firewall esté bloqueando el puerto 20128
### Modelo no encontrado
Si obtienes errores de "modelo no encontrado":
1. Verifica que el nombre del modelo coincida con tu configuración de 9Router
2. Verifica que la conexión del proveedor esté activa en el dashboard de 9Router
3. Asegúrate de que el modelo esté disponible en tus proveedores conectados
## Endpoint en la nube
Para usar el endpoint en la nube de 9Router en lugar de localhost:
```bash
export ANTHROPIC_BASE_URL="https://9router.com"
```
Asegúrate de haber configurado tu API key en el dashboard en la nube de 9Router.
+201
View File
@@ -0,0 +1,201 @@
# Integración con Cline
Integra 9Router con la extensión Cline de VSCode para enrutar tus solicitudes de IA a través del sistema de enrutamiento inteligente de 9Router.
## Requisitos previos
- Visual Studio Code instalado
- Extensión Cline instalada desde el marketplace de VSCode
- 9Router ejecutándose localmente o endpoint en la nube configurado
- API key del dashboard de 9Router
## Configuración
### 1. Abrir la configuración de Cline
1. Abre Visual Studio Code
2. Abre el panel de la extensión Cline (clic en el ícono de Cline en la barra lateral)
3. Clic en el ícono de **Settings** (engranaje) en el panel de Cline
### 2. Seleccionar el proveedor de API
1. En la configuración de Cline, encuentra el dropdown **API Provider**
2. Selecciona **Ollama** de la lista
- Nota: Usamos el tipo de proveedor Ollama porque es compatible con APIs estilo OpenAI
### 3. Configurar Base URL
Establece la URL base a tu endpoint de 9Router:
**Para 9Router local:**
```
http://localhost:20128/v1
```
**Para 9Router en la nube:**
```
https://9router.com
```
**Pasos:**
1. En el campo **Base URL**, ingresa tu endpoint de 9Router
2. Asegúrate de incluir `/v1` al final
### 4. Agregar API Key
1. En el campo **API Key**, ingresa tu API key de 9Router
2. Puedes encontrar tu API key en el dashboard de 9Router en **Settings → API Keys**
3. La key debe comenzar con `sk-9router-`
### 5. Seleccionar modelo
1. En el dropdown **Model**, puedes:
- Seleccionar de los modelos disponibles (si Cline los auto-detecta)
- Ingresar manualmente el nombre del modelo desde tu configuración de 9Router
2. Nombres comunes de modelos:
- `gpt-4`
- `gpt-4o`
- `claude-opus-4-5`
- `claude-sonnet-4-5`
- `gemini-2.0-flash`
### 6. Guardar la configuración
Clic en **Save** o cierra el panel de configuración. Cline guardará automáticamente tu configuración.
## Ejemplo de configuración
Tu configuración de Cline debería verse así:
```
API Provider: Ollama
Base URL: http://localhost:20128/v1
API Key: sk-9router-xxxxxxxxxxxxx
Model: gpt-4
```
## Modelos disponibles
Puedes usar cualquier modelo configurado en tu dashboard de 9Router. Ejemplos comunes:
| Nombre del modelo | Proveedor | Descripción |
|------------|----------|-------------|
| `gpt-4` | OpenAI | GPT-4 Turbo |
| `gpt-4o` | OpenAI | GPT-4 Optimized |
| `claude-opus-4-5` | Anthropic | Claude Opus 4.5 |
| `claude-sonnet-4-5` | Anthropic | Claude Sonnet 4.5 |
| `gemini-2.0-flash` | Google | Gemini 2.0 Flash |
## Uso
### Chat con IA
1. Abre el panel de Cline en VSCode
2. Escribe tu mensaje en el input del chat
3. Presiona Enter para enviar
4. Cline usará 9Router para procesar tu solicitud
### Generación de código
1. Pide a Cline que genere código: "Create a React component for a login form"
2. Cline generará código usando 9Router
3. Revisa y acepta el código generado
### Explicación de código
1. Selecciona código en tu editor
2. Pregunta a Cline: "Explain this code"
3. Obtén explicaciones potenciadas por IA a través de 9Router
### Operaciones con archivos
1. Pide a Cline que cree, modifique o elimine archivos
2. Cline usará 9Router para entender el contexto y hacer cambios
3. Revisa los cambios antes de aceptar
## Solución de problemas
### Error "Connection Failed"
1. Verifica que 9Router esté corriendo: `curl http://localhost:20128/health`
2. Verifica que la URL base sea correcta e incluya `/v1`
3. Asegúrate de que ningún firewall esté bloqueando el puerto 20128
4. Intenta reiniciar VSCode
### Error "Invalid API Key"
1. Verifica tu API key en el dashboard de 9Router
2. Asegúrate de haber copiado la key completa incluyendo el prefijo `sk-9router-`
3. Verifica que la API key no haya expirado
4. Intenta regenerar una nueva API key
### Error "Model Not Found"
1. Verifica que el nombre del modelo coincida exactamente con tu configuración de 9Router
2. Verifica que la conexión del proveedor esté activa en el dashboard de 9Router
3. Asegúrate de que el modelo esté disponible en tus proveedores conectados
4. Intenta usar el nombre completo del modelo (ej. `openai/gpt-4` en lugar de `gpt-4`)
### Cline no responde
1. Revisa el panel de output de Cline para mensajes de error
2. Verifica que tu instancia de 9Router esté ejecutándose y saludable
3. Intenta recargar la ventana de VSCode (Cmd/Ctrl + Shift + P → "Reload Window")
4. Revisa los logs de 9Router para cualquier error
## Configuración avanzada
### Usar endpoint en la nube
Para usar el endpoint en la nube de 9Router en lugar de localhost:
1. En la configuración de Cline, establece Base URL a: `https://9router.com`
2. Asegúrate de haber configurado tu API key en el dashboard en la nube de 9Router
3. Asegúrate de que tu endpoint en la nube esté activo y accesible
### Múltiples modelos
Puedes cambiar rápidamente entre modelos:
1. Abre la configuración de Cline
2. Cambia el campo **Model** a otro modelo
3. Guarda y continúa chateando con el nuevo modelo
### Timeout personalizado
Si experimentas problemas de timeout con solicitudes grandes:
1. Abre la configuración de VSCode (Cmd/Ctrl + ,)
2. Busca "Cline timeout"
3. Aumenta el valor de timeout (por defecto suele ser 30 segundos)
## Mejores prácticas
1. **Usa modelos apropiados**: Elige modelos rápidos (como Haiku o Flash) para tareas simples, y modelos más potentes (como Opus o GPT-4) para tareas complejas
2. **Monitorea el uso**: Revisa el dashboard de 9Router para estadísticas de uso y costos
3. **Gestión de contexto**: Mantén tus conversaciones enfocadas para reducir el uso de tokens
4. **Cambio de modelo**: Cambia modelos según la complejidad de la tarea para optimizar costo y rendimiento
5. **Seguridad de API Key**: Nunca subas tu API key al control de versiones
## Integración con características de 9Router
### Enrutamiento de modelos
9Router enruta automáticamente tus solicitudes al mejor proveedor disponible según:
- Disponibilidad del modelo
- Estado de salud del proveedor
- Optimización de costos
- Balanceo de carga
### Soporte de fallback
Si un proveedor falla, 9Router automáticamente cambia a proveedores alternativos configurados en tu dashboard.
### Seguimiento de uso
Monitorea tu uso de Cline a través del dashboard de 9Router:
- Total de solicitudes
- Uso de tokens
- Costo por modelo
- Distribución por proveedor
+136
View File
@@ -0,0 +1,136 @@
# Integración con OpenAI Codex CLI
Integra 9Router con OpenAI Codex CLI para enrutar tus solicitudes de la API de OpenAI a través del sistema de enrutamiento inteligente de 9Router.
## Requisitos previos
- OpenAI Codex CLI instalado
- 9Router ejecutándose localmente o endpoint en la nube configurado
- API key del dashboard de 9Router
## Configuración
### 1. Configurar variables de entorno
Establece las siguientes variables de entorno en tu archivo de configuración del shell (`~/.bashrc`, `~/.zshrc`, o `~/.bash_profile`):
```bash
# Base URL for 9Router
export OPENAI_BASE_URL="http://localhost:20128/v1"
# API Key from 9Router dashboard
export OPENAI_API_KEY="your-9router-api-key"
```
### 2. Recargar la configuración del shell
```bash
source ~/.zshrc # o ~/.bashrc
```
### 3. Verificar la configuración
Verifica que las variables de entorno estén configuradas correctamente:
```bash
echo $OPENAI_BASE_URL
echo $OPENAI_API_KEY
```
## Modelos disponibles
9Router proporciona los siguientes modelos de Codex:
| ID del modelo | Descripción |
|----------|-------------|
| `cx/gpt-5.2-codex` | GPT-5.2 Codex - Última versión |
| `cx/gpt-5.1-codex-max` | GPT-5.1 Codex Max - Contexto extendido |
## Ejemplos de uso
### Uso básico
```bash
# Usar GPT-5.2 Codex
codex --model cx/gpt-5.2-codex "Write a function to sort an array"
# Usar GPT-5.1 Codex Max
codex --model cx/gpt-5.1-codex-max "Explain this complex algorithm"
```
### Generación de código
```bash
codex --model cx/gpt-5.2-codex "Create a REST API endpoint for user authentication"
```
### Explicación de código
```bash
codex --model cx/gpt-5.1-codex-max "Explain what this code does: $(cat myfile.js)"
```
## Archivo de configuración
También puedes configurar Codex CLI usando un archivo de configuración. Crea o edita `~/.codex/config.json`:
```json
{
"baseUrl": "http://localhost:20128/v1",
"apiKey": "your-9router-api-key",
"defaultModel": "cx/gpt-5.2-codex"
}
```
## Solución de problemas
### Errores de autenticación
Si encuentras errores de autenticación:
1. Verifica que tu API key sea correcta en el dashboard de 9Router
2. Verifica que la variable de entorno `OPENAI_API_KEY` esté configurada
3. Asegúrate de que la API key no haya expirado
### Problemas de conexión
Si encuentras errores de conexión:
1. Verifica que 9Router esté corriendo: `curl http://localhost:20128/health`
2. Verifica que las variables de entorno estén configuradas correctamente
3. Asegúrate de que ningún firewall esté bloqueando el puerto 20128
### Modelo no disponible
Si obtienes errores de "modelo no disponible":
1. Verifica que el nombre del modelo coincida con tu configuración de 9Router
2. Verifica que la conexión del proveedor de OpenAI esté activa en el dashboard de 9Router
3. Asegúrate de que el modelo esté disponible en tus proveedores conectados
## Endpoint en la nube
Para usar el endpoint en la nube de 9Router en lugar de localhost:
```bash
export OPENAI_BASE_URL="https://9router.com"
```
Asegúrate de haber configurado tu API key en el dashboard en la nube de 9Router.
## Configuración avanzada
### Timeout personalizado
```bash
export OPENAI_TIMEOUT=60 # segundos
```
### Modo debug
Habilita el modo debug para ver logs detallados de request/response:
```bash
export CODEX_DEBUG=true
codex --model cx/gpt-5.2-codex "Your prompt"
```
+249
View File
@@ -0,0 +1,249 @@
# Integración con la extensión Continue de VSCode
Integra 9Router con la extensión Continue para llevar la asistencia de IA directamente a Visual Studio Code.
## Requisitos previos
- Visual Studio Code instalado
- Extensión Continue instalada desde el marketplace de VSCode
- API key de 9Router desde el [dashboard](https://9router.com/dashboard)
- 9Router ejecutándose (local o en la nube)
## Pasos de configuración
### 1. Abrir la configuración de Continue
1. Abre VSCode
2. Presiona `Cmd+Shift+P` (Mac) o `Ctrl+Shift+P` (Windows/Linux)
3. Escribe "Continue: Open Config" y selecciónalo
4. Esto abre `~/.continue/config.json`
### 2. Agregar configuración de modelo de 9Router
Agrega la siguiente configuración a tu `config.json`:
**Configuración de un solo modelo:**
```json
{
"models": [
{
"title": "9Router - Claude Opus",
"provider": "openai",
"model": "cc/claude-opus-4-5-20251101",
"apiKey": "your-api-key-from-dashboard",
"apiBase": "http://localhost:20128/v1"
}
]
}
```
**Configuración de múltiples modelos:**
```json
{
"models": [
{
"title": "9Router - Claude Opus (Best)",
"provider": "openai",
"model": "cc/claude-opus-4-5-20251101",
"apiKey": "your-api-key-from-dashboard",
"apiBase": "http://localhost:20128/v1"
},
{
"title": "9Router - Claude Sonnet (Balanced)",
"provider": "openai",
"model": "cc/claude-sonnet-4-20250514",
"apiKey": "your-api-key-from-dashboard",
"apiBase": "http://localhost:20128/v1"
},
{
"title": "9Router - DeepSeek Chat (Code)",
"provider": "openai",
"model": "cx/deepseek-chat",
"apiKey": "your-api-key-from-dashboard",
"apiBase": "http://localhost:20128/v1"
},
{
"title": "9Router - Claude Haiku (Fast)",
"provider": "openai",
"model": "cc/claude-haiku-4-20250514",
"apiKey": "your-api-key-from-dashboard",
"apiBase": "http://localhost:20128/v1"
}
]
}
```
**Para 9Router en la nube:**
Reemplaza `apiBase` con:
```json
"apiBase": "https://9router.com/v1"
```
### 3. Guardar y recargar
1. Guarda el archivo de configuración
2. Recarga la ventana de VSCode: `Cmd+Shift+P` → "Developer: Reload Window"
3. La extensión Continue cargará la nueva configuración
### 4. Seleccionar modelo
1. Abre la barra lateral de Continue (clic en el ícono de Continue en el panel izquierdo)
2. Clic en el dropdown selector de modelo en la parte superior
3. Elige tu modelo preferido de 9Router
## Modelos disponibles
### Modelos Claude (Anthropic)
- `cc/claude-opus-4-5-20251101` - El más capaz, ideal para tareas complejas
- `cc/claude-sonnet-4-20250514` - Rendimiento y velocidad equilibrados
- `cc/claude-haiku-4-20250514` - El más rápido, bueno para tareas simples
### Modelos DeepSeek
- `cx/deepseek-chat` - Excelente para generación de código
- `cx/deepseek-reasoner` - Mejor para resolución de problemas complejos
### Modelos GLM (Zhipu AI)
- `glm/glm-4-plus` - Chino e inglés avanzado
- `glm/glm-4-flash` - Respuestas rápidas
## Ejemplos de uso
### Explicación de código
1. Selecciona código en el editor
2. Abre la barra lateral de Continue
3. Escribe: "Explain this code"
4. Modelo: `cc/claude-sonnet-4-20250514`
### Generación de código
1. Abre la barra lateral de Continue
2. Escribe: "Create a React component for user profile card"
3. Modelo: `cx/deepseek-chat`
### Refactorización
1. Selecciona código para refactorizar
2. Escribe: "Refactor this to use async/await"
3. Modelo: `cc/claude-sonnet-4-20250514`
### Corrección de bugs
1. Selecciona código problemático
2. Escribe: "Find and fix the bug in this code"
3. Modelo: `cx/deepseek-reasoner`
## Configuración avanzada
### Prompts de sistema personalizados
Agrega prompts de sistema personalizados para comportamientos específicos:
```json
{
"models": [
{
"title": "9Router - Code Expert",
"provider": "openai",
"model": "cx/deepseek-chat",
"apiKey": "your-api-key",
"apiBase": "http://localhost:20128/v1",
"systemMessage": "You are an expert programmer. Always provide clean, well-documented code with best practices."
}
]
}
```
### Temperatura y parámetros
Ajusta el comportamiento del modelo con parámetros:
```json
{
"models": [
{
"title": "9Router - Creative Writer",
"provider": "openai",
"model": "cc/claude-opus-4-5-20251101",
"apiKey": "your-api-key",
"apiBase": "http://localhost:20128/v1",
"temperature": 0.9,
"topP": 0.95
}
]
}
```
### Proveedores de contexto
Configura qué contexto envía Continue al modelo:
```json
{
"contextProviders": [
{
"name": "code",
"params": {
"maxLines": 100
}
},
{
"name": "diff",
"params": {}
},
{
"name": "terminal",
"params": {}
}
]
}
```
## Atajos de teclado
- `Cmd+L` (Mac) / `Ctrl+L` (Windows/Linux) - Abrir chat de Continue
- `Cmd+I` (Mac) / `Ctrl+I` (Windows/Linux) - Edición inline
- `Cmd+Shift+R` (Mac) / `Ctrl+Shift+R` (Windows/Linux) - Regenerar respuesta
## Solución de problemas
### El modelo no responde
- Verifica que 9Router esté corriendo: `curl http://localhost:20128/health`
- Verifica la API key en config.json
- Revisa la consola de desarrollador de VSCode por errores: `Help``Toggle Developer Tools`
### Modelo incorrecto seleccionado
- Clic en el dropdown de modelo en la barra lateral de Continue
- Selecciona el modelo correcto de 9Router
- El nombre del modelo debe coincidir exactamente (sensible a mayúsculas)
### La configuración no se carga
- Verifica que la sintaxis JSON sea válida (usa un validador de JSON)
- Verifica la ubicación del archivo: `~/.continue/config.json`
- Recarga la ventana de VSCode después de cambios
### Rendimiento lento
- Cambia a modelos más rápidos (haiku, flash)
- Reduce el tamaño del contexto en contextProviders
- Verifica la latencia de red hacia 9Router
## Mejores prácticas
### Estrategia de selección de modelo
- **Ediciones rápidas**: Usa `cc/claude-haiku-4-20250514`
- **Generación de código**: Usa `cx/deepseek-chat`
- **Refactoring complejo**: Usa `cc/claude-opus-4-5-20251101`
- **Resolución de problemas**: Usa `cx/deepseek-reasoner`
### Gestión de contexto
- Selecciona solo el código relevante antes de preguntar
- Usa prompts específicos y claros
- Divide tareas complejas en pasos más pequeños
### Optimización de costos
- Usa modelos más rápidos/baratos para tareas simples
- Limita el tamaño del contexto cuando sea posible
- Cachea respuestas usadas con frecuencia
## Próximos pasos
- [Configurar Cursor](cursor.md) para integración mejorada con IDE
- [Configurar Roo](roo.md) para asistente de IA
- [Explorar uso de CLI](../cli/basic-usage.md)
- [Aprende sobre la selección de modelos](../models/overview.md)
+149
View File
@@ -0,0 +1,149 @@
# Integración con Cursor
Integra 9Router con Cursor IDE para enrutar tus solicitudes de IA a través del sistema de enrutamiento inteligente de 9Router.
## Requisitos previos
- Cursor IDE instalado
- Cuenta Cursor Pro (requerida para endpoints de API personalizados)
- Endpoint en la nube de 9Router configurado
- API key del dashboard de 9Router
## ⚠️ Notas importantes
> **Endpoint en la nube requerido**: Cursor enruta solicitudes a través de su propio servidor y no soporta endpoints localhost. Debes usar el endpoint en la nube de 9Router: `https://9router.com`
> **Cursor Pro requerido**: Esta característica requiere una cuenta Cursor Pro para usar endpoints de API personalizados.
## Configuración
### 1. Abrir la configuración de Cursor
1. Abre Cursor IDE
2. Ve a **Settings** (Cmd/Ctrl + ,)
3. Navega a la sección **Models**
### 2. Habilitar OpenAI API
1. Encuentra la opción **OpenAI API key**
2. Activa el toggle para habilitar la configuración de API personalizada
### 3. Configurar Base URL
Establece la URL base al endpoint en la nube de 9Router:
```
https://9router.com
```
**Pasos:**
1. En la configuración de Models, localiza el campo **Base URL**
2. Ingresa: `https://9router.com`
3. Clic en **Save**
### 4. Agregar API Key
1. En el campo **API Key**, ingresa tu API key de 9Router
2. Puedes encontrar tu API key en el dashboard de 9Router en **Settings → API Keys**
3. Clic en **Save**
### 5. Agregar modelo personalizado
1. Clic en el botón **View All Models**
2. Clic en **Add Custom Model**
3. Ingresa el nombre del modelo desde tu configuración de 9Router (ej. `gpt-4`, `claude-opus-4-5`, etc.)
4. Clic en **Add**
### 6. Seleccionar modelo
1. En la interfaz de chat de Cursor, clic en el dropdown selector de modelo
2. Elige tu modelo personalizado de la lista
3. ¡Empieza a usar 9Router con Cursor!
## Ejemplo de configuración
Tu configuración de Cursor debería verse así:
```
OpenAI API: ✓ Enabled
Base URL: https://9router.com
API Key: sk-9router-xxxxxxxxxxxxx
Custom Models: gpt-4, claude-opus-4-5, gemini-2.0-flash
```
## Modelos disponibles
Puedes usar cualquier modelo configurado en tu dashboard de 9Router. Ejemplos comunes:
| Nombre del modelo | Proveedor | Descripción |
|------------|----------|-------------|
| `gpt-4` | OpenAI | GPT-4 Turbo |
| `gpt-4o` | OpenAI | GPT-4 Optimized |
| `claude-opus-4-5` | Anthropic | Claude Opus 4.5 |
| `claude-sonnet-4-5` | Anthropic | Claude Sonnet 4.5 |
| `gemini-2.0-flash` | Google | Gemini 2.0 Flash |
## Uso
### Interfaz de chat
1. Abre el chat de Cursor (Cmd/Ctrl + L)
2. Selecciona tu modelo del dropdown
3. Comienza a chatear con IA a través de 9Router
### Generación de código inline
1. Selecciona código en tu editor
2. Presiona Cmd/Ctrl + K
3. Ingresa tu prompt
4. Cursor usará 9Router para generar código
### Explicación de código
1. Selecciona código en tu editor
2. Presiona Cmd/Ctrl + L
3. Pregunta "Explain this code"
4. Obtén explicaciones potenciadas por IA a través de 9Router
## Solución de problemas
### Error "Invalid API Key"
1. Verifica tu API key en el dashboard de 9Router
2. Asegúrate de haber copiado la key completa incluyendo el prefijo `sk-9router-`
3. Verifica que la API key no haya expirado
4. Intenta regenerar una nueva API key
### Error "Model Not Found"
1. Verifica que el nombre del modelo coincida exactamente con tu configuración de 9Router
2. Verifica que la conexión del proveedor esté activa en el dashboard de 9Router
3. Asegúrate de que el modelo esté disponible en tus proveedores conectados
4. Intenta usar el nombre completo del modelo (ej. `openai/gpt-4` en lugar de `gpt-4`)
### Problemas de conexión
1. Verifica que estés usando el endpoint en la nube: `https://9router.com`
2. Verifica tu conexión a internet
3. Asegúrate de que el servicio en la nube de 9Router esté operativo
4. Intenta deshabilitar VPN o proxy si está habilitado
### Localhost no funciona
> **Recuerda**: Cursor no soporta endpoints localhost. Debes usar el endpoint en la nube `https://9router.com`. Si necesitas usar una instancia local de 9Router, considera usar un servicio de tunneling como ngrok para exponer tu endpoint local.
## Configuración del endpoint en la nube
Si estás ejecutando 9Router localmente y quieres usarlo con Cursor:
1. Habilita el endpoint en la nube en la configuración de 9Router
2. Configura tu URL del endpoint en la nube en el dashboard de 9Router
3. Usa la URL en la nube en la configuración de Cursor
4. Asegúrate de que tu instancia local de 9Router sea accesible desde internet
## Mejores prácticas
1. **Usa aliases de modelos**: Crea aliases cortos para modelos usados con frecuencia en 9Router
2. **Monitorea el uso**: Revisa el dashboard de 9Router para estadísticas de uso y costos
3. **Rota las API Keys**: Rota tus API keys regularmente por seguridad
4. **Prueba modelos**: Prueba diferentes modelos para encontrar el mejor para tu caso de uso

Some files were not shown because too many files have changed in this diff Show More