chore: import upstream snapshot with attribution
Deploy GitBook to 9router.github.io / build-deploy (push) Failing after 2s
Deploy GitBook to 9router.github.io / build-deploy (push) Failing after 2s
This commit is contained in:
@@ -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*
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
version: 2
|
||||
updates: []
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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/
|
||||
|
||||
Vendored
+20
@@ -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
@@ -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
|
||||
@@ -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(...)`).
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`decolua/9router`
|
||||
- 原始仓库:https://github.com/decolua/9router
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
+1311
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"dockerfilePath": "./Dockerfile"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
app/*
|
||||
node_modules/*
|
||||
@@ -0,0 +1,9 @@
|
||||
# Ignore everything except what's in package.json "files"
|
||||
*
|
||||
!cli.js
|
||||
!hooks/
|
||||
!app/
|
||||
!package.json
|
||||
!README.md
|
||||
!LICENSE
|
||||
|
||||
+42
@@ -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
@@ -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.**
|
||||
|
||||
[](https://www.npmjs.com/package/9router)
|
||||
[](https://www.npmjs.com/package/9router)
|
||||
[](https://hub.docker.com/r/decolua/9router)
|
||||
[](https://github.com/decolua/9router/pkgs/container/9router)
|
||||
[](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
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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); });
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 |
@@ -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
|
||||
};
|
||||
@@ -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()
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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");
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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)
|
||||
@@ -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
Reference in New Issue
Block a user